diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 57c57d9..14ed79f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -71,9 +71,10 @@ src/ target-table-filters.ts 表格筛选器 target-table-sorters.ts 表格排序器 color-threshold.ts 可用率颜色阈值函数 - hooks/ TanStack Query 数据层 + hooks/ React hooks(数据查询、Drawer 状态、浏览器 UI 偏好) use-queries.ts 全局面板查询 hook(dashboard/meta/metrics) use-target-detail.ts 目标详情 Drawer 状态与条件查询 hook + use-theme-preference.ts 主题模式偏好、本地存储和 TDesign theme-mode 应用 hook utils/ 前端工具函数 time.ts 时间处理(subtractHours、相对时间、动态时长单位) scripts/ 构建、schema 生成和清理脚本 @@ -554,6 +555,7 @@ main.tsx └── ErrorBoundary(React 错误边界) └── QueryClientProvider(TanStack Query 全局挂载) ├── App(根组件,Layout + HeadMenu 骨架) + │ ├── useThemePreference() ─── Header 主题模式 RadioGroup(系统/明亮/黑暗,本地存储记忆 + theme-mode 应用) │ ├── useDashboard(refreshInterval) ─── GET /api/dashboard?window=24h&recentLimit=30(动态刷新间隔,RadioGroup 频率选择 + 倒计时/手动刷新按钮) │ ├── SummaryCards(单 Card 内嵌居中 Statistic,无 shadow) │ └── TargetBoard(目标列表,Space 24px 间距) @@ -569,7 +571,7 @@ main.tsx └── ReactQueryDevtools(开发工具,仅开发环境) ``` -**数据层架构**: +**Hook 架构**: ``` hooks/use-queries.ts(全局面板级查询) @@ -583,6 +585,12 @@ hooks/use-target-detail.ts(Drawer 状态与详情级条件查询) ├── activeTab 受控 Tabs 状态(每次 openDrawer 重置为 overview) ├── useTargetMetrics(/api/targets/:id/metrics)(条件查询:enabled 仅当 Drawer 打开且时间范围有效) └── useQuery(/api/targets/:id/history)(条件查询:enabled 仅当 Drawer 打开 + 时间范围有效 + activeTab=history) + +hooks/use-theme-preference.ts(浏览器 UI 偏好) +├── ThemePreference: system / light / dark(RadioGroup 受控值) +├── EffectiveTheme: light / dark(写入 document.documentElement theme-mode) +├── localStorage key: dial.theme.preference(同一浏览器记忆) +└── matchMedia("(prefers-color-scheme: dark)")(系统模式下跟随系统明暗变化) ``` ### 2.3 TanStack Query 数据层 @@ -683,7 +691,7 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) | 组件 | 文件 | 用途 | | -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `App` | `app.tsx` | 根组件,Layout + HeadMenu 骨架、刷新倒计时、Skeleton 加载 | +| `App` | `app.tsx` | 根组件,Layout + HeadMenu 骨架、主题模式选择、刷新倒计时、Skeleton 加载 | | `ErrorBoundary` | `components/ErrorBoundary.tsx` | React 错误边界,捕获渲染异常并展示降级 UI | | `SummaryCards` | `components/SummaryCards.tsx` | 总览统计卡片(单 Card 内嵌居中 Statistic,无 shadow) | | `TargetBoard` | `components/TargetBoard.tsx` | 按分组渲染目标表格列表(Space 24px 间距) | @@ -728,7 +736,7 @@ export function TargetGroup({ name, targets, onTargetClick }: TargetGroupProps) **styles.css 组织**: - 自定义 CSS 变量(如可用率渐变色 `--avail-0` ~ `--avail-9`)定义在 `:root` 中 -- 布局类(`.dashboard`、`.dashboard-header`)定义全局页面结构 +- 布局类(`.dashboard`、`.dashboard-header-controls`)定义全局页面结构和 Header 右侧单行操作区 - 组件修饰类(`.status-dot--up`、`.latency-ok`)为自定义视觉组件提供样式变体 - TDesign 表格行高亮(`.row-down`)通过 `rowClassName` prop 应用 diff --git a/README.md b/README.md index b063885..e354164 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DiAL -基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite,前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等,并支持手动、10 秒、30 秒、1 分钟、5 分钟刷新频率切换。 +基于 Bun + TypeScript 的多类型拨测监控工具。通过 YAML 配置文件定义 HTTP 和命令行拨测目标,后端按配置定时并发拨测,结果持久化到本地 SQLite,前端 Dashboard 展示各目标实时状态、可用率、耗时趋势等,并支持手动、10 秒、30 秒、1 分钟、5 分钟刷新频率切换,以及系统、明亮、黑暗三种主题模式。主题模式选择会保存在当前浏览器本地存储中,同一浏览器再次访问时自动恢复。 ## 快速开始 diff --git a/openspec/specs/css-utility-classes/spec.md b/openspec/specs/css-utility-classes/spec.md index dda2a11..e4a408a 100644 --- a/openspec/specs/css-utility-classes/spec.md +++ b/openspec/specs/css-utility-classes/spec.md @@ -73,9 +73,13 @@ styles.css SHALL 定义前端组件复用的工具类,包含页面布局相关 - **WHEN** HeadMenu logo 区域渲染品牌名和副标题 - **THEN** 品牌 SHALL 使用 `.dashboard-brand` 类(display: inline-flex; align-items: baseline; gap: var(--td-comp-margin-s)),品牌名 SHALL 使用 `.dashboard-logo` 类(font-size: calc(var(--td-font-size-title-large) + 6px); font-weight: 700),副标题 SHALL 使用 `.dashboard-subtitle` 类(font-size: var(--td-font-size-body-medium); color: var(--td-text-color-secondary)) -#### Scenario: 刷新控制区域类 -- **WHEN** HeadMenu operations 区域渲染刷新频率选择器和倒计时/按钮 -- **THEN** 容器 SHALL 使用 `.dashboard-refresh-control` 类(display: inline-flex; align-items: center; gap: var(--td-comp-margin-s); margin-right: var(--td-comp-margin-xxl)) +#### Scenario: Header 右侧操作区类 +- **WHEN** HeadMenu operations 区域渲染主题模式选择器、刷新频率选择器和倒计时/按钮 +- **THEN** 容器 SHALL 使用 `.dashboard-header-controls` 类(display: inline-flex; align-items: center; gap: var(--td-comp-margin-s); margin-right: var(--td-comp-margin-xxl)) + +#### Scenario: Header 右侧操作区单行布局 +- **WHEN** Header 右侧操作区渲染 +- **THEN** `.dashboard-header-controls` SHALL 保持桌面单行水平布局,不为该区域新增窄屏换行或收纳规则 #### Scenario: 倒计时文本类 - **WHEN** 倒计时文本或刷新按钮渲染 diff --git a/openspec/specs/dashboard-layout/spec.md b/openspec/specs/dashboard-layout/spec.md index 080c081..929fa6d 100644 --- a/openspec/specs/dashboard-layout/spec.md +++ b/openspec/specs/dashboard-layout/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义 Dashboard 页面骨架布局:顶部导航栏(含品牌标识和刷新频率选择器/倒计时控件)、内容区域居中与最大宽度、页面背景色。 +定义 Dashboard 页面骨架布局:顶部导航栏(含品牌标识、主题模式选择器、刷新频率选择器和倒计时控件)、内容区域居中与最大宽度、页面背景色。 ## Requirements @@ -13,13 +13,17 @@ Dashboard SHALL 使用 TDesign Layout 组件体系构建页面骨架,包含顶 #### Scenario: 顶部导航栏 - **WHEN** Dashboard 页面渲染 -- **THEN** `Layout.Header` SHALL 内嵌 TDesign `HeadMenu` 组件,`logo` prop 渲染品牌名 "DiAL" 和副标题 "统一拨测平台"(水平排列),`operations` prop 渲染刷新频率选择器和倒计时/刷新按钮组合控件 +- **THEN** `Layout.Header` SHALL 内嵌 TDesign `HeadMenu` 组件,`logo` prop 渲染品牌名 "DiAL" 和副标题 "统一拨测平台"(水平排列),`operations` prop 渲染主题模式选择器、刷新频率选择器和倒计时/刷新按钮组合控件 -#### Scenario: 刷新控制区域 +#### Scenario: Header 右侧操作区 - **WHEN** Dashboard 页面渲染 -- **THEN** HeadMenu operations 区域 SHALL 包含 RadioGroup 刷新频率选择器和倒计时文本(或手动刷新按钮),两者水平排列并垂直居中 +- **THEN** HeadMenu operations 区域 SHALL 包含主题模式 RadioGroup、刷新频率 RadioGroup 和倒计时文本(或手动刷新按钮),三者水平排列并垂直居中 -#### Scenario: 刷新控制区域位置 +#### Scenario: 主题选择器位置 +- **WHEN** HeadMenu operations 区域渲染 +- **THEN** 主题模式 RadioGroup SHALL 位于刷新频率 RadioGroup 前面 + +#### Scenario: Header 右侧操作区位置 - **WHEN** HeadMenu 渲染 - **THEN** operations 区域 SHALL 使用右侧 margin 向内收缩,避免紧贴浏览器右边缘 @@ -29,4 +33,4 @@ Dashboard SHALL 使用 TDesign Layout 组件体系构建页面骨架,包含顶 #### Scenario: 页面背景色 - **WHEN** Dashboard 页面渲染 -- **THEN** 页面背景色 SHALL 使用 `var(--td-bg-color-page)`,内容卡片浮于浅灰背景之上 +- **THEN** 页面背景色 SHALL 使用 `var(--td-bg-color-page)`,内容卡片浮于当前 TDesign 主题背景之上 diff --git a/openspec/specs/theme-mode-preference/spec.md b/openspec/specs/theme-mode-preference/spec.md new file mode 100644 index 0000000..e304212 --- /dev/null +++ b/openspec/specs/theme-mode-preference/spec.md @@ -0,0 +1,73 @@ +## Purpose + +定义 Dashboard 主题模式选择、系统主题跟随、浏览器本地持久化和 TDesign 主题变量应用行为。 + +## Requirements + +### Requirement: 主题模式选择器 +Dashboard SHALL 在 Header 右侧提供主题模式 RadioGroup,允许用户选择"系统""明亮""黑暗"三种模式。 + +#### Scenario: 主题模式选项渲染 +- **WHEN** Dashboard 页面渲染 +- **THEN** HeadMenu operations 区域 SHALL 在刷新频率选择器前显示 RadioGroup(theme="button", variant="default-filled"),选项为:系统、明亮、黑暗 + +#### Scenario: 默认选择系统 +- **WHEN** 当前浏览器没有已保存的有效主题偏好 +- **THEN** 主题模式 RadioGroup SHALL 默认选中"系统" + +#### Scenario: 用户切换主题模式 +- **WHEN** 用户点击"系统""明亮"或"黑暗"任一主题模式选项 +- **THEN** RadioGroup SHALL 选中该选项,并触发对应主题模式生效 + +### Requirement: 主题模式生效 +系统 SHALL 根据用户主题偏好计算有效主题,并通过 `` 元素的 `theme-mode` 属性应用 TDesign 主题变量。 + +#### Scenario: 系统模式跟随暗色系统 +- **WHEN** 用户主题偏好为"系统"且 `prefers-color-scheme: dark` 匹配 +- **THEN** 系统 SHALL 设置 `document.documentElement` 的 `theme-mode` 属性为 `dark` + +#### Scenario: 系统模式跟随亮色系统 +- **WHEN** 用户主题偏好为"系统"且 `prefers-color-scheme: dark` 不匹配 +- **THEN** 系统 SHALL 设置 `document.documentElement` 的 `theme-mode` 属性为 `light` + +#### Scenario: 系统主题变化自动更新 +- **WHEN** 用户主题偏好为"系统"且浏览器系统主题在明亮和黑暗之间变化 +- **THEN** 系统 SHALL 自动更新 `theme-mode` 属性为新的有效主题 + +#### Scenario: 明亮模式固定主题 +- **WHEN** 用户主题偏好为"明亮" +- **THEN** 系统 SHALL 设置 `theme-mode` 属性为 `light`,且系统主题变化 SHALL NOT 改变该属性 + +#### Scenario: 黑暗模式固定主题 +- **WHEN** 用户主题偏好为"黑暗" +- **THEN** 系统 SHALL 设置 `theme-mode` 属性为 `dark`,且系统主题变化 SHALL NOT 改变该属性 + +### Requirement: 主题偏好本地持久化 +系统 SHALL 将用户选择的主题偏好保存到当前浏览器本地存储,并在后续页面加载时恢复。 + +#### Scenario: 保存用户选择 +- **WHEN** 用户切换主题模式 +- **THEN** 系统 SHALL 将对应偏好值写入 `localStorage` 的 `dial.theme.preference` 键 + +#### Scenario: 恢复已保存偏好 +- **WHEN** 页面加载且 `localStorage` 的 `dial.theme.preference` 键保存了有效偏好值 +- **THEN** 系统 SHALL 使用该偏好初始化主题模式 RadioGroup 和有效主题 + +#### Scenario: 非法本地偏好回退 +- **WHEN** 页面加载且 `dial.theme.preference` 保存了非 `system`、`light`、`dark` 的值 +- **THEN** 系统 SHALL 忽略该值并按"系统"模式初始化 + +#### Scenario: 本地存储不可用 +- **WHEN** 浏览器读取或写入 `localStorage` 抛出异常 +- **THEN** Dashboard SHALL 继续正常渲染,并按内存中的主题偏好应用主题 + +### Requirement: 启动期主题恢复 +系统 SHALL 在 React App 首次渲染前尽早应用一次有效主题,降低暗色环境下的亮色闪烁。 + +#### Scenario: 渲染前应用已保存偏好 +- **WHEN** 前端入口初始化且浏览器已保存有效主题偏好 +- **THEN** 系统 SHALL 在创建 React root 前根据该偏好设置 `` 的 `theme-mode` 属性 + +#### Scenario: 渲染前应用系统偏好 +- **WHEN** 前端入口初始化且浏览器没有有效主题偏好 +- **THEN** 系统 SHALL 在创建 React root 前根据 `prefers-color-scheme: dark` 设置 `` 的 `theme-mode` 属性 diff --git a/scripts/build.ts b/scripts/build.ts index 246f9b4..ee39a88 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,5 +1,5 @@ import { readdir, rm, writeFile } from "node:fs/promises"; -import { join, relative } from "node:path"; +import { join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; const projectRoot = fileURLToPath(new URL("..", import.meta.url)); @@ -68,7 +68,7 @@ async function codeGeneration() { for (let i = 0; i < allFiles.length; i++) { const urlPath = allFiles[i]!; const varName = `f${i}`; - const filePath = relative(buildDir, join(distWebDir, urlPath.slice(1))); + const filePath = toImportSpecifier(buildDir, join(distWebDir, urlPath.slice(1))); importLines.push(`import ${varName} from "./${filePath}" with { type: "file" };`); if (urlPath === "/index.html") { @@ -134,6 +134,10 @@ async function scanDir(dir: string, prefix: string): Promise { return paths; } +function toImportSpecifier(fromDir: string, targetPath: string) { + return relative(fromDir, targetPath).split(sep).join("/"); +} + async function viteBuild() { console.log("Step 1/3: Vite build..."); const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], { diff --git a/src/web/app.tsx b/src/web/app.tsx index f5c64bc..c7dd4fa 100644 --- a/src/web/app.tsx +++ b/src/web/app.tsx @@ -9,6 +9,7 @@ import { TargetBoard } from "./components/TargetBoard"; import { TargetDetailDrawer } from "./components/TargetDetailDrawer"; import { useDashboard } from "./hooks/use-queries"; import { useTargetDetail } from "./hooks/use-target-detail"; +import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference"; const { Content, Header } = Layout; const DEFAULT_REFRESH_INTERVAL_MS = 30000; @@ -24,9 +25,15 @@ const REFRESH_OPTIONS = [ { label: "1分钟", value: 60000 }, { label: "5分钟", value: 300000 }, ] as const; +const THEME_OPTIONS = [ + { label: "系统", value: "system" }, + { label: "明亮", value: "light" }, + { label: "黑暗", value: "dark" }, +] as const; export function App() { const [refreshInterval, setRefreshInterval] = useState(DEFAULT_REFRESH_INTERVAL_MS); + const { preference: themePreference, setPreference: setThemePreference } = useThemePreference(); const dashboardRefetchInterval = refreshInterval === 0 ? false : refreshInterval; const { data: dashboard, @@ -58,6 +65,10 @@ export function App() { setRefreshInterval(value); }; + const handleThemeChange = (value: ThemePreference) => { + setThemePreference(value); + }; + return (
@@ -69,7 +80,14 @@ export function App() { } operations={ -
+
+ ({ label: option.label, value: option.value }))} + theme="button" + value={themePreference} + variant="default-filled" + /> ({ label: option.label, value: option.value }))} diff --git a/src/web/hooks/use-theme-preference.ts b/src/web/hooks/use-theme-preference.ts new file mode 100644 index 0000000..2e85be0 --- /dev/null +++ b/src/web/hooks/use-theme-preference.ts @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; + +export type EffectiveTheme = "dark" | "light"; +export type ThemePreference = "dark" | "light" | "system"; + +export const THEME_PREFERENCE_STORAGE_KEY = "dial.theme.preference"; +export const THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)"; + +export function applyInitialThemePreference() { + applyThemeMode(resolveEffectiveTheme(readThemePreference(), getSystemPrefersDark())); +} + +export function applyThemeMode(theme: EffectiveTheme, root: HTMLElement = document.documentElement) { + root.setAttribute("theme-mode", theme); +} + +export function getSystemPrefersDark(matchMedia: Window["matchMedia"] = window.matchMedia): boolean { + try { + return matchMedia(THEME_MEDIA_QUERY).matches; + } catch { + return false; + } +} + +export function parseThemePreference(value: unknown): ThemePreference { + return value === "dark" || value === "light" || value === "system" ? value : "system"; +} + +export function readThemePreference(storage: Storage = window.localStorage): ThemePreference { + try { + return parseThemePreference(storage.getItem(THEME_PREFERENCE_STORAGE_KEY)); + } catch { + return "system"; + } +} + +export function resolveEffectiveTheme(preference: ThemePreference, systemPrefersDark: boolean): EffectiveTheme { + if (preference === "dark" || preference === "light") return preference; + return systemPrefersDark ? "dark" : "light"; +} + +export function useThemePreference() { + const [preference, setPreferenceState] = useState(() => readThemePreference()); + const [systemPrefersDark, setSystemPrefersDark] = useState(() => getSystemPrefersDark()); + const effectiveTheme = resolveEffectiveTheme(preference, systemPrefersDark); + + useEffect(() => { + applyThemeMode(effectiveTheme); + }, [effectiveTheme]); + + useEffect(() => { + const mediaQueryList = window.matchMedia(THEME_MEDIA_QUERY); + + const handleChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches); + mediaQueryList.addEventListener("change", handleChange); + return () => mediaQueryList.removeEventListener("change", handleChange); + }, []); + + const setPreference = (nextPreference: ThemePreference) => { + setPreferenceState(nextPreference); + writeThemePreference(nextPreference); + }; + + return { effectiveTheme, preference, setPreference }; +} + +export function writeThemePreference(preference: ThemePreference, storage: Storage = window.localStorage) { + try { + storage.setItem(THEME_PREFERENCE_STORAGE_KEY, preference); + } catch { + // 存储不可用时仅使用当前内存状态,避免阻断 Dashboard 渲染。 + } +} diff --git a/src/web/main.tsx b/src/web/main.tsx index 3aec7c4..1baa0bd 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client"; import { App } from "./app"; import { ErrorBoundary } from "./components/ErrorBoundary"; +import { applyInitialThemePreference } from "./hooks/use-theme-preference"; import "tdesign-react/dist/reset.css"; import "tdesign-react/dist/tdesign.min.css"; @@ -26,6 +27,8 @@ if (!rootElement) { throw new Error("找不到前端挂载节点 #root"); } +applyInitialThemePreference(); + createRoot(rootElement).render( diff --git a/src/web/styles.css b/src/web/styles.css index 146a298..b77a704 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -46,7 +46,7 @@ font-weight: 400; } -.dashboard-refresh-control { +.dashboard-header-controls { display: inline-flex; align-items: center; gap: var(--td-comp-margin-s); diff --git a/tests/server/checker/config-loader.test.ts b/tests/server/checker/config-loader.test.ts index c746104..52e0203 100644 --- a/tests/server/checker/config-loader.test.ts +++ b/tests/server/checker/config-loader.test.ts @@ -156,7 +156,7 @@ describe("loadConfig", () => { expect(t.cmd.args).toEqual(["nginx"]); expect(t.cmd.cwd).toBe(subdir); expect(t.cmd.maxOutputBytes).toBe(104857600); - expect(t.cmd.env["PATH"]).toBeDefined(); + expect(Object.keys(t.cmd.env).some((key) => key.toUpperCase() === "PATH")).toBe(true); }); test("解析完整配置", async () => { @@ -234,7 +234,7 @@ targets: await writeFile( configPath, `server: - dataDir: "${dataDir}" + dataDir: ${JSON.stringify(dataDir)} targets: - name: "test" type: http @@ -609,7 +609,7 @@ targets: const t = config.targets[0] as ResolvedCommandTarget; expect(t.cmd.env["LANG"]).toBe("C"); expect(t.cmd.env["CUSTOM_VAR"]).toBe("test"); - expect(t.cmd.env["PATH"]).toBeDefined(); + expect(Object.keys(t.cmd.env).some((key) => key.toUpperCase() === "PATH")).toBe(true); }); test("解析 group 字段", async () => { diff --git a/tests/web/components/App.test.tsx b/tests/web/components/App.test.tsx index 2a132ec..bbc6f03 100644 --- a/tests/web/components/App.test.tsx +++ b/tests/web/components/App.test.tsx @@ -3,14 +3,14 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import "../../../tests/web/test-utils"; -import { render } from "@testing-library/react"; -import { describe, expect, test, vi } from "bun:test"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; import { App } from "../../../src/web/app"; +import { THEME_MEDIA_QUERY, THEME_PREFERENCE_STORAGE_KEY } from "../../../src/web/hooks/use-theme-preference"; -// Mock hooks -void vi.mock("../../../src/web/hooks/use-queries", () => ({ - useDashboard: vi.fn(() => ({ +function createDashboardResult(overrides = {}) { + return { data: { summary: { down: 0, @@ -31,7 +31,43 @@ void vi.mock("../../../src/web/hooks/use-queries", () => ({ isFetching: false, isLoading: false, refetch: vi.fn(), - })), + ...overrides, + }; +} + +function installMatchMedia(initialMatches: boolean) { + const originalMatchMedia = window.matchMedia; + let matches = initialMatches; + const listeners = new Set<(event: MediaQueryListEvent) => void>(); + const mediaQueryList = { + addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener), + addListener: (listener: (event: MediaQueryListEvent) => void) => listeners.add(listener), + dispatchEvent: () => true, + get matches() { + return matches; + }, + media: THEME_MEDIA_QUERY, + onchange: null, + removeEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener), + removeListener: (listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener), + } as MediaQueryList; + + window.matchMedia = () => mediaQueryList; + + return { + restore: () => { + window.matchMedia = originalMatchMedia; + }, + setMatches: (nextMatches: boolean) => { + matches = nextMatches; + listeners.forEach((listener) => listener({ matches, media: THEME_MEDIA_QUERY } as MediaQueryListEvent)); + }, + }; +} + +// Mock hooks +void vi.mock("../../../src/web/hooks/use-queries", () => ({ + useDashboard: vi.fn(() => createDashboardResult()), useMeta: vi.fn(() => ({ data: { checkerTypes: ["http", "cmd"] }, })), @@ -61,6 +97,20 @@ void vi.mock("../../../src/web/hooks/use-target-detail", () => ({ })); describe("App", () => { + let matchMediaController: ReturnType; + + beforeEach(() => { + const { useDashboard } = require("../../../src/web/hooks/use-queries"); + useDashboard.mockReturnValue(createDashboardResult()); + window.localStorage.clear(); + document.documentElement.removeAttribute("theme-mode"); + matchMediaController = installMatchMedia(false); + }); + + afterEach(() => { + matchMediaController?.restore(); + }); + test("渲染不崩溃", () => { const { container } = render(); expect(container.firstChild).not.toBeNull(); @@ -68,14 +118,16 @@ describe("App", () => { test("loading 状态不崩溃", () => { const { useDashboard } = require("../../../src/web/hooks/use-queries"); - useDashboard.mockReturnValue({ - data: null, - dataUpdatedAt: 0, - error: null, - isFetching: true, - isLoading: true, - refetch: vi.fn(), - }); + useDashboard.mockReturnValue( + createDashboardResult({ + data: null, + dataUpdatedAt: 0, + error: null, + isFetching: true, + isLoading: true, + refetch: vi.fn(), + }), + ); const { container } = render(); expect(container.firstChild).not.toBeNull(); @@ -83,14 +135,16 @@ describe("App", () => { test("错误状态不崩溃", () => { const { useDashboard } = require("../../../src/web/hooks/use-queries"); - useDashboard.mockReturnValue({ - data: null, - dataUpdatedAt: 0, - error: { message: "Network error" }, - isFetching: false, - isLoading: false, - refetch: vi.fn(), - }); + useDashboard.mockReturnValue( + createDashboardResult({ + data: null, + dataUpdatedAt: 0, + error: { message: "Network error" }, + isFetching: false, + isLoading: false, + refetch: vi.fn(), + }), + ); const { container } = render(); expect(container.firstChild).not.toBeNull(); @@ -98,30 +152,60 @@ describe("App", () => { test("有数据状态不崩溃", () => { const { useDashboard } = require("../../../src/web/hooks/use-queries"); - useDashboard.mockReturnValue({ - data: { - summary: { - down: 1, - incidents: 0, - lastCheckTime: "2025-01-15T10:00:00.000Z", - total: 2, - up: 1, - window: { - from: "2025-01-14T10:00:00.000Z", - label: "24h", - to: "2025-01-15T10:00:00.000Z", + useDashboard.mockReturnValue( + createDashboardResult({ + data: { + summary: { + down: 1, + incidents: 0, + lastCheckTime: "2025-01-15T10:00:00.000Z", + total: 2, + up: 1, + window: { + from: "2025-01-14T10:00:00.000Z", + label: "24h", + to: "2025-01-15T10:00:00.000Z", + }, }, + targets: [], }, - targets: [], - }, - dataUpdatedAt: Date.now(), - error: null, - isFetching: false, - isLoading: false, - refetch: vi.fn(), - }); + dataUpdatedAt: Date.now(), + error: null, + isFetching: false, + isLoading: false, + refetch: vi.fn(), + }), + ); const { container } = render(); expect(container.firstChild).not.toBeNull(); }); + + test("默认渲染主题模式选项并按系统亮色应用主题", async () => { + render(); + expect(screen.getByText("系统")).not.toBeNull(); + expect(screen.getByText("明亮")).not.toBeNull(); + expect(screen.getByText("黑暗")).not.toBeNull(); + await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("light")); + }); + + test("切换黑暗模式后写入本地存储并应用主题", async () => { + render(); + fireEvent.click(screen.getByText("黑暗")); + expect(window.localStorage.getItem(THEME_PREFERENCE_STORAGE_KEY)).toBe("dark"); + await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("dark")); + }); + + test("刷新后恢复已保存的主题偏好", async () => { + window.localStorage.setItem(THEME_PREFERENCE_STORAGE_KEY, "dark"); + render(); + await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("dark")); + }); + + test("系统模式响应 matchMedia 变化", async () => { + render(); + await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("light")); + act(() => matchMediaController.setMatches(true)); + await waitFor(() => expect(document.documentElement.getAttribute("theme-mode")).toBe("dark")); + }); }); diff --git a/tests/web/hooks/use-theme-preference.test.ts b/tests/web/hooks/use-theme-preference.test.ts new file mode 100644 index 0000000..64c0195 --- /dev/null +++ b/tests/web/hooks/use-theme-preference.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; + +import { + applyThemeMode, + parseThemePreference, + readThemePreference, + resolveEffectiveTheme, + THEME_PREFERENCE_STORAGE_KEY, + writeThemePreference, +} from "../../../src/web/hooks/use-theme-preference"; + +function createMemoryStorage(initialValue?: string): Storage { + const data = new Map(); + if (initialValue !== undefined) data.set(THEME_PREFERENCE_STORAGE_KEY, initialValue); + + return { + clear: () => data.clear(), + getItem: (key: string) => data.get(key) ?? null, + key: (index: number) => Array.from(data.keys())[index] ?? null, + get length() { + return data.size; + }, + removeItem: (key: string) => void data.delete(key), + setItem: (key: string, value: string) => void data.set(key, value), + }; +} + +function createThrowingStorage(): Storage { + return { + clear: () => { + throw new Error("storage unavailable"); + }, + getItem: () => { + throw new Error("storage unavailable"); + }, + key: () => { + throw new Error("storage unavailable"); + }, + get length(): number { + throw new Error("storage unavailable"); + }, + removeItem: () => { + throw new Error("storage unavailable"); + }, + setItem: () => { + throw new Error("storage unavailable"); + }, + }; +} + +describe("use-theme-preference 工具函数", () => { + test("解析有效主题偏好并对非法值回退为系统", () => { + expect(parseThemePreference("system")).toBe("system"); + expect(parseThemePreference("light")).toBe("light"); + expect(parseThemePreference("dark")).toBe("dark"); + expect(parseThemePreference("unknown")).toBe("system"); + expect(parseThemePreference(null)).toBe("system"); + }); + + test("根据系统模式计算有效主题", () => { + expect(resolveEffectiveTheme("system", true)).toBe("dark"); + expect(resolveEffectiveTheme("system", false)).toBe("light"); + expect(resolveEffectiveTheme("light", true)).toBe("light"); + expect(resolveEffectiveTheme("dark", false)).toBe("dark"); + }); + + test("读取本地存储偏好并在非法值时回退", () => { + expect(readThemePreference(createMemoryStorage("dark"))).toBe("dark"); + expect(readThemePreference(createMemoryStorage("bad-value"))).toBe("system"); + }); + + test("本地存储不可用时读取和写入均不抛错", () => { + const storage = createThrowingStorage(); + expect(readThemePreference(storage)).toBe("system"); + expect(() => writeThemePreference("dark", storage)).not.toThrow(); + }); + + test("应用有效主题到指定根元素", () => { + const root = document.createElement("html"); + applyThemeMode("dark", root); + expect(root.getAttribute("theme-mode")).toBe("dark"); + }); +}); diff --git a/tests/web/test-utils.tsx b/tests/web/test-utils.tsx index 9bbd654..2baa56f 100644 --- a/tests/web/test-utils.tsx +++ b/tests/web/test-utils.tsx @@ -41,9 +41,8 @@ export const testHelpers = { }; }, toHaveTextContent: (element: Element | null, text: RegExp | string) => { - const pass = - element?.textContent !== null && - (typeof text === "string" ? element.textContent.includes(text) : text.test(element.textContent)); + const content = element?.textContent ?? ""; + const pass = element !== null && (typeof text === "string" ? content.includes(text) : text.test(content)); return { message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`), pass,