134 lines
5.9 KiB
TypeScript
134 lines
5.9 KiB
TypeScript
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||
|
|
import { MemoryRouter } from "react-router-dom";
|
||
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
|
import { ApiError } from "../data/live/http";
|
||
|
|
import { KEYS } from "../data/mock/keys";
|
||
|
|
import { I18nProvider } from "../i18n/I18nContext";
|
||
|
|
import type { Opportunity, RadarToday } from "../domain/types";
|
||
|
|
import { RadarTodayPage } from "./RadarTodayPage";
|
||
|
|
|
||
|
|
const backend = vi.hoisted(() => ({
|
||
|
|
today: null as RadarToday | null,
|
||
|
|
error: null as unknown,
|
||
|
|
calls: [] as Array<Record<string, string | undefined>>,
|
||
|
|
primaryCalls: [] as Array<{ id: string; productId: string; reason: string }>,
|
||
|
|
}));
|
||
|
|
|
||
|
|
function productMatch(productId: string, label: string, score: number) {
|
||
|
|
return {
|
||
|
|
brand_id: "b1",
|
||
|
|
product_id: productId,
|
||
|
|
brand_name_snapshot: "澄光品牌",
|
||
|
|
product_label_snapshot: label,
|
||
|
|
brand_updated_at: 10,
|
||
|
|
product_updated_at: 11,
|
||
|
|
product_fit_score: score,
|
||
|
|
product_fit_band: "strong" as const,
|
||
|
|
eligible: true,
|
||
|
|
excluded: false,
|
||
|
|
reasons: [
|
||
|
|
{ dimension: "pain" as const, score: 35, reason: "對應泛紅不適", candidate_excerpt: "泛紅不適", product_basis: "泛紅不適" },
|
||
|
|
{ dimension: "scenario" as const, score: 25, reason: "對應日常修護", candidate_excerpt: "日常修護", product_basis: "日常修護" },
|
||
|
|
{ dimension: "audience" as const, score: 20, reason: "對應敏感肌", candidate_excerpt: "敏感肌", product_basis: "敏感肌" },
|
||
|
|
{ dimension: "capability" as const, score: 20, reason: "對應修護能力", candidate_excerpt: "修護", product_basis: "修護" },
|
||
|
|
],
|
||
|
|
risks: [],
|
||
|
|
watch_ids: ["w1"],
|
||
|
|
matched_terms: ["敏感肌"],
|
||
|
|
matched_at: 12,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function sampleOpportunity(): Opportunity {
|
||
|
|
return {
|
||
|
|
id: "opp-1",
|
||
|
|
source: "threads",
|
||
|
|
external_id: "https://www.threads.net/@seeker/post/shared-1",
|
||
|
|
permalink: "https://www.threads.net/@seeker/post/shared-1",
|
||
|
|
author_handle: "seeker",
|
||
|
|
text: "敏感肌使用者求推薦:泛紅不適,想了解日常修護。",
|
||
|
|
posted_at: Date.now() * 1e6,
|
||
|
|
status: "qualified",
|
||
|
|
intent_score: 90,
|
||
|
|
intent_band: "high",
|
||
|
|
reasons: [
|
||
|
|
{ dimension: "authenticity", score: 30, reason: "使用者求助" },
|
||
|
|
{ dimension: "intent", score: 30, reason: "明確詢問" },
|
||
|
|
{ dimension: "region", score: 10, reason: "未猜地區" },
|
||
|
|
{ dimension: "freshness", score: 15, reason: "剛發布" },
|
||
|
|
{ dimension: "fit", score: 5, reason: "有產品上下文" },
|
||
|
|
],
|
||
|
|
region_match: "unknown",
|
||
|
|
freshness_hours: 1,
|
||
|
|
matched_terms: ["敏感肌"],
|
||
|
|
primary_brand_id: "b1",
|
||
|
|
primary_product_id: "p1",
|
||
|
|
primary_product_label: "舒緩精華",
|
||
|
|
primary_product_fit_score: 100,
|
||
|
|
product_matches: [productMatch("p1", "舒緩精華", 100), productMatch("p2", "修護乳霜", 80)],
|
||
|
|
created_at: Date.now() * 1e6,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
vi.mock("../data/DataContext", () => ({
|
||
|
|
useRepos: () => ({
|
||
|
|
accounts: { async list() { return []; } },
|
||
|
|
scout: {
|
||
|
|
async listBrands() { return [{ id: "b1", display_name: "澄光品牌", brief: "" }]; },
|
||
|
|
async listProducts() { return [{ id: "p1", brand_id: "b1", label: "舒緩精華", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 }, { id: "p2", brand_id: "b1", label: "修護乳霜", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 }]; },
|
||
|
|
},
|
||
|
|
radar: {
|
||
|
|
async getToday(filter?: { brand_id?: string; product_id?: string; fit_band?: string }) {
|
||
|
|
backend.calls.push(filter ?? {});
|
||
|
|
if (backend.error) throw backend.error;
|
||
|
|
if (!backend.today) throw new Error("missing live fixture");
|
||
|
|
return backend.today;
|
||
|
|
},
|
||
|
|
async setPrimaryProduct(id: string, productId: string, reason: string) {
|
||
|
|
backend.primaryCalls.push({ id, productId, reason });
|
||
|
|
return backend.today?.high[0] ?? sampleOpportunity();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
}));
|
||
|
|
|
||
|
|
function renderPage() {
|
||
|
|
return render(
|
||
|
|
<MemoryRouter initialEntries={["/app/radar/today?brand_id=b1&product_id=p2&fit_band=strong"]}>
|
||
|
|
<I18nProvider><RadarTodayPage /></I18nProvider>
|
||
|
|
</MemoryRouter>,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
|
||
|
|
backend.today = { stats: { total: 1, high: 1, mid: 0, low: 0 }, high: [sampleOpportunity()], mid: [], low: [], truncated_count: 0 };
|
||
|
|
backend.error = null;
|
||
|
|
backend.calls = [];
|
||
|
|
backend.primaryCalls = [];
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("live product radar flow", () => {
|
||
|
|
it("keeps URL filters, renders multi-product evidence, and sends primary override", async () => {
|
||
|
|
renderPage();
|
||
|
|
expect(await screen.findByText("敏感肌使用者求推薦:泛紅不適,想了解日常修護。"))
|
||
|
|
.toBeTruthy();
|
||
|
|
expect(backend.calls[0]).toEqual({ brand_id: "b1", product_id: "p2", fit_band: "strong" });
|
||
|
|
expect(screen.getAllByText("舒緩精華").length).toBeGreaterThan(0);
|
||
|
|
fireEvent.click(screen.getByRole("button", { name: "查看 2 個產品匹配" }));
|
||
|
|
|
||
|
|
fireEvent.change(screen.getByRole("combobox", { name: "主推產品" }), { target: { value: "p2" } });
|
||
|
|
fireEvent.change(screen.getByRole("textbox", { name: "主推理由" }), { target: { value: "本次依證據指定" } });
|
||
|
|
fireEvent.click(screen.getByRole("button", { name: "設定主推" }));
|
||
|
|
await waitFor(() => expect(backend.primaryCalls).toEqual([{ id: "opp-1", productId: "p2", reason: "本次依證據指定" }]));
|
||
|
|
});
|
||
|
|
|
||
|
|
it("keeps API failures visible instead of showing zero results", async () => {
|
||
|
|
backend.today = null;
|
||
|
|
backend.error = new ApiError("服務暫時不可用", 501010, 503);
|
||
|
|
renderPage();
|
||
|
|
expect(await screen.findByRole("alert")).toBeTruthy();
|
||
|
|
expect(screen.queryByText(/尚未指定主推產品/)).toBeNull();
|
||
|
|
});
|
||
|
|
});
|