feat: enhance order asset selection and previews

This commit is contained in:
afei A
2026-03-29 00:24:29 +08:00
parent 162d3e12d2
commit d09491cd8a
18 changed files with 1160 additions and 183 deletions

View File

@@ -158,3 +158,63 @@ test("normalizes upstream validation errors from the backend", async () => {
},
});
});
test("accepts order creation payloads without a scene asset id", async () => {
vi.stubEnv("BACKEND_BASE_URL", "http://backend.test/api/v1");
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
order_id: 88,
workflow_id: "wf-88",
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: "low",
service_mode: "auto_basic",
model_id: 101,
garment_asset_id: 303,
}),
});
const response = await POST(request);
const payload = await response.json();
expect(response.status).toBe(201);
expect(payload).toEqual({
mode: "proxy",
data: {
orderId: 88,
workflowId: "wf-88",
status: "created",
},
});
expect(fetchMock).toHaveBeenCalledWith(
"http://backend.test/api/v1/orders",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
customer_level: "low",
service_mode: "auto_basic",
model_id: 101,
garment_asset_id: 303,
}),
}),
);
});

View File

@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { test, expect } from "vitest";
import { OrderDetail } from "@/features/orders/order-detail";
@@ -97,3 +97,140 @@ test("renders the business-empty final-result state when no final asset exists",
expect(screen.getByText("最终图暂未生成")).toBeInTheDocument();
expect(screen.getByText("当前订单还没有可展示的最终结果。")).toBeInTheDocument();
});
test("renders prepared-model input snapshots when asset metadata contains resource inputs", () => {
render(
<OrderDetail
viewModel={{
...BASE_ORDER_DETAIL,
assets: [
{
id: 66,
orderId: 101,
type: "prepared_model",
stepName: "prepare_model",
parentAssetId: null,
rootAssetId: null,
versionNo: 0,
isCurrentVersion: false,
stepLabel: "模型准备",
label: "模型准备产物",
uri: "https://images.example.com/prepared-model.jpg",
metadata: {
model_input: {
resource_id: 3,
resource_name: "主模特",
original_url: "https://images.example.com/model.jpg",
},
garment_input: {
resource_id: 4,
resource_name: "上衣",
original_url: "https://images.example.com/garment.jpg",
},
scene_input: {
resource_id: 5,
resource_name: "白棚",
original_url: "https://images.example.com/scene.jpg",
},
},
createdAt: "2026-03-27T00:08:00Z",
isMock: false,
},
],
}}
/>,
);
expect(screen.getByText("输入素材")).toBeInTheDocument();
expect(screen.getByText("模特图")).toBeInTheDocument();
expect(screen.getByText("服装图")).toBeInTheDocument();
expect(screen.getByText("场景图")).toBeInTheDocument();
expect(screen.getByText("主模特")).toBeInTheDocument();
expect(screen.getByText("上衣")).toBeInTheDocument();
expect(screen.getByText("白棚")).toBeInTheDocument();
expect(screen.getByAltText("模型准备产物预览")).toBeInTheDocument();
});
test("renders image previews for non-mock final and process assets", () => {
render(
<OrderDetail
viewModel={{
...BASE_ORDER_DETAIL,
hasMockAssets: false,
finalAsset: {
...BASE_ORDER_DETAIL.finalAsset!,
uri: "https://images.example.com/final.jpg",
isMock: false,
},
assets: [
{
id: 78,
orderId: 101,
type: "tryon",
stepName: "tryon",
parentAssetId: null,
rootAssetId: null,
versionNo: 0,
isCurrentVersion: false,
stepLabel: "试穿生成",
label: "试穿生成产物",
uri: "https://images.example.com/tryon.jpg",
metadata: null,
createdAt: "2026-03-27T00:09:00Z",
isMock: false,
},
],
}}
/>,
);
expect(screen.getByAltText("最终图预览")).toBeInTheDocument();
expect(screen.getByAltText("试穿生成产物预览")).toBeInTheDocument();
});
test("opens an asset preview dialog for real images and resets zoom state", () => {
render(
<OrderDetail
viewModel={{
...BASE_ORDER_DETAIL,
hasMockAssets: false,
finalAsset: {
...BASE_ORDER_DETAIL.finalAsset!,
uri: "https://images.example.com/final.jpg",
isMock: false,
},
assets: [
{
id: 78,
orderId: 101,
type: "scene",
stepName: "scene",
parentAssetId: null,
rootAssetId: null,
versionNo: 0,
isCurrentVersion: false,
stepLabel: "场景处理",
label: "场景处理产物",
uri: "https://images.example.com/scene.jpg",
metadata: null,
createdAt: "2026-03-27T00:09:00Z",
isMock: false,
},
],
}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "查看场景处理产物大图" }));
expect(screen.getByRole("dialog", { name: "场景处理产物预览" })).toBeInTheDocument();
const zoomableImage = screen.getByAltText("场景处理产物大图");
fireEvent.wheel(zoomableImage, { deltaY: -120 });
expect(zoomableImage).toHaveStyle({ transform: "translate(0px, 0px) scale(1.12)" });
fireEvent.click(screen.getByRole("button", { name: "重置预览" }));
expect(zoomableImage).toHaveStyle({ transform: "translate(0px, 0px) scale(1)" });
});

