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

299 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
hours: [6] as number[],
}));
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 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);
},
async suggestWatchTerms(): Promise<WatchTermSuggestion[]> {
if (backend.suggestError) throw backend.suggestError;
return backend.suggestions;
},
async getRadarSchedule() {
return { hours: backend.hours, timezone: "Asia/Taipei" };
},
async saveRadarSchedule(hours: number[]) {
backend.hours = [...hours].sort((a, b) => a - b);
return { hours: backend.hours, timezone: "Asia/Taipei" };
},
},
};
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;
backend.hours = [6];
vi.spyOn(window, "confirm").mockReturnValue(true);
});
describe("RadarWatchesPage", () => {
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();
});
it("沒服務檔案也能新增,品牌只是選項", async () => {
backend.profileExists = false;
renderPage();
expect(await screen.findByText(t("radar.watches.profileOptional"))).toBeTruthy();
expect(screen.getByRole("button", { name: t("radar.watches.add") })).not.toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.add") }));
fireEvent.change(screen.getByLabelText(t("radar.watches.terms"), { exact: false }), {
target: { value: "台北 美甲推薦" },
});
fireEvent.click(screen.getByRole("button", { name: t("common.save") }));
await screen.findByText(t("radar.watches.created"));
expect(screen.getByText("台北 美甲推薦")).toBeTruthy();
});
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();
// 封存後只能刪除設定;既有歷史資料不會被這個動作連帶刪除。
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();
});
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.watches.showAdvanced") }));
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("徵才");
// 排除詞在進階區裡,採用後不能把使用者的輸入藏回去
expect(screen.getByRole("button", { name: t("radar.watches.hideAdvanced") })).toBeTruthy();
// 已採用的不能再按,否則使用者會以為沒生效而重複點
await waitFor(() =>
expect(screen.getAllByRole("button", { name: t("radar.suggest.adopted") })).toHaveLength(2),
);
});
it("建議失敗時說出原因,不裝作沒有建議", async () => {
backend.suggestError = new ApiError(
"AI 沒有回傳可用的關鍵字建議,請稍後再試",
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.watches.showAdvanced") }));
fireEvent.click(screen.getByRole("button", { name: t("radar.suggest.ask") }));
const banner = await screen.findByRole("alert");
expect(banner.textContent).toContain("AI 沒有回傳可用的關鍵字建議");
expect(screen.queryByText(t("radar.suggest.none"))).toBeNull();
});
it("長句關鍵字儲存時收成可搜短詞", async () => {
renderPage();
await screen.findByText(t("radar.watches.empty"));
await createWatch("晚上睡覺容易口乾舌燥怎麼辦");
await screen.findByText(t("radar.watches.created"));
expect(screen.getByText("晚上睡覺")).toBeTruthy();
expect(screen.getByText("口乾舌燥")).toBeTruthy();
});
it("可以多選巡邏時段", async () => {
renderPage();
const evening = await screen.findByRole("button", { name: t("radar.watches.slot.18") });
expect(screen.getByRole("button", { name: t("radar.watches.slot.6") })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(evening);
await screen.findByText(t("radar.watches.scheduleSaved"));
expect(backend.hours).toEqual([6, 18]);
expect(evening).toHaveAttribute("aria-pressed", "true");
});
});