thread-master/apps/web/src/pages/RadarWatchesPage.test.tsx

263 lines
10 KiB
TypeScript
Raw Normal View History

2026-08-03 05:52:02 +00:00
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<string, string | number>) => 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";
},
2026-08-13 02:22:24 +00:00
async deleteArchivedWatch(id: string) {
const w = find(id);
if (w.status !== "archived") throw new ApiError("only archived watch can be permanently deleted", 400100, 400);
backend.watches = backend.watches.filter((row) => row.id !== id);
},
2026-08-03 05:52:02 +00:00
async suggestWatchTerms(): Promise<WatchTermSuggestion[]> {
if (backend.suggestError) throw backend.suggestError;
return backend.suggestions;
},
},
};
return { useRepos: () => repos };
});
function renderPage() {
return render(
<I18nProvider>
<MemoryRouter>
<RadarWatchesPage />
</MemoryRouter>
</I18nProvider>,
);
}
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", () => {
2026-08-13 02:22:24 +00:00
it("建立表單直接標示關鍵字為必填", async () => {
renderPage();
await screen.findByText(t("radar.watches.empty"));
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.add") }));
const terms = screen.getByLabelText(t("radar.watches.terms"), { exact: false }) as HTMLTextAreaElement;
expect(terms.required).toBe(true);
expect(terms.closest("label")?.querySelector(".hb-field__required")?.textContent).toBe("*");
expect(screen.getByText(t("radar.watches.requiredHint"))).toBeTruthy();
});
2026-08-03 05:52:02 +00:00
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"),
2026-08-13 02:22:24 +00:00
).toBe("/app/policy");
2026-08-03 05:52:02 +00:00
});
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();
2026-08-13 02:22:24 +00:00
// 封存後只能刪除設定;既有歷史資料不會被這個動作連帶刪除。
const deleteButton = within(archived).getByRole("button", { name: t("radar.watches.deleteArchived") });
fireEvent.click(deleteButton);
await screen.findByText(t("radar.watches.deletedArchived"));
expect(screen.queryByText("推薦室內設計")).toBeNull();
2026-08-03 05:52:02 +00:00
});
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();
});
});