276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||
|
|
import { MemoryRouter } from "react-router-dom";
|
||
|
|
import { 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 { 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<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", () => {
|
||
|
|
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();
|
||
|
|
});
|
||
|
|
});
|