import { fireEvent, render, screen, waitFor, within } 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 { translate } from "../lib/i18n/messages"; import type { RadarWatch, WatchTermSuggestion } from "../domain/types"; import { RadarWatchesPage } from "./RadarWatchesPage"; const t = (key: string, params?: Record) => translate("zh-TW", key, params); const backend = vi.hoisted(() => ({ watches: [] as RadarWatch[], profileExists: true, maxActive: 5, suggestions: [] as WatchTermSuggestion[], suggestError: null as unknown, seq: 0, })); function activeCount(): number { return backend.watches.filter((w) => w.status === "active").length; } function find(id: string): RadarWatch { const w = backend.watches.find((x) => x.id === id); if (!w) throw new ApiError("watch not found", 404001, 404); return w; } vi.mock("../data/DataContext", () => { const repos = { radar: { async listWatches(page = 1, pageSize = 20, status?: string) { const all = backend.watches.filter((w) => !status || w.status === status); return { list: all.slice((page - 1) * pageSize, page * pageSize), total: all.length, active_count: activeCount(), max_active: backend.maxActive, profile_exists: backend.profileExists, }; }, async createWatch(input: { terms: string[]; exclude_terms?: string[]; regions?: string[]; enabled?: boolean; }) { const wantsActive = input.enabled !== false; if (wantsActive && activeCount() >= backend.maxActive) { throw new ApiError( `active watch limit reached (${backend.maxActive} of ${backend.maxActive} on your plan); pause an existing watch or upgrade your plan`, 400100, 400, ); } backend.seq += 1; const w: RadarWatch = { id: `w${backend.seq}`, terms: input.terms, exclude_terms: input.exclude_terms ?? [], regions: input.regions ?? [], status: wantsActive ? "active" : "paused", created_at: 1, updated_at: 1, }; backend.watches.push(w); return w; }, async updateWatch(id: string, patch: { terms?: string[]; exclude_terms?: string[]; regions?: string[] }) { const w = find(id); Object.assign(w, patch); return w; }, async pauseWatch(id: string) { const w = find(id); w.status = "paused"; return w; }, async resumeWatch(id: string) { const w = find(id); if (activeCount() >= backend.maxActive) { throw new ApiError( `active watch limit reached (${backend.maxActive} of ${backend.maxActive} on your plan); pause an existing watch or upgrade your plan`, 400100, 400, ); } w.status = "active"; return w; }, async archiveWatch(id: string) { find(id).status = "archived"; }, async suggestWatchTerms(): Promise { if (backend.suggestError) throw backend.suggestError; return backend.suggestions; }, }, }; return { useRepos: () => repos }; }); function renderPage() { return render( , ); } function row(term: string): HTMLElement { const chip = screen.getByText(term); const article = chip.closest("article"); if (!article) throw new Error(`no watch row for ${term}`); return article; } async function createWatch(terms: string) { fireEvent.click(screen.getByRole("button", { name: t("radar.watches.add") })); fireEvent.change(screen.getByLabelText(t("radar.watches.terms"), { exact: false }), { target: { value: terms }, }); fireEvent.click(screen.getByRole("button", { name: t("common.save") })); } beforeEach(() => { localStorage.setItem( KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }), ); backend.watches = []; backend.profileExists = true; backend.maxActive = 5; backend.suggestions = []; backend.suggestError = null; backend.seq = 0; vi.spyOn(window, "confirm").mockReturnValue(true); }); describe("RadarWatchesPage", () => { it("沒服務檔案時停用新增,並指路去填", async () => { backend.profileExists = false; renderPage(); expect(await screen.findByText(t("radar.watches.needProfile"))).toBeTruthy(); expect(screen.getByRole("button", { name: t("radar.watches.add") })).toBeDisabled(); expect( screen.getByRole("link", { name: t("radar.watches.goProfile") }).getAttribute("href"), ).toBe("/app/brands"); }); it("建立→暫停→恢復→封存的狀態變化都反映在列上", async () => { renderPage(); await screen.findByText(t("radar.watches.empty")); await createWatch("推薦室內設計\n找設計師"); await screen.findByText(t("radar.watches.created")); expect(within(row("推薦室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy(); expect(screen.getByText(t("radar.watches.quota", { used: 1, max: 5 }))).toBeTruthy(); fireEvent.click(within(row("推薦室內設計")).getByRole("button", { name: t("radar.watches.pause") })); await screen.findByText(t("radar.watches.paused")); expect(within(row("推薦室內設計")).getByText(t("radar.watches.status.paused"))).toBeTruthy(); expect(screen.getByText(t("radar.watches.quota", { used: 0, max: 5 }))).toBeTruthy(); fireEvent.click(within(row("推薦室內設計")).getByRole("button", { name: t("radar.watches.resume") })); await screen.findByText(t("radar.watches.resumed")); expect(within(row("推薦室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy(); fireEvent.click(within(row("推薦室內設計")).getByRole("button", { name: t("radar.watches.archive") })); await screen.findByText(t("radar.watches.archived")); const archived = row("推薦室內設計"); expect(within(archived).getByText(t("radar.watches.status.archived"))).toBeTruthy(); // 封存是終點:不再提供暫停/恢復/再封存 expect(within(archived).queryByRole("button")).toBeNull(); }); it("配額滿時照後端訊息說上限與升級,表單留著讓人改", async () => { backend.maxActive = 1; renderPage(); await screen.findByText(t("radar.watches.empty")); await createWatch("推薦室內設計"); await screen.findByText(t("radar.watches.created")); expect(screen.getByText(t("radar.watches.quotaFull"), { exact: false })).toBeTruthy(); await createWatch("找設計師"); const banner = await screen.findByRole("alert"); expect(banner.textContent).toContain("active watch limit reached (1 of 1 on your plan)"); // 表單沒被關掉:使用者可以改成不立刻啟用再送 expect(screen.getByText(t("radar.watches.newTitle"))).toBeTruthy(); }); it("關鍵字建議可逐條採用,include 進關鍵字、exclude 進排除詞", async () => { backend.suggestions = [ { term: "推薦室內設計", reason: "客人找設計師時最常這樣問", usage: "include" }, { term: "徵才", reason: "這類是招募文,不是客人", usage: "exclude" }, ]; renderPage(); await screen.findByText(t("radar.watches.empty")); fireEvent.click(screen.getByRole("button", { name: t("radar.watches.add") })); fireEvent.click(screen.getByRole("button", { name: t("radar.suggest.ask") })); await screen.findByText("客人找設計師時最常這樣問"); const adoptButtons = screen.getAllByRole("button", { name: t("radar.suggest.adopt") }); fireEvent.click(adoptButtons[0]); fireEvent.click(adoptButtons[1]); expect( (screen.getByLabelText(t("radar.watches.terms"), { exact: false }) as HTMLTextAreaElement).value, ).toBe("推薦室內設計"); expect( (screen.getByLabelText(t("radar.watches.excludeTerms"), { exact: false }) as HTMLTextAreaElement) .value, ).toBe("徵才"); // 已採用的不能再按,否則使用者會以為沒生效而重複點 await waitFor(() => expect(screen.getAllByRole("button", { name: t("radar.suggest.adopted") })).toHaveLength(2), ); }); it("建議失敗時說出原因,不裝作沒有建議", async () => { backend.suggestError = new ApiError( "service profile required before suggesting watch terms", 400100, 400, ); renderPage(); await screen.findByText(t("radar.watches.empty")); fireEvent.click(screen.getByRole("button", { name: t("radar.watches.add") })); fireEvent.click(screen.getByRole("button", { name: t("radar.suggest.ask") })); const banner = await screen.findByRole("alert"); expect(banner.textContent).toContain("service profile required before suggesting watch terms"); expect(screen.queryByText(t("radar.suggest.none"))).toBeNull(); }); });