yggdrasil/libs/yggdrasil-core/src/mermaid.test.ts
xfy fac247f60a feat(mermaid): 流程图配色对齐 Catppuccin + 容器美化
mermaid.initialize 之前用内置 'default'/'dark' 主题,配色与站点 Catppuccin
调色板完全脱节,图是"外来"的样子;且零 mermaid 专用 CSS,SVG 直接套在通用
pre 容器里,宽图横向滚动、无居中无留白。

改动:
- mermaid.ts:theme 切到 'base'(base 不硬编码颜色,themeVariables 能完全
  控制调色板;'default' 主题硬编码 mainBkg=#ECECFF 会阻断覆盖)。新增 Latte/
  Mocha 两套 themeVariables,节点用 surface 色阶、边框/连线用 subtext 色阶、
  文字用 primary text,hex 取自 themes/*.tmTheme。flowchart 配 basis 平滑曲线
  + diagramPadding 16 + useMaxWidth:true(配合 CSS 消除横向滚动)。
- input.css:新增 .md-content pre[data-mermaid-rendered] 规则,flex 居中 + svg
  max-width:100% 缩放适配;.mermaid-error 加 accent 色左边条提示。
- mermaid.test.ts:theme 断言同步到 'base'+darkMode;新增 themeVariables
  注入测试(钉住 Latte/Mocha 关键 hex)。

范围:纯前端 TS + CSS,零 Rust 改动。
2026-07-17 10:13:20 +08:00

222 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* mermaid 测试:钉住懒加载渲染的扫描、幂等、主题适配与错误回退。
* 黑盒驱动:通过 window.__initMermaid 入口mock IntersectionObserver 与 mermaid bundle 加载。
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import './index';
import { _resetMermaidLoader } from './mermaid';
// mock IntersectionObserverobserve 时立即异步触发 isIntersecting 回调,模拟块进视口。
const disconnect = vi.fn();
const observe = vi.fn();
let intersectCallback: ((entries: { isIntersecting: boolean }[]) => void) | null = null;
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(cb: (entries: { isIntersecting: boolean }[]) => void) {
intersectCallback = cb;
}
observe() {
observe();
// 立即触发可见,模拟块已在视口内。
if (intersectCallback) intersectCallback([{ isIntersecting: true }]);
}
disconnect() {
disconnect();
}
},
);
describe('initMermaid', () => {
const mockRender = vi.fn().mockResolvedValue({ svg: '<svg>diagram</svg>' });
const mockInitialize = vi.fn();
beforeEach(() => {
document.body.innerHTML = '';
mockRender.mockResolvedValue({ svg: '<svg>diagram</svg>' });
mockRender.mockClear();
mockInitialize.mockClear();
observe.mockClear();
disconnect.mockClear();
// 注入 mock mermaid bundle 加载函数
_resetMermaidLoader(async () => ({ initialize: mockInitialize, render: mockRender }));
});
afterEach(() => {
document.body.innerHTML = '';
});
it('扫描 language-mermaid 块并渲染成 SVG', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.innerHTML).toContain('<svg>diagram</svg>');
});
});
it('用 dark 主题初始化 mermaid', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'dark');
await vi.waitFor(() => {
// base 主题 + darkMode 标志:让 themeVariables 完全控制 Catppuccin 调色板
expect(mockInitialize).toHaveBeenCalledWith(
expect.objectContaining({ theme: 'base', darkMode: true }),
);
});
});
it('亮/暗主题传入对应的 Catppuccin themeVariables', async () => {
// 暗色Mocha 调色板(背景 #313244、文字 #cdd6f4
const darkRoot = document.createElement('div');
darkRoot.className = 'post-content';
darkRoot.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(darkRoot);
window.__initMermaid('.post-content', 'dark');
await vi.waitFor(() => {
expect(mockInitialize).toHaveBeenCalled();
});
const darkCall = mockInitialize.mock.calls.slice(-1)[0]?.[0] as Record<string, unknown>;
const darkVars = darkCall.themeVariables as Record<string, string>;
expect(darkVars.background).toBe('#313244');
expect(darkVars.primaryTextColor).toBe('#cdd6f4');
expect(darkVars.lineColor).toBe('#a6adc8');
// 亮色Latte 调色板(背景 #dce0e8、文字 #4c4f69
document.body.innerHTML = '';
const lightRoot = document.createElement('div');
lightRoot.className = 'post-content';
lightRoot.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(lightRoot);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(mockInitialize).toHaveBeenCalled();
});
const lightCall = mockInitialize.mock.calls.slice(-1)[0]?.[0] as Record<string, unknown>;
const lightVars = lightCall.themeVariables as Record<string, string>;
expect(lightVars.background).toBe('#dce0e8');
expect(lightVars.primaryTextColor).toBe('#4c4f69');
expect(lightVars.lineColor).toBe('#5c5f77');
});
it('幂等:重复调用不重复渲染已处理的块', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
});
mockRender.mockClear();
// 第二次调用(模拟上下篇切换):不应再次 render
window.__initMermaid('.post-content', 'light');
await new Promise((r) => setTimeout(r, 50));
expect(mockRender).not.toHaveBeenCalled();
});
it('非 mermaid 代码块不受影响', () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-rust">fn main() {}</code></pre>';
document.body.appendChild(root);
expect(() => window.__initMermaid('.post-content', 'light')).not.toThrow();
// 不应尝试渲染 rust 块
expect(observe).not.toHaveBeenCalled();
});
it('selector 未命中时不报错', () => {
expect(() => window.__initMermaid('.not-exist', 'light')).not.toThrow();
});
it('渲染失败时加 mermaid-error class', async () => {
mockRender.mockRejectedValueOnce(new Error('syntax error'));
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">bad syntax</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.classList.contains('mermaid-error')).toBe(true);
});
});
it('主题切换时重渲染已渲染的块', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
// 首次渲染light
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
});
expect(root.querySelector('pre')?.dataset.mermaidTheme).toBe('light');
const firstRenderCalls = mockRender.mock.calls.length;
// 主题切换 → dark应触发重渲染
window.__initMermaid('.post-content', 'dark');
await vi.waitFor(() => {
expect(mockRender.mock.calls.length).toBeGreaterThan(firstRenderCalls);
});
expect(mockInitialize).toHaveBeenLastCalledWith(
expect.objectContaining({ theme: 'base', darkMode: true }),
);
expect(root.querySelector('pre')?.dataset.mermaidTheme).toBe('dark');
});
it('主题未变时重渲染路径幂等(同主题跳过)', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
});
mockRender.mockClear();
// 同主题再调模拟上下篇切换复用组件实例、effect 重跑)
window.__initMermaid('.post-content', 'light');
await new Promise((r) => setTimeout(r, 50));
expect(mockRender).not.toHaveBeenCalled();
});
it('主题切换重渲染用唯一 render id避免 mermaid 残留节点冲突)', async () => {
const root = document.createElement('div');
root.className = 'post-content';
root.innerHTML = '<pre><code class="language-mermaid">graph TD; A--&gt;B</code></pre>';
document.body.appendChild(root);
window.__initMermaid('.post-content', 'light');
await vi.waitFor(() => {
expect(root.querySelector('pre')?.dataset.mermaidRendered).toBe('true');
});
const firstId = mockRender.mock.calls[0][0];
window.__initMermaid('.post-content', 'dark');
await vi.waitFor(() => {
expect(mockRender.mock.calls.length).toBeGreaterThanOrEqual(2);
});
const secondId = mockRender.mock.calls[mockRender.mock.calls.length - 1][0];
// 两次 render 的 id 必须不同,否则撞上 mermaid 内部残留的 d-前缀节点(#357
expect(secondId).not.toBe(firstId);
});
});