View File

@@ -84,6 +84,19 @@ async function chooseSelectOption(label: string, optionName: string) {
fireEvent.click(await screen.findByRole("option", { name: optionName }));
}
async function chooseLibraryResource(label: string, resourceName: string) {
fireEvent.click(screen.getByRole("button", { name: `选择${label}` }));
const dialog = await screen.findByRole("dialog", { name: `选择${label}` });
fireEvent.click(within(dialog).getByRole("button", { name: resourceName }));
await waitFor(() => {
expect(
screen.queryByRole("dialog", { name: `选择${label}` }),
).not.toBeInTheDocument();
});
}
beforeEach(() => {
pushMock.mockReset();
});
@@ -97,7 +110,7 @@ test("forces low customers to use auto_basic and mid customers to use semi_pro",
render(<SubmitWorkbench />);
await screen.findByText("Ava / Studio");
await screen.findByRole("button", { name: "选择模特资源" });
await chooseSelectOption("客户层级", "低客单 low");
@@ -142,12 +155,12 @@ test("preserves selected values when order submission fails", async () => {
render(<SubmitWorkbench />);
await screen.findByText("Ava / Studio");
await screen.findByRole("button", { name: "选择模特资源" });
await chooseSelectOption("客户层级", "低客单 low");
await chooseSelectOption("模特资源", "Ava / Studio");
await chooseSelectOption("场景资源", "Loft Window");
await chooseSelectOption("服装资源", "Structured Coat 01");
await chooseLibraryResource("模特资源", "Ava / Studio");
await chooseLibraryResource("场景资源", "Loft Window");
await chooseLibraryResource("服装资源", "Structured Coat 01");
const summaryCard = screen.getByRole("region", { name: "提单摘要" });
expect(within(summaryCard).getByText("Ava / Studio")).toBeInTheDocument();
@@ -165,15 +178,9 @@ test("preserves selected values when order submission fails", async () => {
expect(screen.getByRole("combobox", { name: "服务模式" })).toHaveTextContent(
"自动基础处理 auto_basic",
);
expect(screen.getByRole("combobox", { name: "模特资源" })).toHaveTextContent(
"Ava / Studio",
);
expect(screen.getByRole("combobox", { name: "场景资源" })).toHaveTextContent(
"Loft Window",
);
expect(screen.getByRole("combobox", { name: "服装资源" })).toHaveTextContent(
"Structured Coat 01",
);
expect(screen.getAllByText("Ava / Studio").length).toBeGreaterThan(1);
expect(screen.getAllByText("Loft Window").length).toBeGreaterThan(1);
expect(screen.getAllByText("Structured Coat 01").length).toBeGreaterThan(1);
});
test("submits the selected resources, surfaces returned ids, and redirects to the order detail page", async () => {
@@ -198,12 +205,12 @@ test("submits the selected resources, surfaces returned ids, and redirects to th
render(<SubmitWorkbench />);
await screen.findByText("Ava / Studio");
await screen.findByRole("button", { name: "选择模特资源" });
await chooseSelectOption("客户层级", "低客单 low");
await chooseSelectOption("模特资源", "Ava / Studio");
await chooseSelectOption("场景资源", "Loft Window");
await chooseSelectOption("服装资源", "Structured Coat 01");
await chooseLibraryResource("模特资源", "Ava / Studio");
await chooseLibraryResource("场景资源", "Loft Window");
await chooseLibraryResource("服装资源", "Structured Coat 01");
fireEvent.click(screen.getByRole("button", { name: "提交订单" }));
@@ -232,3 +239,78 @@ test("submits the selected resources, surfaces returned ids, and redirects to th
expect(pushMock).toHaveBeenCalledWith("/orders/77");
});
});
test("allows submitting without selecting a scene resource", async () => {
const fetchMock = createFetchMock({
orderResponse: new Response(
JSON.stringify({
mode: "proxy",
data: {
orderId: 88,
workflowId: "wf-88",
status: "created",
},
}),
{
status: 201,
headers: { "content-type": "application/json" },
},
),
});
vi.stubGlobal("fetch", fetchMock);
render(<SubmitWorkbench />);
await screen.findByRole("button", { name: "选择模特资源" });
await chooseSelectOption("客户层级", "低客单 low");
await chooseLibraryResource("模特资源", "Ava / Studio");
await chooseLibraryResource("服装资源", "Structured 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,
garment_asset_id: 303,
}),
}),
);
});
});
test("opens a shared resource manager modal and picks a resource card for each library type", async () => {
vi.stubGlobal("fetch", createFetchMock());
render(<SubmitWorkbench />);
await screen.findByRole("button", { name: "选择模特资源" });
fireEvent.click(screen.getByRole("button", { name: "选择模特资源" }));
const modelDialog = await screen.findByRole("dialog", { name: "选择模特资源" });
expect(within(modelDialog).getByTestId("resource-picker-masonry").className).toContain(
"columns-1",
);
expect(
within(modelDialog).getByRole("button", { name: "Ava / Studio" }),
).toBeInTheDocument();
fireEvent.click(within(modelDialog).getByRole("button", { name: "Ava / Studio" }));
await waitFor(() => {
expect(
screen.queryByRole("dialog", { name: "选择模特资源" }),
).not.toBeInTheDocument();
});
const summaryCard = screen.getByRole("region", { name: "提单摘要" });
expect(within(summaryCard).getByText("Ava / Studio")).toBeInTheDocument();
});

