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

281 lines
10 KiB
TypeScript
Raw Normal View History

2026-08-13 02:22:24 +00:00
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
2026-08-19 02:01:15 +00:00
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2026-08-13 02:22:24 +00:00
import type { Repos } from "../data/repos";
import type { Job, ScoutHomeworkRecord, ScoutRun, ScoutRunBrief } from "../domain/types";
import { I18nProvider } from "../i18n/I18nContext";
2026-08-19 02:01:15 +00:00
import { ensureCatalog, translate } from "../lib/i18n/messages";
2026-08-13 02:22:24 +00:00
import { scoutPostFixture } from "../test/scoutFixtures";
import { ScoutPage } from "./ScoutPage";
const harness = vi.hoisted(() => ({
repos: null as Repos | null,
failLoad: false,
jobs: [] as Job[],
listRunPostsCalls: 0,
}));
vi.mock("../data/DataContext", () => ({
useRepos: () => harness.repos,
useData: () => ({
repos: harness.repos,
dataSource: "live" as const,
refresh: () => {},
tick: 0,
}),
}));
vi.mock("../data/JobLiveContext", () => ({
useJobLive: () => ({
jobs: harness.jobs,
activeJobs: [],
reload: async () => {},
revision: 0,
}),
}));
function buildRepos(): Repos {
const run: ScoutRun = {
id: "run-fixture-1",
job_id: "job-fixture-1",
theme_key: "activity|主題",
theme_label: "主題",
intent: "主題",
mode: "activity",
target_count: 1,
status: "succeeded",
searched_count: 1,
duplicate_count: 0,
irrelevant_count: 0,
eligible_count: 2,
pending_count: 2,
shortfall_count: 0,
shortfall_reasons: [],
created_at: 1_700_000_001_000_000_000,
};
const post = scoutPostFixture({ run_id: run.id });
const unknownPost = scoutPostFixture({
id: "scout-post-unknown-time",
run_id: run.id,
permalink: "https://www.threads.net/@topic_user/post/scout-post-unknown-time",
posted_at: undefined,
created_at: 1_700_000_002_000_000_000,
});
const scout = {
async listRuns() {
if (harness.failLoad) throw new Error("fixture load failed");
return {
list: [run],
pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 },
};
},
async listRunPosts() {
harness.listRunPostsCalls += 1;
if (harness.failLoad) throw new Error("fixture load failed");
return {
list: [post, unknownPost],
pagination: { page: 1, pageSize: 8, total: 2, totalPages: 1 },
run,
};
},
async listHomework() {
if (harness.failLoad) throw new Error("fixture load failed");
return [];
},
async saveHomework(record: ScoutHomeworkRecord) {
return record;
},
async prepareBrief(): Promise<ScoutRunBrief> {
return {
intent: "主題",
mode: "activity",
brand_id: null,
product_id: null,
pains: [],
tags: ["主題"],
periphery: [],
scan_terms: ["主題"],
theme_key: "activity|主題",
theme_label: "主題",
};
},
async runScanFromBrief() {
throw new Error("scan not configured in fixture");
},
async removePost() {},
async removeRun() {},
};
const jobs = {
async list() { return { list: [], pagination: { page: 1, pageSize: 10, total: 0, totalPages: 0 } }; },
async get() { return null; },
async startDemo() { throw new Error("not used"); },
async remove() {},
};
return { scout, jobs } as unknown as Repos;
}
function renderPage() {
return render(
<MemoryRouter>
<I18nProvider>
<ScoutPage />
</I18nProvider>
</MemoryRouter>,
);
}
describe("ScoutPage baseline harness", () => {
2026-08-19 02:01:15 +00:00
// 英文字典是動態載入的,斷言前先確保它到位
beforeAll(async () => {
await ensureCatalog("en");
});
2026-08-13 02:22:24 +00:00
beforeEach(() => {
harness.failLoad = false;
harness.repos = buildRepos();
harness.jobs = [];
harness.listRunPostsCalls = 0;
localStorage.clear();
});
it("renders a loaded activity post through the repository seam", async () => {
renderPage();
expect((await screen.findAllByText("這是一則可供海巡測試的主題貼文")).length).toBeGreaterThan(0);
expect(screen.getByText(translate("en", "scout.runs"))).toBeTruthy();
expect(screen.getAllByText(/Posted/).length).toBeGreaterThan(0);
expect(screen.getAllByText(translate("en", "scout.postedUnknown")).length).toBeGreaterThan(0);
});
it("shows a readable load error without turning it into an empty success", async () => {
harness.failLoad = true;
renderPage();
await waitFor(() => {
expect(screen.getByText("fixture load failed")).toBeTruthy();
});
expect(screen.queryByText("這是一則可供海巡測試的主題貼文")).toBeNull();
});
it("keeps the selected run and result page when a scan completes", async () => {
const repos = buildRepos();
const queuedJob: Job = {
id: "job-new",
template_type: "scout_scan",
status: "queued",
progress_summary: "queued",
progress_percent: 0,
created_at: 1_700_000_003_000_000_000,
updated_at: 1_700_000_003_000_000_000,
};
const completedJob = { ...queuedJob, status: "succeeded" as const, updated_at: queuedJob.updated_at + 1 };
repos.scout.runScanFromBrief = async () => ({ job: queuedJob, run: { ...(await repos.scout.listRuns()).list[0]!, id: "run-new" } });
repos.jobs.get = async () => completedJob;
harness.repos = repos;
renderPage();
await screen.findAllByText("這是一則可供海巡測試的主題貼文");
const initialCalls = harness.listRunPostsCalls;
fireEvent.change(screen.getByLabelText(translate("en", "scout.keyword")), { target: { value: "主題" } });
fireEvent.click(screen.getByRole("button", { name: translate("en", "scout.planKeywords") }));
await screen.findByText(translate("en", "scout.confirmScan"));
fireEvent.click(screen.getByRole("button", { name: translate("en", "scout.confirmScan") }));
await waitFor(() => expect(screen.getByText(translate("en", "scout.newRunReady"))).toBeTruthy());
expect(harness.listRunPostsCalls).toBe(initialCalls);
expect(screen.getAllByText("這是一則可供海巡測試的主題貼文").length).toBeGreaterThan(0);
});
it("offers an immediate patrol that still prepares focused search terms", async () => {
const repos = buildRepos();
const queuedJob: Job = {
id: "job-immediate",
template_type: "scout_scan",
status: "queued",
progress_summary: "queued",
progress_percent: 0,
created_at: 1_700_000_003_000_000_000,
updated_at: 1_700_000_003_000_000_000,
};
const runScan = vi.fn(async (brief: ScoutRunBrief) => ({
job: queuedJob,
run: { ...(await repos.scout.listRuns()).list[0]!, id: "run-immediate", intent: brief.intent },
}));
repos.scout.runScanFromBrief = runScan;
harness.repos = repos;
renderPage();
await screen.findAllByText("這是一則可供海巡測試的主題貼文");
fireEvent.change(screen.getByLabelText(translate("en", "scout.keyword")), { target: { value: "主題" } });
fireEvent.click(screen.getByRole("button", { name: translate("en", "scout.startImmediate") }));
await waitFor(() => expect(runScan).toHaveBeenCalledTimes(1));
expect(runScan.mock.calls[0]?.[0].scan_terms.length).toBeGreaterThan(0);
expect(screen.getByText(/Patrol job .*queued/)).toBeTruthy();
});
it("keeps the batch visible when deleting a running batch conflicts", async () => {
const repos = buildRepos();
repos.scout.removeRun = async () => {
throw Object.assign(new Error("run is busy"), { code: 409031 });
};
harness.repos = repos;
vi.spyOn(window, "confirm").mockReturnValue(true);
renderPage();
await screen.findAllByText("這是一則可供海巡測試的主題貼文");
fireEvent.click(screen.getByRole("button", { name: translate("en", "scout.deleteRun") }));
await waitFor(() => expect(screen.getByText(translate("en", "scout.err.runBusy"))).toBeTruthy());
expect(screen.getByText(translate("en", "scout.runs"))).toBeTruthy();
vi.restoreAllMocks();
});
it("keeps the current post when it was already removed elsewhere", async () => {
const repos = buildRepos();
repos.scout.removePost = async () => {
throw Object.assign(new Error("not found"), { code: 404001 });
};
harness.repos = repos;
vi.spyOn(window, "confirm").mockReturnValue(true);
renderPage();
await screen.findAllByText("這是一則可供海巡測試的主題貼文");
fireEvent.click(screen.getByRole("button", { name: /^Delete$/ }));
await waitFor(() => expect(screen.getByText(translate("en", "scout.err.notFound"))).toBeTruthy());
expect(screen.getAllByText("這是一則可供海巡測試的主題貼文").length).toBeGreaterThan(0);
vi.restoreAllMocks();
});
it("backs up from an emptied result page after deleting its last post", async () => {
const repos = buildRepos();
const baseRun = (await repos.scout.listRuns()).list[0]!;
const allPosts = Array.from({ length: 9 }, (_, index) =>
scoutPostFixture({
id: `page-post-${index + 1}`,
run_id: baseRun.id,
text: `分頁貼文 ${index + 1}`,
posted_at: 1_700_000_000_000_000_000 + index,
}),
);
let removed = false;
repos.scout.listRunPosts = async (_runId, query) => {
const current = removed ? allPosts.slice(0, 8) : allPosts;
const page = query?.page ?? 1;
const pageSize = query?.pageSize ?? 8;
return {
list: current.slice((page - 1) * pageSize, page * pageSize),
pagination: { page, pageSize, total: current.length, totalPages: Math.ceil(current.length / pageSize) },
run: baseRun,
};
};
repos.scout.removePost = async () => {
removed = true;
};
harness.repos = repos;
vi.spyOn(window, "confirm").mockReturnValue(true);
renderPage();
await screen.findAllByText("分頁貼文 1");
fireEvent.click(screen.getByRole("button", { name: translate("en", "pager.next") }));
await screen.findAllByText("分頁貼文 9");
fireEvent.click(screen.getByRole("button", { name: /^Delete$/ }));
await waitFor(() => expect(screen.getAllByText("分頁貼文 1").length).toBeGreaterThan(0));
expect(screen.queryByText("分頁貼文 9")).toBeNull();
vi.restoreAllMocks();
});
});