feat: bootstrap auto virtual tryon admin frontend

This commit is contained in:
afei A
2026-03-27 23:38:50 +08:00
commit 98c6b741d6
119 changed files with 19046 additions and 0 deletions

View 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: "不支持的资源库类型。",
});
});

View 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",
},
],
},
});
});

View 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",
},
});
});

View 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),
);
});

View 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",
},
});
});

View 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,
});
});

View 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",
});
});

View 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),
);
});

View 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();
});

View 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();
});

View 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);
});

View 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");
});
});

View 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");
});
});

View 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",
);
});

View 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();
});

View 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);
});

View 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");
});

View 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",
},
});
});

View 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
View 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;
}

View 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"),
);
});
});

View 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");
});

View 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");
});