View File

@@ -42,6 +42,7 @@ const BASE_WORKFLOW_DETAIL: WorkflowDetailVM = {
endedAt: "2026-03-27T00:07:00Z",
containsMockAssets: false,
mockAssetUris: [],
previewUri: null,
isCurrent: false,
isFailed: true,
},
@@ -64,6 +65,7 @@ const BASE_WORKFLOW_DETAIL: WorkflowDetailVM = {
endedAt: null,
containsMockAssets: true,
mockAssetUris: ["mock://fusion-preview"],
previewUri: "mock://fusion-preview",
isCurrent: true,
isFailed: false,
},
@@ -82,3 +84,41 @@ test("highlights failed steps and mock asset hints in the workflow timeline", ()
expect(screen.getByText("Temporal activity timed out.")).toBeInTheDocument();
expect(screen.getByText("当前流程包含 mock 资产")).toBeInTheDocument();
});
test("renders image previews for real workflow step outputs", () => {
render(
<WorkflowDetail
viewModel={{
...BASE_WORKFLOW_DETAIL,
hasMockAssets: false,
steps: [
{
id: 21,
workflowRunId: 9001,
name: "scene",
label: "场景处理",
status: "succeeded",
statusMeta: {
label: "已完成",
tone: "success",
},
input: null,
output: {
uri: "https://images.example.com/orders/101/scene/generated.jpg",
},
errorMessage: null,
startedAt: "2026-03-27T00:06:00Z",
endedAt: "2026-03-27T00:07:00Z",
containsMockAssets: false,
mockAssetUris: [],
previewUri: "https://images.example.com/orders/101/scene/generated.jpg",
isCurrent: false,
isFailed: false,
},
],
}}
/>,
);
expect(screen.getByAltText("场景处理预览")).toBeInTheDocument();
});

View File

@@ -69,6 +69,33 @@ test("tags nested mock asset uris found in workflow step payloads", () => {
expect(viewModel.failureCount).toBe(1);
});
test("extracts a step preview uri from standard output uri fields", () => {
const viewModel = adaptWorkflowDetail({
...WORKFLOW_BASE,
current_step: "scene",
steps: [
{
id: 2,
workflow_run_id: 9001,
step_name: "scene",
step_status: "succeeded",
input_json: null,
output_json: {
uri: "https://images.example.com/orders/101/scene/generated.jpg",
},
error_message: null,
started_at: "2026-03-27T00:08:00Z",
ended_at: "2026-03-27T00:09:00Z",
},
],
});
expect(viewModel.steps[0].previewUri).toBe(
"https://images.example.com/orders/101/scene/generated.jpg",
);
expect(viewModel.steps[0].containsMockAssets).toBe(false);
});
test("maps workflow lookup status and current step labels", () => {
const viewModel = adaptWorkflowLookupItem(WORKFLOW_BASE);