diff --git a/src/web/features/chat/parts/TextPart.tsx b/src/web/features/chat/parts/TextPart.tsx index 102d910..18bbdb5 100644 --- a/src/web/features/chat/parts/TextPart.tsx +++ b/src/web/features/chat/parts/TextPart.tsx @@ -1,5 +1,5 @@ import { Typography } from "antd"; -import Markdown from "markdown-to-jsx/react"; +import { Markdown } from "markdown-to-jsx/react"; import type { PartProps } from "./types"; diff --git a/src/web/features/inbox/components/MaterialCard.tsx b/src/web/features/inbox/components/MaterialCard.tsx index 4419913..8db5cfd 100644 --- a/src/web/features/inbox/components/MaterialCard.tsx +++ b/src/web/features/inbox/components/MaterialCard.tsx @@ -3,6 +3,8 @@ import { Button, Flex, Popconfirm, Tag, Typography } from "antd"; import type { Material, MaterialStatus } from "../types"; +import { formatDateLabel } from "../../../shared/utils/time"; + interface MaterialCardProps { material: Material; onDelete: () => void; @@ -12,7 +14,7 @@ interface MaterialCardProps { function formatAssociatedDate(date: string): string { if (!date) return "—"; - return date; + return formatDateLabel(date); } const STATUS_MAP: Record = { diff --git a/src/web/shared/utils/time.ts b/src/web/shared/utils/time.ts index 824ad7b..5550993 100644 --- a/src/web/shared/utils/time.ts +++ b/src/web/shared/utils/time.ts @@ -3,6 +3,23 @@ export function formatCountdown(seconds: number): string { return `${Math.floor(seconds / 60)}分${seconds % 60}秒`; } +export function formatDateLabel(dateStr: string, now: Date = new Date()): string { + const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) return "—"; + + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today.getTime() - 86_400_000); + const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + + if (dateDay.getTime() >= today.getTime()) return "今天"; + if (dateDay.getTime() >= yesterday.getTime()) return "昨天"; + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + export function formatDurationUnit(ms: null | number): { suffix: string; value: number } { if (ms === null) return { suffix: "", value: 0 }; if (ms < 60000) return { suffix: "秒", value: roundToOne(ms / 1000) }; diff --git a/tests/server/bootstrap-db.test.ts b/tests/server/bootstrap-db.test.ts deleted file mode 100644 index 64fcf24..0000000 --- a/tests/server/bootstrap-db.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable @typescript-eslint/require-await */ -import { describe, expect, test } from "bun:test"; -import { mkdirSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import type { ResolvedConfig } from "../../src/server/config/types"; - -import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap"; -import { createMemoryLogger } from "../../src/server/logger"; - -function makeTempConfig(overrides: Partial = {}): ResolvedConfig { - const base = join(tmpdir(), `bootstrap-db-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(base, { recursive: true }); - return { - configDir: base, - dataDir: join(base, "data"), - host: "127.0.0.1", - logging: { - consoleLevel: "info", - fileLevel: "info", - filePath: join(base, "data", "logs", "test.log"), - rotationFrequency: "daily", - rotationMaxFiles: 14, - rotationSizeBytes: 52428800, - rotationSizeRaw: "50MB", - }, - port: 0, - ...overrides, - }; -} - -describe("bootstrap 数据库集成", () => { - test("启动时将数据库传递给 startServer", async () => { - let started = false; - let receivedDb: unknown = undefined; - - const cfg = makeTempConfig(); - const mockLoadConfig = (async () => cfg) as unknown as BootstrapDependencies["loadConfig"]; - const mockOnSignal = (_signal: string, _handler: () => void) => {}; - const mockStartServer = (options: { db: unknown }) => { - receivedDb = options.db; - started = true; - return { close: () => {} }; - }; - - const deps: BootstrapDependencies = { - createLogger: async () => createMemoryLogger(), - loadConfig: mockLoadConfig, - onSignal: mockOnSignal, - startServer: mockStartServer, - }; - - await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps); - - expect(started).toBe(true); - expect(receivedDb).not.toBeUndefined(); - expect(typeof (receivedDb as { close?: unknown }).close).toBe("function"); - }); -}); diff --git a/tests/server/bootstrap.test.ts b/tests/server/bootstrap.test.ts index 094859f..4b9a39b 100644 --- a/tests/server/bootstrap.test.ts +++ b/tests/server/bootstrap.test.ts @@ -265,4 +265,27 @@ describe("bootstrap", () => { expect(flushed).toBe(true); expect(exitCode).toBe(0); }); + + test("启动时将数据库传递给 startServer", async () => { + let started = false; + let receivedDb: unknown = undefined; + + const cfg = makeTempConfig(); + const deps: BootstrapDependencies = { + createLogger: async () => createMemoryLogger(), + loadConfig: async () => cfg, + onSignal: (_signal, _handler) => {}, + startServer: (options: { db: unknown }) => { + receivedDb = options.db; + started = true; + return {}; + }, + }; + + await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps); + + expect(started).toBe(true); + expect(receivedDb).not.toBeUndefined(); + expect(typeof (receivedDb as { close?: unknown }).close).toBe("function"); + }); }); diff --git a/tests/server/routes/projects.test.ts b/tests/server/routes/projects.test.ts deleted file mode 100644 index 47aa7d0..0000000 --- a/tests/server/routes/projects.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type Database from "bun:sqlite"; - -import { describe, expect, test } from "bun:test"; - -import type { Project, RuntimeMode } from "../../../src/shared/api"; - -import { createNoopLogger } from "../../../src/server/logger"; -import { createMigratedMemoryTestDatabase } from "../../helpers"; - -const MODE: RuntimeMode = "test"; -const LOG = createNoopLogger(); - -async function archiveProjectViaHandler(req: Request, db: Database): Promise { - const { handleArchiveProject: h } = await import("../../../src/server/routes/projects/archive"); - return h(req, db, MODE, LOG); -} - -// Inline imports for actual route handler tests (each handler is in separate file) -async function createProjectViaHandler(req: Request, db: Database): Promise { - const { handleCreateProject: h } = await import("../../../src/server/routes/projects/create"); - return h(req, db, MODE, LOG); -} - -function createTestProject(db: Database, name = "测试项目"): Project { - const result = createProject(db, { name }, LOG); - if ("error" in result) throw new Error(result.error); - return result.project; -} - -async function deleteProjectViaHandler(req: Request, db: Database): Promise { - const { handleDeleteProject: h } = await import("../../../src/server/routes/projects/delete"); - return h(req, db, MODE, LOG); -} - -async function getProjectViaHandler(req: Request, db: Database): Promise { - const { handleGetProject: h } = await import("../../../src/server/routes/projects/get"); - return h(req, db, MODE, LOG); -} - -async function listProjectsViaHandler(req: Request, db: Database): Promise { - const { handleListProjects: h } = await import("../../../src/server/routes/projects/list"); - return h(req, db, MODE, LOG); -} - -async function restoreProjectViaHandler(req: Request, db: Database): Promise { - const { handleRestoreProject: h } = await import("../../../src/server/routes/projects/restore"); - return h(req, db, MODE, LOG); -} - -async function updateProjectViaHandler(req: Request, db: Database): Promise { - const { handleUpdateProject: h } = await import("../../../src/server/routes/projects/update"); - return h(req, db, MODE, LOG); -} - -// Need db/projects for setup -import { archiveProject, createProject, getProject } from "../../../src/server/db/projects"; - -async function withRouteDb(callback: (db: Database) => Promise): Promise { - const handle = createMigratedMemoryTestDatabase("route-test"); - try { - await callback(handle.db); - handle.close(); - } finally { - handle.cleanup(); - } -} - -describe("项目 API 路由", () => { - test("POST /api/projects 创建项目", async () => { - await withRouteDb(async (db) => { - const req = new Request("http://localhost/api/projects", { - body: JSON.stringify({ description: "路由测试", name: "路由项目" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - const res = await createProjectViaHandler(req, db); - expect(res.status).toBe(201); - const body = (await res.json()) as { project: Project }; - expect(body.project.name).toBe("路由项目"); - }); - }); - - test("GET /api/projects 列表查询", async () => { - await withRouteDb(async (db) => { - createTestProject(db, "A项目"); - createTestProject(db, "B项目"); - - const req = new Request("http://localhost/api/projects?page=1&pageSize=20"); - const res = await listProjectsViaHandler(req, db); - expect(res.status).toBe(200); - const body = (await res.json()) as { items: Project[]; total: number }; - expect(body.total).toBe(2); - expect(body.items.length).toBe(2); - }); - }); - - test("GET /api/projects/:id 获取详情", async () => { - await withRouteDb(async (db) => { - const project = createTestProject(db, "详情路由"); - - const req = new Request(`http://localhost/api/projects/${project.id}`); - const res = await getProjectViaHandler(req, db); - expect(res.status).toBe(200); - const body = (await res.json()) as { project: Project }; - expect(body.project.name).toBe("详情路由"); - }); - }); - - test("PATCH /api/projects/:id 更新项目", async () => { - await withRouteDb(async (db) => { - const project = createTestProject(db, "更新路由"); - - const req = new Request(`http://localhost/api/projects/${project.id}`, { - body: JSON.stringify({ name: "已更新" }), - headers: { "Content-Type": "application/json" }, - method: "PATCH", - }); - const res = await updateProjectViaHandler(req, db); - expect(res.status).toBe(200); - const body = (await res.json()) as { project: Project }; - expect(body.project.name).toBe("已更新"); - }); - }); - - test("POST /api/projects/:id/archive 归档项目", async () => { - await withRouteDb(async (db) => { - const project = createTestProject(db, "归档路由"); - - const req = new Request(`http://localhost/api/projects/${project.id}/archive`, { method: "POST" }); - const res = await archiveProjectViaHandler(req, db); - expect(res.status).toBe(200); - const body = (await res.json()) as { project: Project }; - expect(body.project.status).toBe("archived"); - }); - }); - - test("POST /api/projects/:id/restore 恢复项目", async () => { - await withRouteDb(async (db) => { - const project = createTestProject(db, "恢复路由"); - archiveProject(db, project.id, LOG); - - const req = new Request(`http://localhost/api/projects/${project.id}/restore`, { method: "POST" }); - const res = await restoreProjectViaHandler(req, db); - expect(res.status).toBe(200); - const body = (await res.json()) as { project: Project }; - expect(body.project.status).toBe("active"); - }); - }); - - test("DELETE /api/projects/:id 永久删除已归档项目", async () => { - await withRouteDb(async (db) => { - const project = createTestProject(db, "删除路由"); - archiveProject(db, project.id, LOG); - - const req = new Request(`http://localhost/api/projects/${project.id}`, { method: "DELETE" }); - const res = await deleteProjectViaHandler(req, db); - expect(res.status).toBe(204); - - const after = getProject(db, project.id); - expect("error" in after).toBe(true); - }); - }); - - test("创建同名项目返回 409", async () => { - await withRouteDb(async (db) => { - const req1 = new Request("http://localhost/api/projects", { - body: JSON.stringify({ name: "重复名" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - await createProjectViaHandler(req1, db); - - const req2 = new Request("http://localhost/api/projects", { - body: JSON.stringify({ name: "重复名" }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - const res = await createProjectViaHandler(req2, db); - expect(res.status).toBe(409); - }); - }); - - test("删除 active 项目返回 409", async () => { - await withRouteDb(async (db) => { - const project = createTestProject(db, "活项目"); - - const req = new Request(`http://localhost/api/projects/${project.id}`, { method: "DELETE" }); - const res = await deleteProjectViaHandler(req, db); - expect(res.status).toBe(409); - }); - }); -}); diff --git a/tests/web/components/ModelTable.test.tsx b/tests/web/components/ModelTable.test.tsx deleted file mode 100644 index 893bf2a..0000000 --- a/tests/web/components/ModelTable.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import { describe, expect, mock, test } from "bun:test"; -import { createElement } from "react"; - -import type { Model, ProviderOption } from "../../../src/shared/api"; - -import { ModelTable } from "../../../src/web/features/models/components/ModelTable"; -import { renderWithProviders } from "../test-utils"; - -const OPENAI_PROVIDER: ProviderOption = { - id: "pv1", - name: "OpenAI", - type: "openai", -}; - -const DEEPSEEK_PROVIDER: ProviderOption = { - id: "pv2", - name: "DeepSeek", - type: "openai-compatible", -}; - -const ENABLED_MODEL: Model = { - capabilities: ["text", "reasoning"], - contextLength: 128000, - createdAt: "2024-01-01T00:00:00.000Z", - id: "m1", - maxOutputTokens: 4096, - modelId: "gpt-4o", - name: "GPT-4o", - providerId: "pv1", - updatedAt: "2024-01-01T00:00:00.000Z", -}; - -const DISABLED_MODEL: Model = { - capabilities: ["text"], - contextLength: null, - createdAt: "2024-01-01T00:00:00.000Z", - id: "m2", - maxOutputTokens: null, - modelId: "deepseek-chat", - name: "DeepSeek Chat", - providerId: "pv2", - updatedAt: "2024-01-01T00:00:00.000Z", -}; - -function clickLatestConfirmButton() { - const buttons = screen.getAllByRole("button", { name: /OK|确定/ }); - fireEvent.click(buttons[buttons.length - 1]!); -} - -describe("ModelTable", () => { - test("渲染模型表格数据", () => { - renderWithProviders( - createElement(ModelTable, { - data: { items: [ENABLED_MODEL, DISABLED_MODEL], page: 1, pageSize: 20, total: 2 }, - loading: false, - onDelete: () => Promise.resolve(), - onEdit: () => undefined, - onPageChange: () => undefined, - page: 1, - pageSize: 20, - providers: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], - }), - ); - - expect(screen.getByText("GPT-4o")).not.toBeNull(); - expect(screen.getByText("DeepSeek Chat")).not.toBeNull(); - expect(screen.getByText("OpenAI")).not.toBeNull(); - expect(screen.getByText("DeepSeek")).not.toBeNull(); - expect(screen.queryByText("状态")).toBeNull(); - expect(screen.queryByRole("button", { name: /启用|禁用/ })).toBeNull(); - }); - - test("模型表格操作触发 edit/delete", async () => { - const onDelete = mock(() => Promise.resolve()); - const onEdit = mock(() => undefined); - - renderWithProviders( - createElement(ModelTable, { - data: { items: [ENABLED_MODEL, DISABLED_MODEL], page: 1, pageSize: 20, total: 2 }, - loading: false, - onDelete, - onEdit, - onPageChange: () => undefined, - page: 1, - pageSize: 20, - providers: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], - }), - ); - - fireEvent.click(screen.getAllByRole("button", { name: /编辑/ })[0]!); - expect(onEdit).toHaveBeenCalledWith(ENABLED_MODEL); - - fireEvent.click(screen.getAllByRole("button", { name: /删除/ })[0]!); - await waitFor(() => expect(screen.getByText("确认删除此模型?")).not.toBeNull()); - clickLatestConfirmButton(); - await waitFor(() => expect(onDelete).toHaveBeenCalledWith("m1")); - }); -}); diff --git a/tests/web/components/ProviderTable.test.tsx b/tests/web/components/ProviderTable.test.tsx deleted file mode 100644 index 928942b..0000000 --- a/tests/web/components/ProviderTable.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import { describe, expect, mock, test } from "bun:test"; -import { createElement } from "react"; - -import type { Provider } from "../../../src/shared/api"; - -import { ProviderTable } from "../../../src/web/features/models/components/ProviderTable"; -import { renderWithProviders } from "../test-utils"; - -const OPENAI_PROVIDER: Provider = { - apiKey: "sk-test", - baseUrl: "https://api.openai.com/v1", - createdAt: "2024-01-01T00:00:00.000Z", - id: "pv1", - name: "OpenAI", - type: "openai", - updatedAt: "2024-01-01T00:00:00.000Z", -}; - -const DEEPSEEK_PROVIDER: Provider = { - apiKey: "sk-off", - baseUrl: "https://api.deepseek.com/v1", - createdAt: "2024-01-01T00:00:00.000Z", - id: "pv2", - name: "DeepSeek", - type: "openai-compatible", - updatedAt: "2024-01-01T00:00:00.000Z", -}; - -function clickLatestConfirmButton() { - const buttons = screen.getAllByRole("button", { name: /OK|确定/ }); - fireEvent.click(buttons[buttons.length - 1]!); -} - -describe("ProviderTable", () => { - test("渲染供应商表格数据", () => { - renderWithProviders( - createElement(ProviderTable, { - data: { items: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], page: 1, pageSize: 20, total: 2 }, - loading: false, - onDelete: () => Promise.resolve(), - onEdit: () => undefined, - onPageChange: () => undefined, - page: 1, - pageSize: 20, - }), - ); - - expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0); - expect(screen.getByText("DeepSeek")).not.toBeNull(); - expect(screen.getByText("https://api.openai.com/v1")).not.toBeNull(); - expect(screen.queryByText("状态")).toBeNull(); - expect(screen.queryByRole("button", { name: "测试连接" })).toBeNull(); - expect(screen.queryByRole("button", { name: /启用|禁用/ })).toBeNull(); - }); - - test("供应商表格操作触发 edit/delete", async () => { - const onDelete = mock(() => Promise.resolve()); - const onEdit = mock(() => undefined); - - renderWithProviders( - createElement(ProviderTable, { - data: { items: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], page: 1, pageSize: 20, total: 2 }, - loading: false, - onDelete, - onEdit, - onPageChange: () => undefined, - page: 1, - pageSize: 20, - }), - ); - - fireEvent.click(screen.getAllByRole("button", { name: /编辑/ })[0]!); - expect(onEdit).toHaveBeenCalledWith(OPENAI_PROVIDER); - - fireEvent.click(screen.getAllByRole("button", { name: /删除/ })[0]!); - await waitFor(() => expect(screen.getByText("确认删除此供应商?")).not.toBeNull()); - clickLatestConfirmButton(); - await waitFor(() => expect(onDelete).toHaveBeenCalledWith("pv1")); - }); -}); diff --git a/tests/web/components/ResourceTable.test.tsx b/tests/web/components/ResourceTable.test.tsx new file mode 100644 index 0000000..193dfdb --- /dev/null +++ b/tests/web/components/ResourceTable.test.tsx @@ -0,0 +1,176 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, expect, mock, test } from "bun:test"; +import { createElement } from "react"; + +import type { Model, Provider, ProviderOption } from "../../../src/shared/api"; + +import { ModelTable } from "../../../src/web/features/models/components/ModelTable"; +import { ProviderTable } from "../../../src/web/features/models/components/ProviderTable"; +import { renderWithProviders } from "../test-utils"; + +const OPENAI_PROVIDER_OPTION: ProviderOption = { + id: "pv1", + name: "OpenAI", + type: "openai", +}; + +const DEEPSEEK_PROVIDER_OPTION: ProviderOption = { + id: "pv2", + name: "DeepSeek", + type: "openai-compatible", +}; + +const ENABLED_MODEL: Model = { + capabilities: ["text", "reasoning"], + contextLength: 128000, + createdAt: "2024-01-01T00:00:00.000Z", + id: "m1", + maxOutputTokens: 4096, + modelId: "gpt-4o", + name: "GPT-4o", + providerId: "pv1", + updatedAt: "2024-01-01T00:00:00.000Z", +}; + +const DISABLED_MODEL: Model = { + capabilities: ["text"], + contextLength: null, + createdAt: "2024-01-01T00:00:00.000Z", + id: "m2", + maxOutputTokens: null, + modelId: "deepseek-chat", + name: "DeepSeek Chat", + providerId: "pv2", + updatedAt: "2024-01-01T00:00:00.000Z", +}; + +const OPENAI_PROVIDER: Provider = { + apiKey: "sk-test", + baseUrl: "https://api.openai.com/v1", + createdAt: "2024-01-01T00:00:00.000Z", + id: "pv1", + name: "OpenAI", + type: "openai", + updatedAt: "2024-01-01T00:00:00.000Z", +}; + +const DEEPSEEK_PROVIDER: Provider = { + apiKey: "sk-off", + baseUrl: "https://api.deepseek.com/v1", + createdAt: "2024-01-01T00:00:00.000Z", + id: "pv2", + name: "DeepSeek", + type: "openai-compatible", + updatedAt: "2024-01-01T00:00:00.000Z", +}; + +function clickLatestConfirmButton() { + const buttons = screen.getAllByRole("button", { name: /OK|确定/ }); + fireEvent.click(buttons[buttons.length - 1]!); +} + +function renderModelTable(overrides?: Record) { + renderWithProviders( + createElement(ModelTable, { + data: { items: [ENABLED_MODEL, DISABLED_MODEL], page: 1, pageSize: 20, total: 2 }, + loading: false, + onDelete: () => Promise.resolve(), + onEdit: () => undefined, + onPageChange: () => undefined, + page: 1, + pageSize: 20, + providers: [OPENAI_PROVIDER_OPTION, DEEPSEEK_PROVIDER_OPTION], + ...overrides, + }), + ); +} + +function renderProviderTable(overrides?: Record) { + renderWithProviders( + createElement(ProviderTable, { + data: { items: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], page: 1, pageSize: 20, total: 2 }, + loading: false, + onDelete: () => Promise.resolve(), + onEdit: () => undefined, + onPageChange: () => undefined, + page: 1, + pageSize: 20, + ...overrides, + }), + ); +} + +const TABLE_TEST_CASES = [ + { + assertData: () => { + expect(screen.getByText("GPT-4o")).not.toBeNull(); + expect(screen.getByText("DeepSeek Chat")).not.toBeNull(); + expect(screen.getByText("OpenAI")).not.toBeNull(); + expect(screen.getByText("DeepSeek")).not.toBeNull(); + }, + assertNoExtra: () => { + expect(screen.queryByText("状态")).toBeNull(); + expect(screen.queryByRole("button", { name: /启用|禁用/ })).toBeNull(); + }, + componentName: "ModelTable", + render: () => renderModelTable(), + }, + { + assertData: () => { + expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0); + expect(screen.getByText("DeepSeek")).not.toBeNull(); + expect(screen.getByText("https://api.openai.com/v1")).not.toBeNull(); + }, + assertNoExtra: () => { + expect(screen.queryByText("状态")).toBeNull(); + expect(screen.queryByRole("button", { name: "测试连接" })).toBeNull(); + expect(screen.queryByRole("button", { name: /启用|禁用/ })).toBeNull(); + }, + componentName: "ProviderTable", + render: () => renderProviderTable(), + }, +]; + +const TABLE_ACTION_TEST_CASES = [ + { + componentName: "ModelTable", + deleteConfirmText: "确认删除此模型?", + deleteId: "m1", + editArg: ENABLED_MODEL, + render: (overrides?: Record) => renderModelTable(overrides), + }, + { + componentName: "ProviderTable", + deleteConfirmText: "确认删除此供应商?", + deleteId: "pv1", + editArg: OPENAI_PROVIDER, + render: (overrides?: Record) => renderProviderTable(overrides), + }, +]; + +describe("ResourceTable", () => { + for (const tc of TABLE_TEST_CASES) { + test(`${tc.componentName} 渲染表格数据`, () => { + tc.render(); + tc.assertData(); + tc.assertNoExtra(); + }); + } + + for (const tc of TABLE_ACTION_TEST_CASES) { + test(`${tc.componentName} 表格操作触发 edit/delete`, async () => { + const onDelete = mock(() => Promise.resolve()); + const onEdit = mock(() => undefined); + + tc.render({ onDelete, onEdit }); + + fireEvent.click(screen.getAllByRole("button", { name: /编辑/ })[0]!); + expect(onEdit).toHaveBeenCalledWith(tc.editArg); + + fireEvent.click(screen.getAllByRole("button", { name: /删除/ })[0]!); + await waitFor(() => expect(screen.getByText(tc.deleteConfirmText)).not.toBeNull()); + clickLatestConfirmButton(); + await waitFor(() => expect(onDelete).toHaveBeenCalledWith(tc.deleteId)); + }); + } +}); diff --git a/tests/web/components/chat/MarkdownTable.test.tsx b/tests/web/components/chat/MarkdownTable.test.tsx deleted file mode 100644 index 69da80d..0000000 --- a/tests/web/components/chat/MarkdownTable.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, test } from "bun:test"; -import { createElement } from "react"; - -import { MarkdownTable } from "../../../../src/web/features/chat/parts/MarkdownTable"; -import { renderWithProviders } from "../../test-utils"; - -describe("MarkdownTable 渲染表格", () => { - test("渲染原生 table 元素并添加 class", () => { - const children = createElement( - "thead", - null, - createElement("tr", null, createElement("th", null, "列1"), createElement("th", null, "列2")), - ); - - renderWithProviders(createElement(MarkdownTable, { children })); - - const table = document.querySelector(".markdown-table"); - expect(table).toBeTruthy(); - expect(table!.tagName).toBe("TABLE"); - expect(screen.getByText("列1")).toBeTruthy(); - expect(screen.getByText("列2")).toBeTruthy(); - }); - - test("正确传递 children 内容", () => { - const children = createElement( - "tbody", - null, - createElement("tr", null, createElement("td", null, "值1"), createElement("td", null, "值2")), - ); - - renderWithProviders(createElement(MarkdownTable, { children })); - - expect(screen.getByText("值1")).toBeTruthy(); - expect(screen.getByText("值2")).toBeTruthy(); - }); - - test("只有传入 table 元素时才有 class", () => { - const { container } = renderWithProviders(createElement(MarkdownTable, { children: null })); - - expect(container.querySelector(".markdown-table")).toBeTruthy(); - }); -}); diff --git a/tests/web/components/query-client-logging.test.tsx b/tests/web/components/query-client-logging.test.tsx deleted file mode 100644 index aa6b7ae..0000000 --- a/tests/web/components/query-client-logging.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { MutationCache, QueryCache, QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; -import { render } from "@testing-library/react"; -import { describe, expect, test } from "bun:test"; -import { createElement, useRef } from "react"; - -import { useCreateProject } from "../../../src/web/shared/hooks/use-projects"; -import { installFetchMock, jsonResponse } from "../test-utils"; - -describe("QueryClient MutationCache onError", () => { - test("mutation 错误触发 MutationCache onError 回调", async () => { - installFetchMock(() => jsonResponse({ error: "项目名称已存在" }, { status: 409 })); - - const errors: string[] = []; - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - mutationCache: new MutationCache({ - onError: (error: Error) => { - errors.push(error.message); - }, - }), - }); - - function TestComponent({ onResult }: { onResult: (mutate: () => void) => void }) { - const { mutate } = useCreateProject(); - const called = useRef(false); - - if (!called.current) { - called.current = true; - onResult(() => { - mutate( - { name: "test" }, - { - onError: () => {}, - }, - ); - }); - } - - return null; - } - - render( - createElement( - QueryClientProvider, - { client: queryClient }, - createElement(TestComponent, { onResult: (fn) => fn() }), - ), - ); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(errors.length).toBe(1); - expect(errors[0]).toBe("项目名称已存在"); - }); -}); - -describe("QueryClient QueryCache onError", () => { - test("query 错误触发 QueryCache onError 回调", async () => { - installFetchMock(() => new Response("broken", { status: 500 })); - - const errors: string[] = []; - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - queryCache: new QueryCache({ - onError: (error: Error) => { - errors.push(error.message); - }, - }), - }); - - function TestComponent({ onResult }: { onResult: (trigger: () => void) => void }) { - const called = useRef(false); - - useQuery({ - queryFn: () => Promise.reject(new Error("test query error")), - queryKey: ["test-query-error"], - }); - - if (!called.current) { - called.current = true; - onResult(() => { - // no-op trigger - }); - } - - return null; - } - - render( - createElement( - QueryClientProvider, - { client: queryClient }, - createElement(TestComponent, { onResult: (fn) => fn() }), - ), - ); - - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(errors.length).toBe(1); - expect(errors[0]).toBe("test query error"); - }); -}); diff --git a/tests/web/features/inbox/MaterialContent.test.tsx b/tests/web/features/inbox/MaterialContent.test.tsx deleted file mode 100644 index 1a1c48b..0000000 --- a/tests/web/features/inbox/MaterialContent.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, test } from "bun:test"; -import { createElement } from "react"; - -import type { Material } from "../../../../src/shared/api"; - -import { MaterialContent } from "../../../../src/web/features/inbox/components/MaterialContent"; -import { renderWithProviders } from "../../test-utils"; - -const MOCK_MATERIAL: Material = { - associatedDate: "2026-06-03", - createdAt: "2026-06-03T00:00:00.000Z", - description: "详细描述内容", - id: "test-id", - projectId: "project-1", - status: "pending", - updatedAt: "2026-06-03T00:00:00.000Z", -}; - -describe("MaterialContent", () => { - test("展示素材详情和状态", () => { - renderWithProviders(createElement(MaterialContent, { material: MOCK_MATERIAL })); - expect(screen.getByText("素材详情")).not.toBeNull(); - expect(screen.getByText("详细描述内容")).not.toBeNull(); - expect(screen.getByText("2026-06-03")).not.toBeNull(); - expect(screen.getByText("待审核")).not.toBeNull(); - }); - - test("展示已通过状态", () => { - const approved: Material = { ...MOCK_MATERIAL, status: "approved" }; - renderWithProviders(createElement(MaterialContent, { material: approved })); - expect(screen.getByText("已通过")).not.toBeNull(); - }); - - test("展示已放弃状态", () => { - const discarded: Material = { ...MOCK_MATERIAL, status: "discarded" }; - renderWithProviders(createElement(MaterialContent, { material: discarded })); - expect(screen.getByText("已放弃")).not.toBeNull(); - }); -}); diff --git a/tests/web/features/inbox/MaterialList.test.tsx b/tests/web/features/inbox/MaterialList.test.tsx index bda5d91..43f3c7b 100644 --- a/tests/web/features/inbox/MaterialList.test.tsx +++ b/tests/web/features/inbox/MaterialList.test.tsx @@ -74,7 +74,7 @@ describe("MaterialList", () => { expect(onAddClick).toHaveBeenCalledTimes(1); }); - test("加载中显示 Spin", () => { + test("加载中显示 Skeleton", () => { renderWithProviders( createElement(MaterialList, { loading: true, @@ -85,6 +85,6 @@ describe("MaterialList", () => { selectedId: null, }), ); - expect(document.querySelector(".ant-spin")).not.toBeNull(); + expect(document.querySelector(".ant-skeleton")).not.toBeNull(); }); }); diff --git a/tests/web/hooks/on-success-logging.test.tsx b/tests/web/hooks/on-success-logging.test.tsx deleted file mode 100644 index 463a597..0000000 --- a/tests/web/hooks/on-success-logging.test.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render } from "@testing-library/react"; -import { describe, expect, mock, test } from "bun:test"; -import { createElement, useRef } from "react"; - -import { - useCreateModel, - useDeleteModel, - useTestModelConnection, - useUpdateModel, -} from "../../../src/web/shared/hooks/use-models"; -import { - useArchiveProject, - useCreateProject, - useDeleteProject, - useRestoreProject, - useUpdateProject, -} from "../../../src/web/shared/hooks/use-projects"; -import { - useCreateProvider, - useDeleteProvider, - useTestProviderConfig, - useUpdateProvider, -} from "../../../src/web/shared/hooks/use-providers"; -import { installFetchMock, jsonResponse } from "../test-utils"; - -const MODEL = { - autoAdapt: true, - capabilities: ["text"] as string[], - createdAt: "2024-01-01T00:00:00.000Z", - customApiKey: null, - customBaseUrl: null, - description: "测试模型", - id: "m1", - modelId: "gpt-4", - name: "测试模型", - providerId: "prov-1", - updatedAt: "2024-01-01T00:00:00.000Z", -}; - -const PROJECT = { - archivedAt: null, - createdAt: "2024-01-01T00:00:00.000Z", - description: "测试", - id: "p1", - name: "测试项目", - status: "active" as const, - updatedAt: "2024-01-01T00:00:00.000Z", -}; - -const PROVIDER = { - createdAt: "2024-01-01T00:00:00.000Z", - id: "prov-1", - name: "测试供应商", - type: "openai" as const, - updatedAt: "2024-01-01T00:00:00.000Z", -}; - -function getLogMessages(spy: ReturnType) { - return spy.mock.calls.map((c) => c[0] as string).filter((s) => s.includes("[Alfred:INFO]")); -} - -function makeQueryClient() { - return new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); -} - -function setupModelFetches(result: unknown) { - installFetchMock((call) => { - if (call.method === "DELETE") return new Response(null, { status: 204 }); - if (call.url.includes("test")) return jsonResponse({ modelTestResponse: { message: "ok", ok: true } }); - return jsonResponse({ model: result }, { status: 201 }); - }); -} - -function setupProjectFetches(result: unknown) { - installFetchMock((call) => { - if (call.method === "DELETE") return new Response(null, { status: 204 }); - if (call.url.includes("archive")) return jsonResponse({ project: result }); - if (call.url.includes("restore")) return jsonResponse({ project: result }); - return jsonResponse({ project: result }, { status: 201 }); - }); -} - -function setupProviderFetches(result: unknown) { - installFetchMock((call) => { - if (call.method === "DELETE") return new Response(null, { status: 204 }); - if (call.url.includes("test")) return jsonResponse({ providerTestResponse: { message: "ok", ok: true } }); - return jsonResponse({ provider: result }, { status: 201 }); - }); -} - -function spyConsoleLog() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const spy = mock((..._args: any[]) => {}); - const orig = console.log; - console.log = spy; - return { orig, restore: () => (console.log = orig), spy }; -} - -describe("useProjects onSuccess 日志", () => { - const qc = makeQueryClient(); - - test("create onSuccess", async () => { - setupProjectFetches(PROJECT); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useCreateProject(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate({ name: "x" })); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - const msgs = getLogMessages(spy); - expect(msgs).toHaveLength(1); - expect(msgs[0]).toMatch(/项目创建成功/); - restore(); - }); - - test("update onSuccess", async () => { - setupProjectFetches(PROJECT); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useUpdateProject(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate({ data: { name: "y" }, id: "p1" })); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - const msgs = getLogMessages(spy); - expect(msgs).toHaveLength(1); - expect(msgs[0]).toMatch(/项目更新成功/); - restore(); - }); - - test("delete onSuccess", async () => { - setupProjectFetches(PROJECT); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useDeleteProject(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate("p1")); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - const msgs = getLogMessages(spy); - expect(msgs).toHaveLength(1); - expect(msgs[0]).toMatch(/项目删除成功/); - restore(); - }); - - test("archive onSuccess", async () => { - setupProjectFetches(PROJECT); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useArchiveProject(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate("p1")); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - const msgs = getLogMessages(spy); - expect(msgs).toHaveLength(1); - expect(msgs[0]).toMatch(/项目归档成功/); - restore(); - }); - - test("restore onSuccess", async () => { - setupProjectFetches(PROJECT); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useRestoreProject(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate("p1")); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - const msgs = getLogMessages(spy); - expect(msgs).toHaveLength(1); - expect(msgs[0]).toMatch(/项目恢复成功/); - restore(); - }); -}); - -describe("useModels onSuccess 日志", () => { - const qc = makeQueryClient(); - - test("create onSuccess", async () => { - setupModelFetches(MODEL); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useCreateModel(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate({ capabilities: ["text"], modelId: "gpt-4", name: "x", providerId: "p1" })); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - const msgs = getLogMessages(spy); - expect(msgs).toHaveLength(1); - expect(msgs[0]).toMatch(/模型创建成功/); - restore(); - }); - - test("update onSuccess", async () => { - setupModelFetches(MODEL); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useUpdateModel(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate({ data: { name: "y" }, id: "m1" })); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - const msgs = getLogMessages(spy); - expect(msgs).toHaveLength(1); - expect(msgs[0]).toMatch(/模型更新成功/); - restore(); - }); - - test("delete onSuccess", async () => { - setupModelFetches(MODEL); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useDeleteModel(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate("m1")); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - const msgs = getLogMessages(spy); - expect(msgs).toHaveLength(1); - expect(msgs[0]).toMatch(/模型删除成功/); - restore(); - }); - - test("test onSuccess", async () => { - setupModelFetches(MODEL); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useTestModelConnection(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate({ modelId: "gpt-4", providerId: "p1" })); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - restore(); - // useTestModelConnection has no onSuccess logger - const infoCalls = spy.mock.calls.filter((c) => typeof c[0] === "string" && c[0].includes("[Alfred:INFO]")); - expect(infoCalls.length).toBe(0); - }); -}); - -describe("useProviders onSuccess 日志", () => { - const qc = makeQueryClient(); - - test("create onSuccess", async () => { - setupProviderFetches(PROVIDER); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useCreateProvider(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate({ apiKey: "k", baseUrl: "http://x", name: "x", type: "openai" })); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - const msgs = getLogMessages(spy); - expect(msgs).toHaveLength(1); - expect(msgs[0]).toMatch(/供应商创建成功/); - restore(); - }); - - test("update onSuccess", async () => { - setupProviderFetches(PROVIDER); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useUpdateProvider(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate({ data: { name: "y" }, id: "prov-1" })); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - const msgs = getLogMessages(spy); - expect(msgs).toHaveLength(1); - expect(msgs[0]).toMatch(/供应商更新成功/); - restore(); - }); - - test("delete onSuccess", async () => { - setupProviderFetches(PROVIDER); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useDeleteProvider(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate("prov-1")); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - const msgs = getLogMessages(spy); - expect(msgs).toHaveLength(1); - expect(msgs[0]).toMatch(/供应商删除成功/); - restore(); - }); - - test("test onSuccess", async () => { - setupProviderFetches(PROVIDER); - const { restore, spy } = spyConsoleLog(); - - function T({ onResult }: { onResult: (fn: () => void) => void }) { - const { mutate } = useTestProviderConfig(); - const c = useRef(false); - if (!c.current) { - c.current = true; - onResult(() => mutate({ apiKey: "k", baseUrl: "http://x", name: "x", type: "openai" })); - } - return null; - } - render(createElement(QueryClientProvider, { client: qc }, createElement(T, { onResult: (fn) => fn() }))); - await new Promise((r) => setTimeout(r, 200)); - - restore(); - // useTestProviderConfig has no onSuccess logger - const infoMsgs = spy.mock.calls.filter((c) => typeof c[0] === "string" && String(c[0]).includes("[Alfred:INFO]")); - expect(infoMsgs.length).toBe(0); - }); -}); diff --git a/tests/web/routes/dashboard.test.tsx b/tests/web/routes/dashboard.test.tsx deleted file mode 100644 index e8a7be2..0000000 --- a/tests/web/routes/dashboard.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* eslint-disable @typescript-eslint/require-await */ -import { screen } from "@testing-library/react"; -import { describe, expect, test } from "bun:test"; -import { createElement } from "react"; - -import { DashboardPage } from "../../../src/web/features/dashboard"; -import { renderWithProviders } from "../test-utils"; - -describe("DashboardPage", () => { - test("渲染欢迎信息", () => { - window.fetch = (async () => { - return new Response( - JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }), - { - headers: { "Content-Type": "application/json" }, - status: 200, - }, - ); - }) as unknown as typeof fetch; - - renderWithProviders(createElement(DashboardPage)); - - expect(screen.getByText(/欢迎使用/)).not.toBeNull(); - }); -}); diff --git a/tests/web/utils/api.test.ts b/tests/web/utils/api.test.ts deleted file mode 100644 index 6f4fa77..0000000 --- a/tests/web/utils/api.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; - -import { handleResponse, handleVoidResponse } from "../../../src/web/shared/utils/api"; - -function expectRejects(action: () => Promise, message: string) { - return action().then( - () => { - throw new Error("expected rejection"); - }, - (error: unknown) => { - expect((error as Error).message).toBe(message); - }, - ); -} - -function non200Response(body: unknown, status: number): Response { - return new Response(JSON.stringify(body), { - headers: { "Content-Type": "application/json" }, - status, - }); -} - -function spyConsoleLog() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const spy = mock((..._args: any[]) => {}); - const orig = console.log; - console.log = spy; - return { orig, restore: () => (console.log = orig), spy }; -} - -function spyConsoleWarn() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const spy = mock((..._args: any[]) => {}); - const orig = console.warn; - console.warn = spy; - return { orig, restore: () => (console.warn = orig), spy }; -} - -describe("api.ts 日志行为", () => { - test("handleResponse 非 200 响应输出 warn 日志", async () => { - const { restore, spy } = spyConsoleWarn(); - - const response = non200Response({ error: "项目名称已存在" }, 409); - await expectRejects(() => handleResponse(response, (d) => d), "项目名称已存在"); - - restore(); - - expect(spy).toHaveBeenCalledTimes(1); - const msg = spy.mock.calls[0]![0] as string; - const data = spy.mock.calls[0]![1] as Record; - expect(msg).toMatch(/\[Alfred:WARN\] API request failed/); - expect(data).toBeObject(); - expect(data).toHaveProperty("duration"); - expect(data).toHaveProperty("errorBody", "项目名称已存在"); - expect(data).toHaveProperty("status", 409); - expect(data).toHaveProperty("url"); - }); - - test("handleVoidResponse 非 200 响应输出 warn 日志", async () => { - const { restore, spy } = spyConsoleWarn(); - - const response = non200Response({ error: "服务器错误" }, 500); - await expectRejects(() => handleVoidResponse(response), "服务器错误"); - - restore(); - - expect(spy).toHaveBeenCalledTimes(1); - const msg = spy.mock.calls[0]![0] as string; - const data = spy.mock.calls[0]![1] as Record; - expect(msg).toMatch(/\[Alfred:WARN\] API request failed/); - expect(data).toHaveProperty("duration"); - expect(data).toHaveProperty("errorBody", "服务器错误"); - expect(data).toHaveProperty("status", 500); - }); - - test("handleResponse 非 JSON 错误响应回退到 HTTP 状态", async () => { - const { restore, spy } = spyConsoleWarn(); - - const response = new Response("broken", { status: 503 }); - await expectRejects(() => handleResponse(response, (d) => d), "HTTP 503"); - - restore(); - - expect(spy).toHaveBeenCalledTimes(1); - const data = spy.mock.calls[0]![1] as Record; - expect(data).toHaveProperty("errorBody", "HTTP 503"); - expect(data).toHaveProperty("status", 503); - }); - - test("handleResponse 成功响应在 DEV 模式输出 debug 日志", async () => { - const { restore, spy } = spyConsoleLog(); - - (import.meta.env as Record)["DEV"] = true; - - const response = new Response(JSON.stringify({ ok: true }), { - headers: { "Content-Type": "application/json" }, - status: 200, - }); - await handleResponse(response, (d) => d); - - (import.meta.env as Record)["DEV"] = undefined; - restore(); - - expect(spy).toHaveBeenCalledTimes(1); - const msg = spy.mock.calls[0]![0] as string; - const data = spy.mock.calls[0]![1] as Record; - expect(msg).toMatch(/\[Alfred:DEBUG\] API request/); - expect(data).toBeObject(); - expect(data).toHaveProperty("duration"); - expect(data).toHaveProperty("status", 200); - expect(data).toHaveProperty("url"); - }); - - test("handleVoidResponse 成功响应在 DEV 模式输出 debug 日志", async () => { - const { restore, spy } = spyConsoleLog(); - - (import.meta.env as Record)["DEV"] = true; - - const response = new Response(null, { status: 204 }); - await handleVoidResponse(response); - - (import.meta.env as Record)["DEV"] = undefined; - restore(); - - expect(spy).toHaveBeenCalledTimes(1); - const msg = spy.mock.calls[0]![0] as string; - const data = spy.mock.calls[0]![1] as Record; - expect(msg).toMatch(/\[Alfred:DEBUG\] API request/); - expect(data).toHaveProperty("duration"); - expect(data).toHaveProperty("status", 204); - expect(data).toHaveProperty("url"); - }); -}); diff --git a/tests/web/utils/time.test.ts b/tests/web/utils/time.test.ts index b11783b..1cd66f8 100644 --- a/tests/web/utils/time.test.ts +++ b/tests/web/utils/time.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { formatCountdown, + formatDateLabel, formatDurationUnit, formatRelativeTime, isOlderThan, @@ -77,3 +78,32 @@ describe("isOlderThan", () => { expect(isOlderThan("2025-01-01T00:01:30.000Z", 60000, now)).toBe(false); }); }); + +describe("formatDateLabel", () => { + const now = new Date("2026-06-03T12:00:00.000Z"); + + test("今天", () => { + expect(formatDateLabel("2026-06-03", now)).toBe("今天"); + expect(formatDateLabel("2026-06-03T08:00:00.000Z", now)).toBe("今天"); + }); + + test("昨天", () => { + expect(formatDateLabel("2026-06-02", now)).toBe("昨天"); + expect(formatDateLabel("2026-06-02T23:59:59.000Z", now)).toBe("昨天"); + }); + + test("其他日期返回 YYYY-MM-DD", () => { + expect(formatDateLabel("2026-05-30", now)).toBe("2026-05-30"); + expect(formatDateLabel("2025-01-15", now)).toBe("2025-01-15"); + }); + + test("无效输入返回占位符", () => { + expect(formatDateLabel("", now)).toBe("—"); + expect(formatDateLabel("not-a-date", now)).toBe("—"); + }); + + test("不传 now 参数使用当前日期", () => { + const result = formatDateLabel(new Date().toISOString().slice(0, 10)); + expect(result).toBe("今天"); + }); +});