import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { KEYS } from "../data/mock/keys"; import { I18nProvider } from "../i18n/I18nContext"; import { translate } from "../lib/i18n/messages"; import type { Opportunity, RadarToday } from "../domain/types"; import { RadarTodayPage } from "./RadarTodayPage"; const t = (key: string, params?: Record) => translate("zh-TW", key, params); const backend = vi.hoisted(() => ({ today: null as RadarToday | null, })); function sampleOpp(id: string, band: "high" | "mid" | "low", score: number): Opportunity { return { id, source: "threads", external_id: id, permalink: `https://example.com/${id}`, author_handle: "seeker", text: `商機內文 ${id}`, posted_at: Date.now() * 1e6, status: "qualified", intent_score: score, intent_band: band, reasons: [ { dimension: "authenticity", score: 20, reason: "真" }, { dimension: "intent", score: 20, reason: "想買" }, { dimension: "region", score: 10, reason: "地區" }, { dimension: "freshness", score: 10, reason: "新" }, { dimension: "fit", score: 10, reason: "合" }, ], region_match: "unknown", freshness_hours: 2, matched_terms: ["水電"], created_at: Date.now() * 1e6, }; } vi.mock("../data/DataContext", () => ({ useRepos: () => ({ radar: { async getToday() { if (!backend.today) throw new Error("no today"); return backend.today; }, async acceptOpportunity() { return { opportunity_id: "x", status: "accepted" }; }, async dismissOpportunity(id: string) { return sampleOpp(id, "high", 80); }, async createReply() { return { id: "r1", opportunity_id: "h1", variant: "public_comment" as const, text: "草稿", created_at: 1, }; }, async markReplyUsed() { return { reply: { id: "r1", opportunity_id: "h1", variant: "public_comment" as const, text: "草稿", used_at: 2, sent_channel: "manual_copy" as const, created_at: 1, }, }; }, async overrideOpportunity(id: string) { return sampleOpp(id, "mid", 60); }, }, }), })); function renderPage() { return render( , ); } describe("RadarTodayPage", () => { beforeEach(() => { backend.today = null; localStorage.setItem( KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }), ); }); it("shows empty reason and CTA when total is 0", async () => { backend.today = { stats: { total: 0, high: 0, mid: 0, low: 0 }, high: [], mid: [], low: [], truncated_count: 0, empty_reason: "no_watch", empty_hint: "建立訂閱", }; renderPage(); expect(await screen.findByText("建立訂閱")).toBeTruthy(); expect(screen.getByRole("link", { name: t("radar.today.empty.goWatches") })).toBeTruthy(); }); it("groups high/mid and collapses low by default", async () => { backend.today = { stats: { total: 3, high: 1, mid: 1, low: 1 }, high: [sampleOpp("h1", "high", 90)], mid: [sampleOpp("m1", "mid", 60)], low: [sampleOpp("l1", "low", 30)], truncated_count: 2, }; renderPage(); expect(await screen.findByText("商機內文 h1")).toBeTruthy(); expect(screen.getByText("商機內文 m1")).toBeTruthy(); expect(screen.queryByText("商機內文 l1")).toBeNull(); expect(screen.getByText(t("radar.today.truncated", { n: 2 }), { exact: false })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /低意向/ })); await waitFor(() => { expect(screen.getByText("商機內文 l1")).toBeTruthy(); }); }); });