feat: bootstrap auto virtual tryon admin frontend
This commit is contained in:
50
tests/app/api/libraries.route.test.ts
Normal file
50
tests/app/api/libraries.route.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { GET } from "../../../app/api/libraries/[libraryType]/route";
|
||||
|
||||
test("returns honest placeholder library data for unsupported backend modules", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/libraries/models"), {
|
||||
params: Promise.resolve({ libraryType: "models" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toMatchObject({
|
||||
mode: "placeholder",
|
||||
data: {
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
libraryType: "models",
|
||||
isMock: true,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
message: "资源库当前使用占位数据,真实后端接口尚未提供。",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects unsupported placeholder library types with a normalized error", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/libraries/unknown"), {
|
||||
params: Promise.resolve({ libraryType: "unknown" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload).toEqual({
|
||||
error: "NOT_FOUND",
|
||||
message: "不支持的资源库类型。",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects inherited object keys instead of treating them as valid library types", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/libraries/toString"), {
|
||||
params: Promise.resolve({ libraryType: "toString" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload).toEqual({
|
||||
error: "NOT_FOUND",
|
||||
message: "不支持的资源库类型。",
|
||||
});
|
||||
});
|
||||
154
tests/app/api/order-revisions.route.test.ts
Normal file
154
tests/app/api/order-revisions.route.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
GET,
|
||||
POST,
|
||||
} from "../../../app/api/orders/[orderId]/revisions/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("proxies manual revision registration and normalizes success data", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
asset_id: 501,
|
||||
parent_asset_id: 11,
|
||||
root_asset_id: 11,
|
||||
version_no: 1,
|
||||
review_task_status: "revision_uploaded",
|
||||
latest_revision_asset_id: 501,
|
||||
revision_count: 1,
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/orders/101/revisions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
parent_asset_id: 11,
|
||||
uploaded_uri: "mock://manual-revision-v1",
|
||||
reviewer_id: 88,
|
||||
comment: "人工修订第一版",
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ orderId: "101" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
assetId: 501,
|
||||
parentAssetId: 11,
|
||||
rootAssetId: 11,
|
||||
versionNo: 1,
|
||||
reviewTaskStatus: "revision_uploaded",
|
||||
latestRevisionAssetId: 501,
|
||||
revisionCount: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("proxies revision chain lookup and normalizes the item list", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
order_id: 101,
|
||||
latest_revision_asset_id: 502,
|
||||
revision_count: 2,
|
||||
items: [
|
||||
{
|
||||
asset_id: 501,
|
||||
order_id: 101,
|
||||
parent_asset_id: 11,
|
||||
root_asset_id: 11,
|
||||
version_no: 1,
|
||||
is_current_version: false,
|
||||
uri: "mock://manual-revision-v1",
|
||||
created_at: "2026-03-27T00:10:00Z",
|
||||
},
|
||||
{
|
||||
asset_id: 502,
|
||||
order_id: 101,
|
||||
parent_asset_id: 501,
|
||||
root_asset_id: 11,
|
||||
version_no: 2,
|
||||
is_current_version: true,
|
||||
uri: "mock://manual-revision-v2",
|
||||
created_at: "2026-03-27T00:20:00Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/orders/101/revisions"), {
|
||||
params: Promise.resolve({ orderId: "101" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 101,
|
||||
latestRevisionAssetId: 502,
|
||||
revisionCount: 2,
|
||||
items: [
|
||||
{
|
||||
assetId: 501,
|
||||
orderId: 101,
|
||||
parentAssetId: 11,
|
||||
rootAssetId: 11,
|
||||
versionNo: 1,
|
||||
isCurrentVersion: false,
|
||||
uri: "mock://manual-revision-v1",
|
||||
createdAt: "2026-03-27T00:10:00Z",
|
||||
},
|
||||
{
|
||||
assetId: 502,
|
||||
orderId: 101,
|
||||
parentAssetId: 501,
|
||||
rootAssetId: 11,
|
||||
versionNo: 2,
|
||||
isCurrentVersion: true,
|
||||
uri: "mock://manual-revision-v2",
|
||||
createdAt: "2026-03-27T00:20:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
163
tests/app/api/orders-create.route.test.ts
Normal file
163
tests/app/api/orders-create.route.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { POST } from "../../../app/api/orders/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("proxies order creation to the backend and returns normalized success data", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
order_id: 77,
|
||||
workflow_id: "wf-77",
|
||||
status: "created",
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/orders", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_level: "mid",
|
||||
service_mode: "semi_pro",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 77,
|
||||
workflowId: "wf-77",
|
||||
status: "created",
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://backend.test/api/v1/orders",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid order creation payloads before proxying", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/orders", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_level: "low",
|
||||
service_mode: "semi_pro",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload).toMatchObject({
|
||||
error: "VALIDATION_ERROR",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects malformed JSON before validation or proxying", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/orders", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{bad json",
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload).toEqual({
|
||||
error: "INVALID_JSON",
|
||||
message: "请求体必须是合法 JSON。",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("normalizes upstream validation errors from the backend", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
detail: "scene_ref_asset_id is invalid",
|
||||
}),
|
||||
{
|
||||
status: 422,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/orders", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
customer_level: "mid",
|
||||
service_mode: "semi_pro",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload).toEqual({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "scene_ref_asset_id is invalid",
|
||||
details: {
|
||||
detail: "scene_ref_asset_id is invalid",
|
||||
},
|
||||
});
|
||||
});
|
||||
83
tests/app/api/orders-overview.route.test.ts
Normal file
83
tests/app/api/orders-overview.route.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { GET } from "../../../app/api/dashboard/orders-overview/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("proxies recent orders overview from the backend list api", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
page: 2,
|
||||
limit: 3,
|
||||
total: 5,
|
||||
total_pages: 2,
|
||||
items: [
|
||||
{
|
||||
order_id: 3,
|
||||
workflow_id: "order-3",
|
||||
customer_level: "mid",
|
||||
service_mode: "semi_pro",
|
||||
status: "waiting_review",
|
||||
current_step: "review",
|
||||
updated_at: "2026-03-27T14:00:03Z",
|
||||
final_asset_id: null,
|
||||
review_task_status: "revision_uploaded",
|
||||
latest_revision_asset_id: 8,
|
||||
latest_revision_version: 1,
|
||||
revision_count: 1,
|
||||
pending_manual_confirm: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://frontend.test/api/dashboard/orders-overview?page=2&limit=3&status=waiting_review&query=order-3"),
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
page: 2,
|
||||
limit: 3,
|
||||
total: 5,
|
||||
totalPages: 2,
|
||||
items: [
|
||||
{
|
||||
orderId: 3,
|
||||
workflowId: "order-3",
|
||||
status: "waiting_review",
|
||||
statusMeta: {
|
||||
label: "待审核",
|
||||
tone: "warning",
|
||||
},
|
||||
currentStep: "review",
|
||||
currentStepLabel: "人工审核",
|
||||
updatedAt: "2026-03-27T14:00:03Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
message: "订单总览当前显示真实后端最近订单。",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://backend.test/api/v1/orders?page=2&limit=3&status=waiting_review&query=order-3",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
64
tests/app/api/reviews-confirm-revision.route.test.ts
Normal file
64
tests/app/api/reviews-confirm-revision.route.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { POST } from "../../../app/api/reviews/[orderId]/confirm-revision/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("proxies revision confirmation and normalizes response data", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
revision_asset_id: 501,
|
||||
decision: "approve",
|
||||
status: "submitted",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/reviews/101/confirm-revision", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reviewer_id: 88,
|
||||
comment: "确认继续流水线",
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ orderId: "101" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
revisionAssetId: 501,
|
||||
decision: "approve",
|
||||
decisionMeta: {
|
||||
label: "通过",
|
||||
tone: "success",
|
||||
},
|
||||
status: "submitted",
|
||||
},
|
||||
});
|
||||
});
|
||||
160
tests/app/api/reviews-pending.route.test.ts
Normal file
160
tests/app/api/reviews-pending.route.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { GET } from "../../../app/api/reviews/pending/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("adapts an empty pending review list into a business-empty queue state", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/reviews/pending"));
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
items: [],
|
||||
state: {
|
||||
kind: "business-empty",
|
||||
title: "暂无待审核订单",
|
||||
description: "当前没有等待人工处理的审核任务。",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes upstream server errors for pending review proxying", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
detail: "database unavailable",
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/reviews/pending"));
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(payload).toEqual({
|
||||
error: "BACKEND_ERROR",
|
||||
message: "database unavailable",
|
||||
details: {
|
||||
detail: "database unavailable",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("enriches pending review items with workflow summary chips", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
|
||||
if (url === "http://backend.test/api/v1/reviews/pending") {
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
review_task_id: 301,
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
current_step: "review",
|
||||
created_at: "2026-03-27T00:00:00Z",
|
||||
},
|
||||
]),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "http://backend.test/api/v1/workflows/101") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
workflow_type: "mid_end",
|
||||
workflow_status: "waiting_review",
|
||||
current_step: "review",
|
||||
steps: [
|
||||
{
|
||||
id: 1,
|
||||
workflow_run_id: 9001,
|
||||
step_name: "fusion",
|
||||
step_status: "failed",
|
||||
input_json: null,
|
||||
output_json: null,
|
||||
error_message: "fusion failed",
|
||||
started_at: "2026-03-27T00:07:00Z",
|
||||
ended_at: "2026-03-27T00:08:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
workflow_run_id: 9001,
|
||||
step_name: "review",
|
||||
step_status: "waiting",
|
||||
input_json: {
|
||||
preview_uri: "mock://fusion-preview",
|
||||
},
|
||||
output_json: null,
|
||||
error_message: null,
|
||||
started_at: "2026-03-27T00:09:00Z",
|
||||
ended_at: null,
|
||||
},
|
||||
],
|
||||
created_at: "2026-03-27T00:00:00Z",
|
||||
updated_at: "2026-03-27T00:10:00Z",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled fetch url: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await GET(new Request("http://localhost/api/reviews/pending"));
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.data.items[0]).toMatchObject({
|
||||
orderId: 101,
|
||||
workflowType: "mid_end",
|
||||
hasMockAssets: true,
|
||||
failureCount: 1,
|
||||
});
|
||||
});
|
||||
144
tests/app/api/reviews-submit.route.test.ts
Normal file
144
tests/app/api/reviews-submit.route.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { POST } from "../../../app/api/reviews/[orderId]/submit/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("proxies review submission through a dynamic route and normalizes success data", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
decision: "approve",
|
||||
status: "queued",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/reviews/101/submit", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
decision: "approve",
|
||||
reviewer_id: 7,
|
||||
selected_asset_id: null,
|
||||
comment: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ orderId: "101" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
decision: "approve",
|
||||
decisionMeta: {
|
||||
label: "通过",
|
||||
tone: "success",
|
||||
},
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://backend.test/api/v1/reviews/101/submit",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid dynamic path params before proxying review submission", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/reviews/not-a-number/submit", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
decision: "approve",
|
||||
reviewer_id: 7,
|
||||
selected_asset_id: null,
|
||||
comment: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ orderId: "not-a-number" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload).toEqual({
|
||||
error: "VALIDATION_ERROR",
|
||||
message: "orderId 必须是正整数。",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("normalizes upstream not-found responses on review submission", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
detail: "review task not found",
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("http://localhost/api/reviews/999/submit", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
decision: "approve",
|
||||
reviewer_id: 7,
|
||||
selected_asset_id: null,
|
||||
comment: null,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ orderId: "999" }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload).toEqual({
|
||||
error: "NOT_FOUND",
|
||||
message: "review task not found",
|
||||
});
|
||||
});
|
||||
83
tests/app/api/workflow-lookup.route.test.ts
Normal file
83
tests/app/api/workflow-lookup.route.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { GET } from "../../../app/api/dashboard/workflow-lookup/route";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("proxies workflow lookup items from the backend workflows list api", async () => {
|
||||
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
page: 2,
|
||||
limit: 4,
|
||||
total: 7,
|
||||
total_pages: 2,
|
||||
items: [
|
||||
{
|
||||
order_id: 3,
|
||||
workflow_id: "order-3",
|
||||
workflow_type: "MidEndPipelineWorkflow",
|
||||
workflow_status: "waiting_review",
|
||||
current_step: "review",
|
||||
updated_at: "2026-03-27T14:00:03Z",
|
||||
failure_count: 0,
|
||||
review_task_status: "revision_uploaded",
|
||||
latest_revision_asset_id: 8,
|
||||
latest_revision_version: 1,
|
||||
revision_count: 1,
|
||||
pending_manual_confirm: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://frontend.test/api/dashboard/workflow-lookup?page=2&limit=4&status=waiting_review&query=4201"),
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toEqual({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
page: 2,
|
||||
limit: 4,
|
||||
total: 7,
|
||||
totalPages: 2,
|
||||
items: [
|
||||
{
|
||||
orderId: 3,
|
||||
workflowId: "order-3",
|
||||
workflowType: "MidEndPipelineWorkflow",
|
||||
status: "waiting_review",
|
||||
statusMeta: {
|
||||
label: "待审核",
|
||||
tone: "warning",
|
||||
},
|
||||
currentStep: "review",
|
||||
currentStepLabel: "人工审核",
|
||||
updatedAt: "2026-03-27T14:00:03Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
message: "流程追踪首页当前显示真实后端最近流程。",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://backend.test/api/v1/workflows?page=2&limit=4&status=waiting_review&query=4201",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
24
tests/features/libraries/library-page.test.tsx
Normal file
24
tests/features/libraries/library-page.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { LibraryPage } from "@/features/libraries/library-page";
|
||||
import type { LibraryItemVM } from "@/lib/types/view-models";
|
||||
|
||||
const MODEL_ITEMS: LibraryItemVM[] = [
|
||||
{
|
||||
id: "model-ava",
|
||||
libraryType: "models",
|
||||
name: "Ava / Studio",
|
||||
description: "中性棚拍模特占位数据,用于提交页联调。",
|
||||
previewUri: "mock://libraries/models/ava",
|
||||
tags: ["女装", "半身", "mock"],
|
||||
isMock: true,
|
||||
},
|
||||
];
|
||||
|
||||
test("states that the resource library is still backed by mock data", () => {
|
||||
render(<LibraryPage libraryType="models" items={MODEL_ITEMS} />);
|
||||
|
||||
expect(screen.getByText("当前资源库仍使用 mock 数据")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ava / Studio")).toBeInTheDocument();
|
||||
});
|
||||
99
tests/features/orders/order-detail.test.tsx
Normal file
99
tests/features/orders/order-detail.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { test, expect } from "vitest";
|
||||
|
||||
import { OrderDetail } from "@/features/orders/order-detail";
|
||||
import type { OrderDetailVM } from "@/lib/types/view-models";
|
||||
|
||||
const BASE_ORDER_DETAIL: OrderDetailVM = {
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
customerLevel: "mid",
|
||||
serviceMode: "semi_pro",
|
||||
status: "waiting_review",
|
||||
statusMeta: {
|
||||
label: "待审核",
|
||||
tone: "warning",
|
||||
},
|
||||
currentStep: "review",
|
||||
currentStepLabel: "人工审核",
|
||||
modelId: 1001,
|
||||
poseId: 2002,
|
||||
garmentAssetId: 3003,
|
||||
sceneRefAssetId: 4004,
|
||||
currentRevisionAssetId: null,
|
||||
currentRevisionVersion: null,
|
||||
latestRevisionAssetId: null,
|
||||
latestRevisionVersion: null,
|
||||
revisionCount: 0,
|
||||
reviewTaskStatus: null,
|
||||
pendingManualConfirm: false,
|
||||
createdAt: "2026-03-27T00:00:00Z",
|
||||
updatedAt: "2026-03-27T00:10:00Z",
|
||||
finalAsset: {
|
||||
id: 88,
|
||||
orderId: 101,
|
||||
type: "final",
|
||||
stepName: null,
|
||||
parentAssetId: null,
|
||||
rootAssetId: null,
|
||||
versionNo: 0,
|
||||
isCurrentVersion: false,
|
||||
stepLabel: "最终图",
|
||||
label: "最终图",
|
||||
uri: "mock://final-preview",
|
||||
metadata: null,
|
||||
createdAt: "2026-03-27T00:10:00Z",
|
||||
isMock: true,
|
||||
},
|
||||
finalAssetState: {
|
||||
kind: "ready",
|
||||
},
|
||||
assets: [
|
||||
{
|
||||
id: 77,
|
||||
orderId: 101,
|
||||
type: "fusion",
|
||||
stepName: "fusion",
|
||||
parentAssetId: null,
|
||||
rootAssetId: null,
|
||||
versionNo: 0,
|
||||
isCurrentVersion: false,
|
||||
stepLabel: "融合",
|
||||
label: "融合产物",
|
||||
uri: "mock://fusion-preview",
|
||||
metadata: null,
|
||||
createdAt: "2026-03-27T00:09:00Z",
|
||||
isMock: true,
|
||||
},
|
||||
],
|
||||
assetGalleryState: {
|
||||
kind: "ready",
|
||||
},
|
||||
hasMockAssets: true,
|
||||
};
|
||||
|
||||
test("shows a mock asset banner when the current order contains mock assets", () => {
|
||||
render(<OrderDetail viewModel={BASE_ORDER_DETAIL} />);
|
||||
|
||||
expect(screen.getByText("当前资产来自 mock 流程")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("最终图").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("renders the business-empty final-result state when no final asset exists", () => {
|
||||
render(
|
||||
<OrderDetail
|
||||
viewModel={{
|
||||
...BASE_ORDER_DETAIL,
|
||||
finalAsset: null,
|
||||
finalAssetState: {
|
||||
kind: "business-empty",
|
||||
title: "最终图暂未生成",
|
||||
description: "当前订单还没有可展示的最终结果。",
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("最终图暂未生成")).toBeInTheDocument();
|
||||
expect(screen.getByText("当前订单还没有可展示的最终结果。")).toBeInTheDocument();
|
||||
});
|
||||
61
tests/features/orders/orders-home.test.tsx
Normal file
61
tests/features/orders/orders-home.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { OrdersHome } from "@/features/orders/orders-home";
|
||||
import type { OrderSummaryVM } from "@/lib/types/view-models";
|
||||
|
||||
const RECENT_ORDERS: OrderSummaryVM[] = [
|
||||
{
|
||||
orderId: 4201,
|
||||
workflowId: "wf-4201",
|
||||
status: "waiting_review",
|
||||
statusMeta: {
|
||||
label: "待审核",
|
||||
tone: "warning",
|
||||
},
|
||||
currentStep: "review",
|
||||
currentStepLabel: "人工审核",
|
||||
updatedAt: "2026-03-27T09:15:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
test("shows the real recent-orders entry state", () => {
|
||||
render(<OrdersHome recentOrders={RECENT_ORDERS} />);
|
||||
|
||||
expect(screen.getByText("最近订单已接入真实后端接口")).toBeInTheDocument();
|
||||
expect(screen.getByText("订单 #4201")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("supports status filtering and pagination actions", () => {
|
||||
const onStatusChange = vi.fn();
|
||||
const onPageChange = vi.fn();
|
||||
const onQuerySubmit = vi.fn();
|
||||
|
||||
render(
|
||||
<OrdersHome
|
||||
currentPage={2}
|
||||
onPageChange={onPageChange}
|
||||
onQuerySubmit={onQuerySubmit}
|
||||
onStatusChange={onStatusChange}
|
||||
recentOrders={RECENT_ORDERS}
|
||||
selectedStatus="waiting_review"
|
||||
selectedQuery=""
|
||||
totalPages={3}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("订单关键词搜索"), {
|
||||
target: { value: "order-4201" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "搜索订单" }));
|
||||
fireEvent.change(screen.getByLabelText("订单状态筛选"), {
|
||||
target: { value: "succeeded" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "上一页" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "下一页" }));
|
||||
|
||||
expect(onQuerySubmit).toHaveBeenCalledWith("order-4201");
|
||||
expect(onStatusChange).toHaveBeenCalledWith("succeeded");
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(1, 1);
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(2, 3);
|
||||
});
|
||||
232
tests/features/orders/submit-workbench.test.tsx
Normal file
232
tests/features/orders/submit-workbench.test.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { SubmitWorkbench } from "@/features/orders/submit-workbench";
|
||||
import {
|
||||
GARMENT_LIBRARY_FIXTURES,
|
||||
MODEL_LIBRARY_FIXTURES,
|
||||
SCENE_LIBRARY_FIXTURES,
|
||||
} from "@/lib/mock/libraries";
|
||||
|
||||
const { pushMock } = vi.hoisted(() => ({
|
||||
pushMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
type LibraryType = "models" | "scenes" | "garments";
|
||||
|
||||
function createLibraryPayload(libraryType: LibraryType) {
|
||||
const fixtureMap = {
|
||||
models: MODEL_LIBRARY_FIXTURES,
|
||||
scenes: SCENE_LIBRARY_FIXTURES,
|
||||
garments: GARMENT_LIBRARY_FIXTURES,
|
||||
};
|
||||
|
||||
return {
|
||||
mode: "placeholder",
|
||||
message: "资源库当前使用占位数据,真实后端接口尚未提供。",
|
||||
data: {
|
||||
items: fixtureMap[libraryType],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFetchMock({
|
||||
orderResponse,
|
||||
}: {
|
||||
orderResponse?: Response;
|
||||
} = {}) {
|
||||
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
|
||||
if (url === "/api/libraries/models") {
|
||||
return new Response(JSON.stringify(createLibraryPayload("models")), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url === "/api/libraries/scenes") {
|
||||
return new Response(JSON.stringify(createLibraryPayload("scenes")), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url === "/api/libraries/garments") {
|
||||
return new Response(JSON.stringify(createLibraryPayload("garments")), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url === "/api/orders" && init?.method === "POST" && orderResponse) {
|
||||
return orderResponse;
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled fetch: ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pushMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("forces low customers to use auto_basic and mid customers to use semi_pro", async () => {
|
||||
vi.stubGlobal("fetch", createFetchMock());
|
||||
|
||||
render(<SubmitWorkbench />);
|
||||
|
||||
const customerLevelSelect = await screen.findByLabelText("客户层级");
|
||||
const serviceModeSelect = screen.getByLabelText("服务模式");
|
||||
|
||||
fireEvent.change(customerLevelSelect, {
|
||||
target: { value: "low" },
|
||||
});
|
||||
|
||||
expect(serviceModeSelect).toHaveValue("auto_basic");
|
||||
expect(
|
||||
screen.getByRole("option", { name: "半人工专业处理 semi_pro" }),
|
||||
).toBeDisabled();
|
||||
|
||||
fireEvent.change(customerLevelSelect, {
|
||||
target: { value: "mid" },
|
||||
});
|
||||
|
||||
expect(serviceModeSelect).toHaveValue("semi_pro");
|
||||
expect(
|
||||
screen.getByRole("option", { name: "自动基础处理 auto_basic" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("preserves selected values when order submission fails", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
createFetchMock({
|
||||
orderResponse: new Response(
|
||||
JSON.stringify({
|
||||
error: "BACKEND_UNAVAILABLE",
|
||||
message: "后端暂时不可用,请稍后重试。",
|
||||
}),
|
||||
{
|
||||
status: 502,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
render(<SubmitWorkbench />);
|
||||
|
||||
await screen.findByText("Ava / Studio");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("客户层级"), {
|
||||
target: { value: "low" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("模特资源"), {
|
||||
target: { value: "model-ava" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("场景资源"), {
|
||||
target: { value: "scene-loft" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("服装资源"), {
|
||||
target: { value: "garment-coat-01" },
|
||||
});
|
||||
|
||||
const summaryCard = screen.getByRole("region", { name: "提单摘要" });
|
||||
expect(within(summaryCard).getByText("Ava / Studio")).toBeInTheDocument();
|
||||
expect(within(summaryCard).getByText("Loft Window")).toBeInTheDocument();
|
||||
expect(within(summaryCard).getByText("Structured Coat 01")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交订单" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("后端暂时不可用,请稍后重试。"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("客户层级")).toHaveValue("low");
|
||||
expect(screen.getByLabelText("服务模式")).toHaveValue("auto_basic");
|
||||
expect(screen.getByLabelText("模特资源")).toHaveValue("model-ava");
|
||||
expect(screen.getByLabelText("场景资源")).toHaveValue("scene-loft");
|
||||
expect(screen.getByLabelText("服装资源")).toHaveValue("garment-coat-01");
|
||||
});
|
||||
|
||||
test("submits the selected resources, surfaces returned ids, and redirects to the order detail page", async () => {
|
||||
const fetchMock = createFetchMock({
|
||||
orderResponse: new Response(
|
||||
JSON.stringify({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 77,
|
||||
workflowId: "wf-77",
|
||||
status: "created",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<SubmitWorkbench />);
|
||||
|
||||
await screen.findByText("Ava / Studio");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("客户层级"), {
|
||||
target: { value: "low" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("模特资源"), {
|
||||
target: { value: "model-ava" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("场景资源"), {
|
||||
target: { value: "scene-loft" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("服装资源"), {
|
||||
target: { value: "garment-coat-01" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "提交订单" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/orders",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
customer_level: "low",
|
||||
service_mode: "auto_basic",
|
||||
model_id: 101,
|
||||
pose_id: 202,
|
||||
garment_asset_id: 303,
|
||||
scene_ref_asset_id: 404,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("订单 #77 已创建,正在跳转到详情页。"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("工作流 ID wf-77")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/orders/77");
|
||||
});
|
||||
});
|
||||
314
tests/features/reviews/review-workbench-detail.test.tsx
Normal file
314
tests/features/reviews/review-workbench-detail.test.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { ReviewWorkbenchDetailScreen } from "@/features/reviews/review-workbench-detail";
|
||||
|
||||
const pushMock = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
function createOrderDetailPayload(
|
||||
orderId = 101,
|
||||
options: { pendingManualConfirm?: boolean } = {},
|
||||
) {
|
||||
return {
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId,
|
||||
workflowId: `wf-${orderId}`,
|
||||
customerLevel: "mid",
|
||||
serviceMode: "semi_pro",
|
||||
status: "waiting_review",
|
||||
statusMeta: {
|
||||
label: "待审核",
|
||||
tone: "warning",
|
||||
},
|
||||
currentStep: "review",
|
||||
currentStepLabel: "人工审核",
|
||||
modelId: 101,
|
||||
poseId: 202,
|
||||
garmentAssetId: 303,
|
||||
sceneRefAssetId: 404,
|
||||
currentRevisionAssetId: null,
|
||||
currentRevisionVersion: null,
|
||||
latestRevisionAssetId: null,
|
||||
latestRevisionVersion: null,
|
||||
revisionCount: 0,
|
||||
reviewTaskStatus: "pending",
|
||||
pendingManualConfirm: options.pendingManualConfirm ?? false,
|
||||
createdAt: "2026-03-27T00:00:00Z",
|
||||
updatedAt: "2026-03-27T00:10:00Z",
|
||||
finalAsset: null,
|
||||
finalAssetState: {
|
||||
kind: "business-empty",
|
||||
title: "最终图暂未生成",
|
||||
description: "当前订单还没有可展示的最终结果。",
|
||||
},
|
||||
assets: [
|
||||
{
|
||||
id: 11,
|
||||
orderId,
|
||||
type: "fusion",
|
||||
stepName: "fusion",
|
||||
stepLabel: "融合",
|
||||
label: "融合产物",
|
||||
uri: "mock://fusion-preview",
|
||||
metadata: null,
|
||||
createdAt: "2026-03-27T00:09:00Z",
|
||||
isMock: true,
|
||||
},
|
||||
],
|
||||
assetGalleryState: {
|
||||
kind: "ready",
|
||||
},
|
||||
hasMockAssets: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkflowPayload(
|
||||
orderId = 101,
|
||||
options: { pendingManualConfirm?: boolean } = {},
|
||||
) {
|
||||
return {
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId,
|
||||
workflowId: `wf-${orderId}`,
|
||||
workflowType: "mid_end",
|
||||
status: "waiting_review",
|
||||
currentRevisionAssetId: null,
|
||||
currentRevisionVersion: null,
|
||||
latestRevisionAssetId: null,
|
||||
latestRevisionVersion: null,
|
||||
revisionCount: 0,
|
||||
reviewTaskStatus: "pending",
|
||||
pendingManualConfirm: options.pendingManualConfirm ?? false,
|
||||
statusMeta: {
|
||||
label: "待审核",
|
||||
tone: "warning",
|
||||
},
|
||||
currentStep: "review",
|
||||
currentStepLabel: "人工审核",
|
||||
createdAt: "2026-03-27T00:00:00Z",
|
||||
updatedAt: "2026-03-27T00:10:00Z",
|
||||
steps: [
|
||||
{
|
||||
id: 1,
|
||||
workflowRunId: 9001,
|
||||
name: "review",
|
||||
label: "人工审核",
|
||||
status: "waiting",
|
||||
statusMeta: {
|
||||
label: "等待人工处理",
|
||||
tone: "warning",
|
||||
},
|
||||
input: null,
|
||||
output: null,
|
||||
errorMessage: null,
|
||||
startedAt: "2026-03-27T00:09:00Z",
|
||||
endedAt: null,
|
||||
containsMockAssets: true,
|
||||
mockAssetUris: ["mock://fusion-preview"],
|
||||
isCurrent: true,
|
||||
isFailed: false,
|
||||
},
|
||||
],
|
||||
stepTimelineState: {
|
||||
kind: "ready",
|
||||
},
|
||||
failureCount: 0,
|
||||
hasMockAssets: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFetchMock(options: { pendingManualConfirm?: boolean } = {}) {
|
||||
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
|
||||
if (url === "/api/orders/101") {
|
||||
return new Response(JSON.stringify(createOrderDetailPayload(101, options)), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url === "/api/workflows/101") {
|
||||
return new Response(JSON.stringify(createWorkflowPayload(101, options)), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (url === "/api/reviews/101/submit" && init?.method === "POST") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
decision: "approve",
|
||||
decisionMeta: {
|
||||
label: "通过",
|
||||
tone: "success",
|
||||
},
|
||||
status: "queued",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/orders/101/revisions" && init?.method === "POST") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
assetId: 501,
|
||||
parentAssetId: 11,
|
||||
rootAssetId: 11,
|
||||
versionNo: 1,
|
||||
reviewTaskStatus: "revision_uploaded",
|
||||
latestRevisionAssetId: 501,
|
||||
revisionCount: 1,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (url === "/api/reviews/101/confirm-revision" && init?.method === "POST") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
mode: "proxy",
|
||||
data: {
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
revisionAssetId: 501,
|
||||
decision: "approve",
|
||||
decisionMeta: {
|
||||
label: "通过",
|
||||
tone: "success",
|
||||
},
|
||||
status: "submitted",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled fetch: ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
pushMock.mockReset();
|
||||
});
|
||||
|
||||
test("requires a comment before rerun_face submission in detail view", async () => {
|
||||
const fetchMock = createFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ReviewWorkbenchDetailScreen orderId={101} />);
|
||||
|
||||
await screen.findByRole("heading", { level: 1, name: "订单 #101" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "重跑 Face" }));
|
||||
|
||||
expect(await screen.findByText("请填写审核备注")).toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
"/api/reviews/101/submit",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test("submits approve then returns to the workbench list", async () => {
|
||||
const fetchMock = createFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ReviewWorkbenchDetailScreen orderId={101} />);
|
||||
|
||||
await screen.findByRole("heading", { level: 1, name: "订单 #101" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "审核通过" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/reviews/101/submit",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/reviews/workbench");
|
||||
});
|
||||
});
|
||||
|
||||
test("registers a manual revision asset from detail view", async () => {
|
||||
const fetchMock = createFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ReviewWorkbenchDetailScreen orderId={101} />);
|
||||
|
||||
await screen.findByRole("heading", { level: 1, name: "订单 #101" });
|
||||
|
||||
fireEvent.change(screen.getByLabelText("修订稿 URI"), {
|
||||
target: { value: "mock://manual-revision-v1" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("修订说明"), {
|
||||
target: { value: "人工修订第一版" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "登记人工修订稿" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/orders/101/revisions",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("confirms an uploaded manual revision and returns to the workbench list", async () => {
|
||||
const fetchMock = createFetchMock({ pendingManualConfirm: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ReviewWorkbenchDetailScreen orderId={101} />);
|
||||
|
||||
await screen.findByRole("heading", { level: 1, name: "订单 #101" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认继续流水线" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/reviews/101/confirm-revision",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushMock).toHaveBeenCalledWith("/reviews/workbench");
|
||||
});
|
||||
});
|
||||
62
tests/features/reviews/review-workbench-list.test.tsx
Normal file
62
tests/features/reviews/review-workbench-list.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
|
||||
import { ReviewWorkbenchListScreen } from "@/features/reviews/review-workbench-list";
|
||||
|
||||
function createPendingPayload() {
|
||||
return {
|
||||
mode: "proxy",
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
reviewTaskId: 301,
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
workflowType: "mid_end",
|
||||
currentStep: "review",
|
||||
currentStepLabel: "人工审核",
|
||||
createdAt: "2026-03-27T00:00:00Z",
|
||||
status: "waiting_review",
|
||||
statusMeta: {
|
||||
label: "待审核",
|
||||
tone: "warning",
|
||||
},
|
||||
hasMockAssets: true,
|
||||
failureCount: 2,
|
||||
},
|
||||
],
|
||||
state: {
|
||||
kind: "ready",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("renders medium-density list rows that link into independent review detail pages", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(createPendingPayload()), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ReviewWorkbenchListScreen />);
|
||||
|
||||
expect(await screen.findByText("审核目标 #101")).toBeInTheDocument();
|
||||
expect(screen.getByText(/工作流 wf-101/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Mock 资产")).toBeInTheDocument();
|
||||
expect(screen.getByText("失败 2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("审核动作面板")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /审核目标 #101/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/reviews/workbench/101",
|
||||
);
|
||||
});
|
||||
84
tests/features/workflows/workflow-detail.test.tsx
Normal file
84
tests/features/workflows/workflow-detail.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { WorkflowDetail } from "@/features/workflows/workflow-detail";
|
||||
import type { WorkflowDetailVM } from "@/lib/types/view-models";
|
||||
|
||||
const BASE_WORKFLOW_DETAIL: WorkflowDetailVM = {
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
workflowType: "mid_end",
|
||||
status: "waiting_review",
|
||||
statusMeta: {
|
||||
label: "待审核",
|
||||
tone: "warning",
|
||||
},
|
||||
currentStep: "review",
|
||||
currentStepLabel: "人工审核",
|
||||
currentRevisionAssetId: null,
|
||||
currentRevisionVersion: null,
|
||||
latestRevisionAssetId: null,
|
||||
latestRevisionVersion: null,
|
||||
revisionCount: 0,
|
||||
reviewTaskStatus: null,
|
||||
pendingManualConfirm: false,
|
||||
createdAt: "2026-03-27T00:00:00Z",
|
||||
updatedAt: "2026-03-27T00:10:00Z",
|
||||
steps: [
|
||||
{
|
||||
id: 11,
|
||||
workflowRunId: 9001,
|
||||
name: "fusion",
|
||||
label: "融合",
|
||||
status: "failed",
|
||||
statusMeta: {
|
||||
label: "失败",
|
||||
tone: "danger",
|
||||
},
|
||||
input: null,
|
||||
output: null,
|
||||
errorMessage: "Temporal activity timed out.",
|
||||
startedAt: "2026-03-27T00:06:00Z",
|
||||
endedAt: "2026-03-27T00:07:00Z",
|
||||
containsMockAssets: false,
|
||||
mockAssetUris: [],
|
||||
isCurrent: false,
|
||||
isFailed: true,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
workflowRunId: 9001,
|
||||
name: "review",
|
||||
label: "人工审核",
|
||||
status: "waiting",
|
||||
statusMeta: {
|
||||
label: "等待人工处理",
|
||||
tone: "warning",
|
||||
},
|
||||
input: null,
|
||||
output: {
|
||||
preview_uri: "mock://fusion-preview",
|
||||
},
|
||||
errorMessage: null,
|
||||
startedAt: "2026-03-27T00:09:00Z",
|
||||
endedAt: null,
|
||||
containsMockAssets: true,
|
||||
mockAssetUris: ["mock://fusion-preview"],
|
||||
isCurrent: true,
|
||||
isFailed: false,
|
||||
},
|
||||
],
|
||||
stepTimelineState: {
|
||||
kind: "ready",
|
||||
},
|
||||
failureCount: 1,
|
||||
hasMockAssets: true,
|
||||
};
|
||||
|
||||
test("highlights failed steps and mock asset hints in the workflow timeline", () => {
|
||||
render(<WorkflowDetail viewModel={BASE_WORKFLOW_DETAIL} />);
|
||||
|
||||
expect(screen.getByText("失败步骤 1 个")).toBeInTheDocument();
|
||||
expect(screen.getByText("Temporal activity timed out.")).toBeInTheDocument();
|
||||
expect(screen.getByText("当前流程包含 mock 资产")).toBeInTheDocument();
|
||||
});
|
||||
62
tests/features/workflows/workflow-lookup.test.tsx
Normal file
62
tests/features/workflows/workflow-lookup.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { WorkflowLookup } from "@/features/workflows/workflow-lookup";
|
||||
import type { WorkflowLookupItemVM } from "@/lib/types/view-models";
|
||||
|
||||
const WORKFLOW_ITEMS: WorkflowLookupItemVM[] = [
|
||||
{
|
||||
orderId: 4201,
|
||||
workflowId: "wf-4201",
|
||||
workflowType: "MidEndPipelineWorkflow",
|
||||
status: "waiting_review",
|
||||
statusMeta: {
|
||||
label: "待审核",
|
||||
tone: "warning",
|
||||
},
|
||||
currentStep: "review",
|
||||
currentStepLabel: "人工审核",
|
||||
updatedAt: "2026-03-27T09:15:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
test("shows the real recent-workflows entry state", () => {
|
||||
render(<WorkflowLookup items={WORKFLOW_ITEMS} />);
|
||||
|
||||
expect(screen.getByText("流程追踪首页当前显示真实后端最近流程。")).toBeInTheDocument();
|
||||
expect(screen.getByText("订单 #4201")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("supports workflow status filtering and pagination actions", () => {
|
||||
const onStatusChange = vi.fn();
|
||||
const onPageChange = vi.fn();
|
||||
const onQuerySubmit = vi.fn();
|
||||
|
||||
render(
|
||||
<WorkflowLookup
|
||||
currentPage={2}
|
||||
items={WORKFLOW_ITEMS}
|
||||
onPageChange={onPageChange}
|
||||
onQuerySubmit={onQuerySubmit}
|
||||
onStatusChange={onStatusChange}
|
||||
selectedStatus="waiting_review"
|
||||
selectedQuery=""
|
||||
totalPages={3}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("流程关键词搜索"), {
|
||||
target: { value: "4201" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "搜索流程" }));
|
||||
fireEvent.change(screen.getByLabelText("流程状态筛选"), {
|
||||
target: { value: "failed" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "上一页" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "下一页" }));
|
||||
|
||||
expect(onQuerySubmit).toHaveBeenCalledWith("4201");
|
||||
expect(onStatusChange).toHaveBeenCalledWith("failed");
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(1, 1);
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(2, 3);
|
||||
});
|
||||
97
tests/lib/adapters/orders.test.ts
Normal file
97
tests/lib/adapters/orders.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { adaptOrderDetail, adaptOrderSummary } from "@/lib/adapters/orders";
|
||||
import type { AssetDto, OrderDetailResponseDto } from "@/lib/types/backend";
|
||||
|
||||
const ORDER_BASE: OrderDetailResponseDto = {
|
||||
order_id: 101,
|
||||
customer_level: "mid",
|
||||
service_mode: "semi_pro",
|
||||
status: "waiting_review",
|
||||
model_id: 7,
|
||||
pose_id: 8,
|
||||
garment_asset_id: 9,
|
||||
scene_ref_asset_id: 10,
|
||||
final_asset_id: null,
|
||||
workflow_id: "wf-101",
|
||||
current_step: "review",
|
||||
current_revision_asset_id: null,
|
||||
current_revision_version: null,
|
||||
latest_revision_asset_id: null,
|
||||
latest_revision_version: null,
|
||||
revision_count: 0,
|
||||
review_task_status: null,
|
||||
pending_manual_confirm: false,
|
||||
final_asset: null,
|
||||
created_at: "2026-03-27T00:00:00Z",
|
||||
updated_at: "2026-03-27T00:10:00Z",
|
||||
};
|
||||
|
||||
const ASSET_BASE: AssetDto = {
|
||||
id: 11,
|
||||
order_id: 101,
|
||||
asset_type: "final",
|
||||
step_name: "export",
|
||||
parent_asset_id: null,
|
||||
root_asset_id: null,
|
||||
version_no: 0,
|
||||
is_current_version: false,
|
||||
uri: "https://cdn.example.com/final-11.png",
|
||||
metadata_json: null,
|
||||
created_at: "2026-03-27T00:09:00Z",
|
||||
};
|
||||
|
||||
test("marks mock asset uris as mock previews", () => {
|
||||
const viewModel = adaptOrderDetail({
|
||||
...ORDER_BASE,
|
||||
final_asset_id: 11,
|
||||
final_asset: {
|
||||
...ASSET_BASE,
|
||||
uri: "mock://result-11",
|
||||
},
|
||||
});
|
||||
|
||||
expect(viewModel.finalAsset?.isMock).toBe(true);
|
||||
expect(viewModel.hasMockAssets).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps missing final assets and empty asset lists as business-empty states", () => {
|
||||
const viewModel = adaptOrderDetail(ORDER_BASE, []);
|
||||
|
||||
expect(viewModel.finalAsset).toBeNull();
|
||||
expect(viewModel.finalAssetState.kind).toBe("business-empty");
|
||||
expect(viewModel.assets).toEqual([]);
|
||||
expect(viewModel.assetGalleryState.kind).toBe("business-empty");
|
||||
});
|
||||
|
||||
test("maps order summary status metadata and current step labels", () => {
|
||||
const viewModel = adaptOrderSummary(ORDER_BASE);
|
||||
|
||||
expect(viewModel).toMatchObject({
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
status: "waiting_review",
|
||||
currentStep: "review",
|
||||
currentStepLabel: "人工审核",
|
||||
statusMeta: {
|
||||
label: "待审核",
|
||||
tone: "warning",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("maps key order detail labels and status metadata", () => {
|
||||
const viewModel = adaptOrderDetail(
|
||||
{
|
||||
...ORDER_BASE,
|
||||
status: "failed",
|
||||
current_step: "fusion",
|
||||
},
|
||||
[ASSET_BASE],
|
||||
);
|
||||
|
||||
expect(viewModel.statusMeta).toEqual({
|
||||
label: "失败",
|
||||
tone: "danger",
|
||||
});
|
||||
expect(viewModel.currentStepLabel).toBe("融合");
|
||||
expect(viewModel.assetGalleryState.kind).toBe("ready");
|
||||
});
|
||||
59
tests/lib/adapters/reviews.test.ts
Normal file
59
tests/lib/adapters/reviews.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { adaptPendingReviews, adaptReviewSubmission } from "@/lib/adapters/reviews";
|
||||
import type {
|
||||
PendingReviewResponseDto,
|
||||
SubmitReviewResponseDto,
|
||||
} from "@/lib/types/backend";
|
||||
|
||||
const PENDING_REVIEW_BASE: PendingReviewResponseDto = {
|
||||
review_task_id: 301,
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
current_step: "review",
|
||||
review_task_status: "pending",
|
||||
latest_revision_asset_id: null,
|
||||
current_revision_asset_id: null,
|
||||
latest_revision_version: null,
|
||||
revision_count: 0,
|
||||
pending_manual_confirm: false,
|
||||
created_at: "2026-03-27T00:00:00Z",
|
||||
};
|
||||
|
||||
const REVIEW_SUBMISSION_BASE: SubmitReviewResponseDto = {
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
decision: "rerun_scene",
|
||||
status: "queued",
|
||||
};
|
||||
|
||||
test("returns a business-empty queue state when no reviews are pending", () => {
|
||||
const viewModel = adaptPendingReviews([]);
|
||||
|
||||
expect(viewModel.items).toEqual([]);
|
||||
expect(viewModel.state.kind).toBe("business-empty");
|
||||
});
|
||||
|
||||
test("maps pending reviews to waiting-review queue items", () => {
|
||||
const viewModel = adaptPendingReviews([PENDING_REVIEW_BASE]);
|
||||
|
||||
expect(viewModel.state.kind).toBe("ready");
|
||||
expect(viewModel.items[0]).toMatchObject({
|
||||
orderId: 101,
|
||||
reviewTaskId: 301,
|
||||
status: "waiting_review",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps review submission decision metadata", () => {
|
||||
const viewModel = adaptReviewSubmission(REVIEW_SUBMISSION_BASE);
|
||||
|
||||
expect(viewModel).toMatchObject({
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
decision: "rerun_scene",
|
||||
status: "queued",
|
||||
decisionMeta: {
|
||||
label: "重跑场景",
|
||||
tone: "warning",
|
||||
},
|
||||
});
|
||||
});
|
||||
87
tests/lib/adapters/workflows.test.ts
Normal file
87
tests/lib/adapters/workflows.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
adaptWorkflowDetail,
|
||||
adaptWorkflowLookupItem,
|
||||
} from "@/lib/adapters/workflows";
|
||||
import type { WorkflowStatusResponseDto } from "@/lib/types/backend";
|
||||
|
||||
const WORKFLOW_BASE: WorkflowStatusResponseDto = {
|
||||
order_id: 101,
|
||||
workflow_id: "wf-101",
|
||||
workflow_type: "mid_end",
|
||||
workflow_status: "running",
|
||||
current_step: "fusion",
|
||||
current_revision_asset_id: null,
|
||||
current_revision_version: null,
|
||||
latest_revision_asset_id: null,
|
||||
latest_revision_version: null,
|
||||
revision_count: 0,
|
||||
review_task_status: null,
|
||||
pending_manual_confirm: false,
|
||||
steps: [],
|
||||
created_at: "2026-03-27T00:00:00Z",
|
||||
updated_at: "2026-03-27T00:10:00Z",
|
||||
};
|
||||
|
||||
test("keeps an empty workflow timeline as a business-empty state", () => {
|
||||
const viewModel = adaptWorkflowDetail(WORKFLOW_BASE);
|
||||
|
||||
expect(viewModel.steps).toEqual([]);
|
||||
expect(viewModel.stepTimelineState.kind).toBe("business-empty");
|
||||
});
|
||||
|
||||
test("tags nested mock asset uris found in workflow step payloads", () => {
|
||||
const viewModel = adaptWorkflowDetail({
|
||||
...WORKFLOW_BASE,
|
||||
steps: [
|
||||
{
|
||||
id: 1,
|
||||
workflow_run_id: 9001,
|
||||
step_name: "fusion",
|
||||
step_status: "failed",
|
||||
input_json: null,
|
||||
output_json: {
|
||||
preview_uri: "mock://fusion-preview-1",
|
||||
nested: {
|
||||
asset_uri: "mock://fusion-asset-1",
|
||||
duplicate_asset_uri: "mock://fusion-asset-1",
|
||||
},
|
||||
ignored_text: "mock://should-not-be-collected",
|
||||
},
|
||||
error_message: "fusion failed",
|
||||
started_at: "2026-03-27T00:08:00Z",
|
||||
ended_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(viewModel.statusMeta).toEqual({
|
||||
label: "处理中",
|
||||
tone: "info",
|
||||
});
|
||||
expect(viewModel.currentStepLabel).toBe("融合");
|
||||
expect(viewModel.steps[0].containsMockAssets).toBe(true);
|
||||
expect(viewModel.steps[0].isCurrent).toBe(true);
|
||||
expect(viewModel.steps[0].isFailed).toBe(true);
|
||||
expect(viewModel.steps[0].mockAssetUris).toEqual([
|
||||
"mock://fusion-preview-1",
|
||||
"mock://fusion-asset-1",
|
||||
]);
|
||||
expect(viewModel.failureCount).toBe(1);
|
||||
});
|
||||
|
||||
test("maps workflow lookup status and current step labels", () => {
|
||||
const viewModel = adaptWorkflowLookupItem(WORKFLOW_BASE);
|
||||
|
||||
expect(viewModel).toMatchObject({
|
||||
orderId: 101,
|
||||
workflowId: "wf-101",
|
||||
workflowType: "mid_end",
|
||||
status: "running",
|
||||
currentStep: "fusion",
|
||||
currentStepLabel: "融合",
|
||||
statusMeta: {
|
||||
label: "处理中",
|
||||
tone: "info",
|
||||
},
|
||||
});
|
||||
});
|
||||
28
tests/scripts/dev-stack-stack-mjs.d.ts
vendored
Normal file
28
tests/scripts/dev-stack-stack-mjs.d.ts
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
declare module "../../scripts/dev-stack/stack.mjs" {
|
||||
export type StackServiceConfig = {
|
||||
key: string;
|
||||
cwd: string;
|
||||
port: number | null;
|
||||
command: string[];
|
||||
};
|
||||
|
||||
export type StackConfig = {
|
||||
frontendRoot: string;
|
||||
backendRoot: string;
|
||||
runtimeRoot: string;
|
||||
pidRoot: string;
|
||||
logRoot: string;
|
||||
temporalDatabaseFile: string;
|
||||
services: StackServiceConfig[];
|
||||
};
|
||||
|
||||
export function getDefaultTemporalCandidates(tempDirectory: string): string[];
|
||||
|
||||
export function resolveTemporalCli(options: {
|
||||
pathLookupResult: string | null;
|
||||
candidatePaths: string[];
|
||||
existingPaths: Set<string>;
|
||||
}): string | null;
|
||||
|
||||
export function createStackConfig(frontendRoot: string, backendRoot?: string): StackConfig;
|
||||
}
|
||||
183
tests/scripts/dev-stack.test.ts
Normal file
183
tests/scripts/dev-stack.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createStackConfig,
|
||||
formatServiceLogs,
|
||||
getDefaultTemporalCandidates,
|
||||
resolveTemporalCli,
|
||||
selectServicesForLogs,
|
||||
} from "../../scripts/dev-stack/stack.mjs";
|
||||
|
||||
describe("createStackConfig", () => {
|
||||
it("derives frontend and sibling backend paths from the workspace root", () => {
|
||||
const config = createStackConfig("/Volumes/DockCase/codes/auto-virtual-tryon-frontend");
|
||||
|
||||
expect(config.frontendRoot).toBe("/Volumes/DockCase/codes/auto-virtual-tryon-frontend");
|
||||
expect(config.backendRoot).toBe("/Volumes/DockCase/codes/auto-virtual-tryon");
|
||||
expect(config.runtimeRoot).toBe(
|
||||
"/Volumes/DockCase/codes/auto-virtual-tryon-frontend/.dev-stack",
|
||||
);
|
||||
expect(config.temporalDatabaseFile).toBe(
|
||||
"/Volumes/DockCase/codes/auto-virtual-tryon-frontend/.dev-stack/temporal-cli-dev.db",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the expected service commands", () => {
|
||||
const config = createStackConfig("/Volumes/DockCase/codes/auto-virtual-tryon-frontend");
|
||||
|
||||
expect(config.services).toEqual([
|
||||
{
|
||||
key: "temporal",
|
||||
cwd: "/Volumes/DockCase/codes/auto-virtual-tryon-frontend/.dev-stack",
|
||||
port: 7233,
|
||||
command: [
|
||||
"__TEMPORAL_CLI__",
|
||||
"server",
|
||||
"start-dev",
|
||||
"--ip",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"7233",
|
||||
"--headless",
|
||||
"--db-filename",
|
||||
"/Volumes/DockCase/codes/auto-virtual-tryon-frontend/.dev-stack/temporal-cli-dev.db",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "backend-api",
|
||||
cwd: "/Volumes/DockCase/codes/auto-virtual-tryon",
|
||||
port: 8000,
|
||||
command: [
|
||||
"/Volumes/DockCase/codes/auto-virtual-tryon/.venv/bin/python",
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"app.main:app",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"8000",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "backend-worker",
|
||||
cwd: "/Volumes/DockCase/codes/auto-virtual-tryon",
|
||||
port: null,
|
||||
command: [
|
||||
"/Volumes/DockCase/codes/auto-virtual-tryon/.venv/bin/python",
|
||||
"-m",
|
||||
"app.workers.runner",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "frontend",
|
||||
cwd: "/Volumes/DockCase/codes/auto-virtual-tryon-frontend",
|
||||
port: 3000,
|
||||
command: [
|
||||
"/Volumes/DockCase/codes/auto-virtual-tryon-frontend/node_modules/.bin/next",
|
||||
"dev",
|
||||
"--hostname",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"3000",
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTemporalCli", () => {
|
||||
it("prefers a CLI already available on PATH", () => {
|
||||
const resolved = resolveTemporalCli({
|
||||
pathLookupResult: "/usr/local/bin/temporal",
|
||||
candidatePaths: ["/tmp/temporal-sdk-python-1.24.0"],
|
||||
existingPaths: new Set(["/usr/local/bin/temporal", "/tmp/temporal-sdk-python-1.24.0"]),
|
||||
});
|
||||
|
||||
expect(resolved).toBe("/usr/local/bin/temporal");
|
||||
});
|
||||
|
||||
it("falls back to a downloaded SDK binary when PATH does not contain temporal", () => {
|
||||
const resolved = resolveTemporalCli({
|
||||
pathLookupResult: null,
|
||||
candidatePaths: ["/tmp/temporal-sdk-python-1.24.0", "/tmp/temporal-cli"],
|
||||
existingPaths: new Set(["/tmp/temporal-sdk-python-1.24.0"]),
|
||||
});
|
||||
|
||||
expect(resolved).toBe("/tmp/temporal-sdk-python-1.24.0");
|
||||
});
|
||||
|
||||
it("returns null when no temporal binary is available", () => {
|
||||
const resolved = resolveTemporalCli({
|
||||
pathLookupResult: null,
|
||||
candidatePaths: ["/tmp/temporal-sdk-python-1.24.0"],
|
||||
existingPaths: new Set<string>(),
|
||||
});
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultTemporalCandidates", () => {
|
||||
it("includes the known SDK cache binary names under the temp directory", () => {
|
||||
expect(getDefaultTemporalCandidates("/var/folders/example/T")).toEqual([
|
||||
"/var/folders/example/T/temporal-sdk-python-1.24.0",
|
||||
"/var/folders/example/T/temporal-sdk-python-1.25.0",
|
||||
"/var/folders/example/T/temporal-sdk-python-1.26.0",
|
||||
"/var/folders/example/T/temporal-cli",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectServicesForLogs", () => {
|
||||
it("returns every service when no filter is provided", () => {
|
||||
const config = createStackConfig("/Volumes/DockCase/codes/auto-virtual-tryon-frontend");
|
||||
|
||||
expect(
|
||||
selectServicesForLogs(config.services, null).map((service: { key: string }) => service.key),
|
||||
).toEqual(["temporal", "backend-api", "backend-worker", "frontend"]);
|
||||
});
|
||||
|
||||
it("filters to a single service key", () => {
|
||||
const config = createStackConfig("/Volumes/DockCase/codes/auto-virtual-tryon-frontend");
|
||||
|
||||
expect(
|
||||
selectServicesForLogs(config.services, "frontend").map((service: { key: string }) => service.key),
|
||||
).toEqual(["frontend"]);
|
||||
});
|
||||
|
||||
it("throws a helpful error for an unknown service", () => {
|
||||
const config = createStackConfig("/Volumes/DockCase/codes/auto-virtual-tryon-frontend");
|
||||
|
||||
expect(() => selectServicesForLogs(config.services, "foo")).toThrow(
|
||||
"Unknown service \"foo\". Expected one of: temporal, backend-api, backend-worker, frontend",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatServiceLogs", () => {
|
||||
it("formats sections with stable headers and preserves line order", () => {
|
||||
const output = formatServiceLogs([
|
||||
{
|
||||
key: "frontend",
|
||||
logFilePath: "/tmp/frontend.log",
|
||||
lines: ["> dev", "ready"],
|
||||
},
|
||||
{
|
||||
key: "backend-api",
|
||||
logFilePath: "/tmp/backend-api.log",
|
||||
lines: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(output).toBe(
|
||||
[
|
||||
"--- frontend (/tmp/frontend.log) ---",
|
||||
"> dev",
|
||||
"ready",
|
||||
"",
|
||||
"--- backend-api (/tmp/backend-api.log) ---",
|
||||
"(log file is empty)",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
75
tests/ui/dashboard-shell.test.tsx
Normal file
75
tests/ui/dashboard-shell.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import HomePage from "../../app/page";
|
||||
import { DashboardShell } from "@/components/layout/dashboard-shell";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
test("renders reusable dashboard landmarks for rail and content", () => {
|
||||
render(<DashboardShell>content</DashboardShell>);
|
||||
|
||||
expect(
|
||||
screen.getByRole("complementary", { name: "Dashboard rail" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("main", { name: "Dashboard content" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders its children in the main content area", () => {
|
||||
render(<DashboardShell>dashboard body</DashboardShell>);
|
||||
|
||||
expect(
|
||||
screen.getByRole("main", { name: "Dashboard content" }),
|
||||
).toHaveTextContent("dashboard body");
|
||||
});
|
||||
|
||||
test("renders the primary navigation as shell framing with route links", () => {
|
||||
const { container } = render(<DashboardShell>content</DashboardShell>);
|
||||
const navigation = screen.getByRole("navigation", {
|
||||
name: "Primary Navigation",
|
||||
});
|
||||
|
||||
expect(navigation).toContainElement(screen.getByRole("link", { name: "订单总览" }));
|
||||
expect(navigation).toContainElement(
|
||||
screen.getByRole("link", { name: "审核工作台" }),
|
||||
);
|
||||
expect(container.querySelectorAll('nav[aria-label="Primary Navigation"] a')).toHaveLength(6);
|
||||
expect(screen.getByRole("link", { name: "订单总览" })).toHaveAttribute(
|
||||
"href",
|
||||
"/orders",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "审核工作台" })).toHaveAttribute(
|
||||
"href",
|
||||
"/reviews/workbench",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not inject page-level content chrome into the main region", () => {
|
||||
render(<DashboardShell>dashboard body</DashboardShell>);
|
||||
|
||||
const main = screen.getByRole("main", { name: "Dashboard content" });
|
||||
|
||||
expect(main).not.toHaveTextContent("Gallery-First Warm Console");
|
||||
expect(main).not.toHaveTextContent("Shared layout frame");
|
||||
});
|
||||
|
||||
test("locks the rail to the viewport and makes the content pane independently scrollable on desktop", () => {
|
||||
const { container } = render(<DashboardShell>dashboard body</DashboardShell>);
|
||||
const shellFrame = container.firstElementChild;
|
||||
const rail = screen.getByRole("complementary", { name: "Dashboard rail" });
|
||||
const main = screen.getByRole("main", { name: "Dashboard content" });
|
||||
|
||||
expect(shellFrame).toHaveClass("md:h-screen", "md:overflow-hidden");
|
||||
expect(rail).toHaveClass("md:h-full");
|
||||
expect(main).toHaveClass("md:h-full", "md:overflow-y-auto");
|
||||
});
|
||||
|
||||
test("redirects the root page to orders", () => {
|
||||
HomePage();
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/orders");
|
||||
});
|
||||
48
tests/ui/status-badge.test.tsx
Normal file
48
tests/ui/status-badge.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
|
||||
// @ts-expect-error "running" is a step status, not a review decision.
|
||||
const invalidReviewDecisionProps: ComponentProps<typeof StatusBadge> = {
|
||||
variant: "reviewDecision",
|
||||
status: "running",
|
||||
};
|
||||
|
||||
test("renders the waiting review label", () => {
|
||||
render(<StatusBadge status="waiting_review" />);
|
||||
|
||||
expect(screen.getByText("待审核")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("uses order status metadata for the rendered tone", () => {
|
||||
render(<StatusBadge status="failed" />);
|
||||
|
||||
expect(screen.getByText("失败")).toHaveAttribute("data-tone", "danger");
|
||||
});
|
||||
|
||||
test("can render review decision metadata when a variant is provided", () => {
|
||||
render(<StatusBadge status="reject" variant="reviewDecision" />);
|
||||
|
||||
expect(screen.getByText("驳回")).toHaveAttribute("data-tone", "danger");
|
||||
});
|
||||
|
||||
test("renders step status metadata for the stepStatus variant", () => {
|
||||
render(<StatusBadge status="running" variant="stepStatus" />);
|
||||
|
||||
expect(screen.getByText("执行中")).toHaveAttribute("data-tone", "info");
|
||||
});
|
||||
|
||||
test("renders workflow step metadata for the workflowStep variant", () => {
|
||||
render(<StatusBadge status="review" variant="workflowStep" />);
|
||||
|
||||
expect(screen.getByText("人工审核")).toHaveAttribute("data-tone", "warning");
|
||||
});
|
||||
|
||||
test("throws a descriptive error for invalid runtime status and variant pairings", () => {
|
||||
expect(() =>
|
||||
render(<StatusBadge status={"reject" as never} variant="order" />),
|
||||
).toThrow('Invalid status "reject" for variant "order".');
|
||||
|
||||
expect(invalidReviewDecisionProps.variant).toBe("reviewDecision");
|
||||
});
|
||||
Reference in New Issue
Block a user