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-19 07:37:44 +00:00
|
|
|
|
it("沒服務檔案也能新增,品牌只是選項", async () => {
|
2026-08-03 05:52:02 +00:00
|
|
|
|
backend.profileExists = false;
|
|
|
|
|
|
renderPage();
|
|
|
|
|
|
|
2026-08-19 07:37:44 +00:00
|
|
|
|
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();
|
2026-08-03 05:52:02 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("建立→暫停→恢復→封存的狀態變化都反映在列上", async () => {
|
|
|
|
|
|
renderPage();
|
|
|
|
|
|
await screen.findByText(t("radar.watches.empty"));
|
|
|
|
|
|
|
2026-08-13 07:54:25 +00:00
|
|
|
|
await createWatch("室內設計\n找設計師");
|
2026-08-03 05:52:02 +00:00
|
|
|
|
await screen.findByText(t("radar.watches.created"));
|
2026-08-13 07:54:25 +00:00
|
|
|
|
expect(within(row("室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy();
|
2026-08-03 05:52:02 +00:00
|
|
|
|
expect(screen.getByText(t("radar.watches.quota", { used: 1, max: 5 }))).toBeTruthy();
|
|
|
|
|
|
|
2026-08-13 07:54:25 +00:00
|
|
|
|
fireEvent.click(within(row("室內設計")).getByRole("button", { name: t("radar.watches.pause") }));
|
2026-08-03 05:52:02 +00:00
|
|
|
|
await screen.findByText(t("radar.watches.paused"));
|
2026-08-13 07:54:25 +00:00
|
|
|
|
expect(within(row("室內設計")).getByText(t("radar.watches.status.paused"))).toBeTruthy();
|
2026-08-03 05:52:02 +00:00
|
|
|
|
expect(screen.getByText(t("radar.watches.quota", { used: 0, max: 5 }))).toBeTruthy();
|
|
|
|
|
|
|
2026-08-13 07:54:25 +00:00
|
|
|
|
fireEvent.click(within(row("室內設計")).getByRole("button", { name: t("radar.watches.resume") }));
|
2026-08-03 05:52:02 +00:00
|
|
|
|
await screen.findByText(t("radar.watches.resumed"));
|
2026-08-13 07:54:25 +00:00
|
|
|
|
expect(within(row("室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy();
|
2026-08-03 05:52:02 +00:00
|
|
|
|
|
2026-08-13 07:54:25 +00:00
|
|
|
|
fireEvent.click(within(row("室內設計")).getByRole("button", { name: t("radar.watches.archive") }));
|
2026-08-03 05:52:02 +00:00
|
|
|
|
await screen.findByText(t("radar.watches.archived"));
|
2026-08-13 07:54:25 +00:00
|
|
|
|
const archived = row("室內設計");
|
2026-08-03 05:52:02 +00:00
|
|
|
|
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"));
|
2026-08-13 07:54:25 +00:00
|
|
|
|
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"));
|
|
|
|
|
|
|
2026-08-13 07:54:25 +00:00
|
|
|
|
await createWatch("室內設計");
|
2026-08-03 05:52:02 +00:00
|
|
|
|
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 = [
|
2026-08-13 07:54:25 +00:00
|
|
|
|
{ term: "室內設計", reason: "客人找設計師時最常這樣問", usage: "include" },
|
2026-08-03 05:52:02 +00:00
|
|
|
|
{ term: "徵才", reason: "這類是招募文,不是客人", usage: "exclude" },
|
|
|
|
|
|
];
|
|
|
|
|
|
renderPage();
|
|
|
|
|
|
await screen.findByText(t("radar.watches.empty"));
|
|
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.add") }));
|
2026-08-19 07:37:44 +00:00
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.showAdvanced") }));
|
2026-08-03 05:52:02 +00:00
|
|
|
|
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,
|
2026-08-13 07:54:25 +00:00
|
|
|
|
).toBe("室內設計");
|
2026-08-03 05:52:02 +00:00
|
|
|
|
expect(
|
|
|
|
|
|
(screen.getByLabelText(t("radar.watches.excludeTerms"), { exact: false }) as HTMLTextAreaElement)
|
|
|
|
|
|
.value,
|
|
|
|
|
|
).toBe("徵才");
|
2026-08-19 07:37:44 +00:00
|
|
|
|
// 排除詞在進階區裡,採用後不能把使用者的輸入藏回去
|
|
|
|
|
|
expect(screen.getByRole("button", { name: t("radar.watches.hideAdvanced") })).toBeTruthy();
|
2026-08-03 05:52:02 +00:00
|
|
|
|
// 已採用的不能再按,否則使用者會以為沒生效而重複點
|
|
|
|
|
|
await waitFor(() =>
|
|
|
|
|
|
expect(screen.getAllByRole("button", { name: t("radar.suggest.adopted") })).toHaveLength(2),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("建議失敗時說出原因,不裝作沒有建議", async () => {
|
|
|
|
|
|
backend.suggestError = new ApiError(
|
2026-08-19 07:37:44 +00:00
|
|
|
|
"AI 沒有回傳可用的關鍵字建議,請稍後再試",
|
2026-08-03 05:52:02 +00:00
|
|
|
|
400100,
|
|
|
|
|
|
400,
|
|
|
|
|
|
);
|
|
|
|
|
|
renderPage();
|
|
|
|
|
|
await screen.findByText(t("radar.watches.empty"));
|
|
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.add") }));
|
2026-08-19 07:37:44 +00:00
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: t("radar.watches.showAdvanced") }));
|
2026-08-03 05:52:02 +00:00
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: t("radar.suggest.ask") }));
|
|
|
|
|
|
|
|
|
|
|
|
const banner = await screen.findByRole("alert");
|
2026-08-19 07:37:44 +00:00
|
|
|
|
expect(banner.textContent).toContain("AI 沒有回傳可用的關鍵字建議");
|
2026-08-03 05:52:02 +00:00
|
|
|
|
expect(screen.queryByText(t("radar.suggest.none"))).toBeNull();
|
|
|
|
|
|
});
|
2026-08-13 07:54:25 +00:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-08-03 05:52:02 +00:00
|
|
|
|
});
|