import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { Repos } from "../data/repos"; import type { Job, ScoutHomeworkRecord, ScoutRun, ScoutRunBrief } from "../domain/types"; import { I18nProvider } from "../i18n/I18nContext"; import { ensureCatalog, translate } from "../lib/i18n/messages"; 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 { 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( , ); } describe("ScoutPage baseline harness", () => { // 英文字典是動態載入的,斷言前先確保它到位 beforeAll(async () => { await ensureCatalog("en"); }); 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(); }); });