yggdrasil/libs/yggdrasil-core/src/theme-transition.test.ts
xfy bbb77954e9 fix(theme): sync CodeMirror/xterm theme in VT callback for circular-expand animation
主题切换的圆形展开 VT 动画对可运行代码块不生效:圆形扫过 CodeMirror/xterm
区域时看不到颜色变化,动画结束后才瞬切。

根因(逐帧像素验证确认):VT 回调只 toggle .dark class,但 CodeMirror 背景
由 catppuccin Extension 注入(.cm-editor 的 EditorView.theme 规则),xterm
背景由 .xterm-scrollable-element 的 inline background-color 注入——两者都
不随 .dark 翻转。set_theme 由 Dioxus use_effect 在 theme.set() 之后异步触发,
晚于 NEW 快照捕获,导致 OLD/NEW 快照在编辑器区域同色,圆形展开无可揭示的变化。

修复:在 VT 回调内(NEW 快照捕获前)同步 dispatch 'yggdrasil:theme-change'
CustomEvent,CodeMirror/xterm 各自的 index.ts 在模块加载时订阅并遍历
_instances 同步调 setTheme。事件先于 applyDarkClass dispatch,确保编辑器换肤
+ class 翻转被同一个 getComputedStyle reflow 捕获进 NEW 快照。

与现有 Dioxus use_effect 幂等共存:setTheme 对相同主题是 no-op(CodeMirror
Compartment.reconfigure + xterm options.theme = 均幂等),use_effect 作兜底
覆盖初始挂载 / 非 VT 场景。

验证:
- yggdrasil-core 测试扩展:断言 VT 回调 / 降级路径 / applyResolvedTheme 都
  dispatch 事件,且事件先于 dark class 翻转
- scripts/vt-theme-sampler.mjs(Playwright 逐帧像素采样):修复前 xterm 比
  等距的 cssvar 探针晚 100ms 变色(bug);修复后同帧变色(lag=0ms)

改动文件:
- libs/yggdrasil-core/src/theme-transition.ts: THEME_CHANGE_EVENT 常量 +
  notifyThemeChange 辅助函数,VT 回调 / 降级路径 / applyResolvedTheme 三处 dispatch
- libs/codemirror-editor/src/index.ts: 订阅事件,forEach _instances.setTheme
- libs/xterm-terminal/src/index.ts: 同样模式
- libs/yggdrasil-core/src/theme-transition.test.ts: 3 个新用例
- public/xterm/terminal.js: 重建产物(含事件订阅)
- scripts/vt-theme-{harness,sampler}: 逐帧像素验证工具(throwaway,可作回归)
2026-07-13 11:20:08 +08:00

202 lines
7.6 KiB
TypeScript

/**
* theme-transition 测试。
*
* happy-dom 不提供 document.startViewTransition,天然覆盖降级路径(瞬切 dark class)。
* 主路径通过 mock startViewTransition 验证:调用它、设 CSS 变量。
*
* 注意:startThemeTransition 只接收 (x, y),目标主题(亮/暗)从 DOM 的 dark class
* 现状推导(取反),不依赖外部传入——避免与调用方状态不同步。
*
* 主题变更事件(THEME_CHANGE_EVENT):验证 VT 回调 + 降级路径都同步 dispatch,
* 且事件在 applyDarkClass 之前触发(编辑器换肤先于 class 翻转,同一 reflow 捕获)。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { THEME_CHANGE_EVENT } from './theme-transition';
import './index';
describe('startThemeTransition', () => {
beforeEach(() => {
document.documentElement.classList.remove('dark');
document.documentElement.classList.remove('is-theme-transitioning');
document.documentElement.style.cssText = '';
vi.restoreAllMocks();
});
afterEach(() => {
document.documentElement.classList.remove('dark');
document.documentElement.classList.remove('is-theme-transitioning');
document.documentElement.style.cssText = '';
});
it('降级:无 startViewTransition 时,亮→暗(无 dark class 时 add)', () => {
expect(document.documentElement.classList.contains('dark')).toBe(false);
window.__startThemeTransition(100, 200);
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('降级:无 startViewTransition 时,暗→亮(有 dark class 时 remove)', () => {
document.documentElement.classList.add('dark');
window.__startThemeTransition(100, 200);
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
it('主路径:有 startViewTransition 时调用它,注入变量,callback 切换 dark class', async () => {
const cbRef: { cb: (() => void) | null } = { cb: null };
const readyP = Promise.resolve();
const finishedP = Promise.resolve();
const startVT = vi.fn((cb: () => void) => {
cbRef.cb = cb;
return { ready: readyP, finished: finishedP, skipTransition: () => {} };
});
Object.defineProperty(document, 'startViewTransition', {
value: startVT,
configurable: true,
writable: true,
});
window.__startThemeTransition(100, 200);
expect(startVT).toHaveBeenCalledTimes(1);
// CSS 变量应注入
expect(document.documentElement.style.getPropertyValue('--tt-x')).toBe('100px');
expect(document.documentElement.style.getPropertyValue('--tt-y')).toBe('200px');
expect(document.documentElement.style.getPropertyValue('--tt-r')).toMatch(/^\d+(\.\d+)?px$/);
// is-theme-transitioning 应在 VT 之前添加
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(true);
// callback 里根据 DOM 现状(无 dark)切到 dark
cbRef.cb?.();
expect(document.documentElement.classList.contains('dark')).toBe(true);
// finished 后移除 is-theme-transitioning 和 CSS 变量
await finishedP;
await Promise.resolve();
// happy-dom microtask 可能需要额外 tick
await new Promise((r) => setTimeout(r, 0));
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(false);
expect(document.documentElement.style.getPropertyValue('--tt-x')).toBe('');
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
});
it('reduced-motion:即使有 startViewTransition 也走降级(瞬切)', () => {
const startVT = vi.fn(() => ({
ready: Promise.resolve(),
finished: Promise.resolve(),
skipTransition: () => {},
}));
Object.defineProperty(document, 'startViewTransition', {
value: startVT,
configurable: true,
writable: true,
});
vi.stubGlobal(
'matchMedia',
vi.fn((q: string) => ({
matches: q.includes('reduce'),
media: q,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})),
);
window.__startThemeTransition(0, 0);
expect(startVT).not.toHaveBeenCalled();
expect(document.documentElement.classList.contains('dark')).toBe(true);
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
});
it('主路径:VT callback 内 dispatch 主题变更事件,且先于 dark class 翻转', () => {
const cbRef: { cb: (() => void) | null } = { cb: null };
Object.defineProperty(document, 'startViewTransition', {
value: (cb: () => void) => {
cbRef.cb = cb;
return { ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} };
},
configurable: true,
writable: true,
});
// 记录事件触发时刻的 dark class 状态——验证事件在 applyDarkClass 之前 dispatch
const eventSnapshots: { isDark: boolean; darkClassAtDispatch: boolean }[] = [];
const listener = (e: Event) => {
const detail = (e as CustomEvent).detail as { isDark: boolean };
eventSnapshots.push({
isDark: detail.isDark,
darkClassAtDispatch: document.documentElement.classList.contains('dark'),
});
};
window.addEventListener(THEME_CHANGE_EVENT, listener);
// 亮→暗:无 dark class,isDark=true
window.__startThemeTransition(0, 0);
cbRef.cb?.();
expect(eventSnapshots).toHaveLength(1);
expect(eventSnapshots[0].isDark).toBe(true);
// 事件触发时 dark class 尚未翻转(仍是 light)——证明事件先于 applyDarkClass
expect(eventSnapshots[0].darkClassAtDispatch).toBe(false);
// callback 执行完后 dark class 已翻转
expect(document.documentElement.classList.contains('dark')).toBe(true);
window.removeEventListener(THEME_CHANGE_EVENT, listener);
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
});
it('降级路径:无 VT 时也 dispatch 主题变更事件(亮→暗)', () => {
const calls: boolean[] = [];
const listener = (e: Event) => {
calls.push((e as CustomEvent).detail.isDark);
};
window.addEventListener(THEME_CHANGE_EVENT, listener);
// 亮→暗
window.__startThemeTransition(0, 0);
expect(calls).toEqual([true]);
expect(document.documentElement.classList.contains('dark')).toBe(true);
// 暗→亮
window.__startThemeTransition(0, 0);
expect(calls).toEqual([true, false]);
expect(document.documentElement.classList.contains('dark')).toBe(false);
window.removeEventListener(THEME_CHANGE_EVENT, listener);
});
it('applyResolvedTheme:同步 dispatch 主题变更事件 + 翻 dark class', () => {
const calls: { isDark: boolean; darkClassAtDispatch: boolean }[] = [];
const listener = (e: Event) => {
const detail = (e as CustomEvent).detail as { isDark: boolean };
calls.push({
isDark: detail.isDark,
darkClassAtDispatch: document.documentElement.classList.contains('dark'),
});
};
window.addEventListener(THEME_CHANGE_EVENT, listener);
window.__applyResolvedTheme(true);
expect(calls).toEqual([{ isDark: true, darkClassAtDispatch: false }]);
expect(document.documentElement.classList.contains('dark')).toBe(true);
window.__applyResolvedTheme(false);
expect(calls).toEqual([
{ isDark: true, darkClassAtDispatch: false },
{ isDark: false, darkClassAtDispatch: true },
]);
expect(document.documentElement.classList.contains('dark')).toBe(false);
window.removeEventListener(THEME_CHANGE_EVENT, listener);
});
});