// src/utils.js 的纯函数测试 // 当前覆盖 isMarkdownFile —— 这是 Stage 8 引入的工具, // 决定一个文件是否能渲染 Markdown 预览(仅 .md / .markdown)。 import { describe, it, expect } from 'vitest'; import { isMarkdownFile, isPathInside, pathsEqual, formatSize, formatDate, formatRelativeTime, escapeHtml, escapeAttr, dirnameOf, basenameOf, resolvePath, toFileUrl, safeRenderMarkdown, } from '../../src/utils.js'; describe('isPathInside', () => { it('基本包含关系 → true', () => { expect(isPathInside('/data/notes/foo.md', '/data/notes')).toBe(true); expect(isPathInside('/data/notes/sub/foo.md', '/data/notes')).toBe(true); }); it('完全相等的路径 → true(自身视为在内)', () => { expect(isPathInside('/data/notes', '/data/notes')).toBe(true); expect(isPathInside('/data/notes/', '/data/notes')).toBe(true); expect(isPathInside('/data/notes', '/data/notes/')).toBe(true); }); it('前缀相同但不是子目录(避免 /foo 包含 /foobar) → false', () => { expect(isPathInside('/data/foobar.md', '/data/foo')).toBe(false); }); it('完全不同的路径 → false', () => { expect(isPathInside('/etc/passwd', '/data/notes')).toBe(false); expect(isPathInside('/data/foo.md', '/var/log')).toBe(false); }); it('parent 是 POSIX 根目录 / → 任何非空 child 都视为在内', () => { // 修复点:旧版本 norm('/') → '',导致 a === '' 与任何 child 都不等、b.startsWith('/') 又为 false, // 误判「根目录下没有路径」。现在显式把空前缀视为「任何子路径都在内」。 expect(isPathInside('/foo/bar', '/')).toBe(true); expect(isPathInside('/a', '/')).toBe(true); expect(isPathInside('/notes/x.md', '/')).toBe(true); }); it('parent 是 Windows 根盘符 C:\\ → 任何非空 child 都视为在内(jsdom/Windows 测试场景)', () => { // 这里没法在 node 里真实触发 isWindows(),但 norm 之后变成 'c:' 是确定的: // 直接构造「模拟 Windows」路径。算法只看规范化结果,不依赖平台检测。 // 这一档测试对应 norm(parent) === 'c:' 的分支。 expect(isPathInside('C:\\Users\\foo\\note.md', 'C:\\')).toBe(true); }); it('空 child / 空 parent / null → false', () => { expect(isPathInside('', '/data')).toBe(false); expect(isPathInside('/data/foo.md', '')).toBe(false); expect(isPathInside(null, '/data')).toBe(false); expect(isPathInside('/data/foo.md', null)).toBe(false); expect(isPathInside(undefined, undefined)).toBe(false); }); it('child 仅有分隔符(规范化后为空)→ false', () => { // 修复点:避免「//」或「/」被当成合法 child 漏进根目录 expect(isPathInside('/', '/data')).toBe(false); expect(isPathInside('//', '/data')).toBe(false); }); it('混合路径分隔符(正反斜杠)→ 视为相同', () => { // norm 把所有 \\ → /,所以 'C:\\data' 与 'C:/data' 等价 // (node 测试环境下 isWindows 返回 false,不会 lowercase; // 这一档主要验证 norm 的归一化效果) expect(isPathInside('/data/notes/foo.md', '\\data\\notes')).toBe(true); }); }); describe('pathsEqual', () => { it('两个相同字符串 → true', () => { expect(pathsEqual('/a/b/c', '/a/b/c')).toBe(true); expect(pathsEqual('C:\\Users\\foo', 'C:\\Users\\foo')).toBe(true); }); it('空值 / null / undefined → false(仅双空相等视为 false)', () => { expect(pathsEqual('', '/a')).toBe(false); expect(pathsEqual('/a', '')).toBe(false); expect(pathsEqual(null, '/a')).toBe(false); expect(pathsEqual('/a', undefined)).toBe(false); }); it('路径内容不同 → false', () => { expect(pathsEqual('/a/b', '/a/c')).toBe(false); expect(pathsEqual('C:\\foo', 'C:\\bar')).toBe(false); }); // Windows case-fold 行为:jsdom 25 默认 userAgent 是 'Mozilla/5.0 (win32)' // → isWindows() 返回 true。所以两条 case 不同的路径在测试环境下应判等。 // 真实的运行环境(Windows / POSIX)效果以 isPathInside 的 Windows 段已经覆盖的 // 「跨斜杠 + 大小写不敏感」原则一致 —— 不为「假想平台」造用例。 it('Windows 路径大小写不敏感:case-fold 后相等 → true', () => { // jsdom 默认 mock 是 win32 userAgent → 这条断言测的是真实运行行为 expect(pathsEqual('C:\\Users\\Foo\\note.md', 'c:\\users\\foo\\note.md')).toBe(true); expect(pathsEqual('/A/b.md', '/a/b.md')).toBe(true); }); }); describe('isMarkdownFile', () => { it('扩展名为 .md → true', () => { expect(isMarkdownFile('note.md')).toBe(true); expect(isMarkdownFile('/abs/path/to/note.md')).toBe(true); expect(isMarkdownFile('C:\\Users\\foo\\notes\\note.md')).toBe(true); }); it('扩展名为 .markdown → true', () => { expect(isMarkdownFile('README.markdown')).toBe(true); expect(isMarkdownFile('/path/to/README.markdown')).toBe(true); }); it('大小写不敏感', () => { expect(isMarkdownFile('Note.MD')).toBe(true); expect(isMarkdownFile('Note.Md')).toBe(true); expect(isMarkdownFile('Note.Markdown')).toBe(true); }); it('其它可编辑扩展 → false(仅支持编辑,不支持预览)', () => { expect(isMarkdownFile('data.json')).toBe(false); expect(isMarkdownFile('script.py')).toBe(false); expect(isMarkdownFile('config.yaml')).toBe(false); expect(isMarkdownFile('plain.txt')).toBe(false); expect(isMarkdownFile('main.js')).toBe(false); expect(isMarkdownFile('main.ts')).toBe(false); expect(isMarkdownFile('styles.css')).toBe(false); expect(isMarkdownFile('page.html')).toBe(false); }); it('无扩展名 / 隐藏文件 / 空字符串 → false', () => { expect(isMarkdownFile('')).toBe(false); expect(isMarkdownFile('.gitignore')).toBe(false); expect(isMarkdownFile('Makefile')).toBe(false); expect(isMarkdownFile(null)).toBe(false); expect(isMarkdownFile(undefined)).toBe(false); expect(isMarkdownFile(42)).toBe(false); }); it('以 .md 结尾但不是扩展名(如 .md5、.model)→ false', () => { // 防御:'foo.model' 的 lastIndexOf('.') 是 'model' 里的点,但我们按最后一个点判断, // 应该是 'l',所以 false。'foo.md5' 最后一个点是 '.md5' 之前?还是…… 看实现: // path.lastIndexOf('.') → 找到最后一个 '.' 的位置。对 'foo.md5',dot 在 3, // 取 dot+1 之后到末尾 'md5',不是集合里的 md。 expect(isMarkdownFile('photo.md5')).toBe(false); expect(isMarkdownFile('archive.model')).toBe(false); }); it('多点文件名(note.v2.md、a.b.c.markdown)→ true', () => { // basename 末段的最后一个点才是扩展名分隔 expect(isMarkdownFile('note.v2.md')).toBe(true); expect(isMarkdownFile('a.b.c.markdown')).toBe(true); }); it('无扩展名的常见文件名 → false', () => { expect(isMarkdownFile('README')).toBe(false); expect(isMarkdownFile('LICENSE')).toBe(false); expect(isMarkdownFile('/abs/path/Makefile')).toBe(false); }); }); describe('formatSize', () => { it('字节 / KB / MB 分级', () => { expect(formatSize(0)).toBe('0 B'); expect(formatSize(512)).toBe('512 B'); expect(formatSize(1024)).toBe('1.0 KB'); expect(formatSize(1536)).toBe('1.5 KB'); expect(formatSize(1024 * 1024)).toBe('1.0 MB'); expect(formatSize(1024 * 1024 * 2.5)).toBe('2.5 MB'); }); it('非法输入 → 占位符', () => { expect(formatSize(-1)).toBe('—'); expect(formatSize(NaN)).toBe('—'); expect(formatSize('100')).toBe('—'); expect(formatSize(null)).toBe('—'); }); }); describe('formatDate', () => { it('YYYY-MM-DD HH:mm', () => { // 用本地时区无关的方式构造:直接取 0 时刻附近 const d = new Date(2024, 0, 15, 9, 5); // 2024-01-15 09:05 本地 expect(formatDate(d.getTime())).toBe('2024-01-15 09:05'); }); it('非法输入 → 空串', () => { expect(formatDate(NaN)).toBe(''); expect(formatDate('xxx')).toBe(''); expect(formatDate(null)).toBe(''); }); }); describe('formatRelativeTime', () => { const now = new Date(2024, 5, 15, 12, 0, 0).getTime(); // 2024-06-15 12:00:00 it('< 60s → "刚刚"', () => { expect(formatRelativeTime(now - 5_000, now)).toBe('刚刚'); expect(formatRelativeTime(now - 59_000, now)).toBe('刚刚'); }); it('< 60min → "N 分钟前"', () => { expect(formatRelativeTime(now - 60_000, now)).toBe('1 分钟前'); expect(formatRelativeTime(now - 5 * 60_000, now)).toBe('5 分钟前'); expect(formatRelativeTime(now - 59 * 60_000, now)).toBe('59 分钟前'); }); it('同日 < 24h → "N 小时前"', () => { expect(formatRelativeTime(now - 60 * 60_000, now)).toBe('1 小时前'); expect(formatRelativeTime(now - 3 * 60 * 60_000, now)).toBe('3 小时前'); }); it('昨天 → "昨天"', () => { const yesterday = now - 25 * 60 * 60_000; // 跨过 0 点 expect(formatRelativeTime(yesterday, now)).toBe('昨天'); }); it('< 7d → "N 天前"', () => { expect(formatRelativeTime(now - 3 * 86400_000, now)).toBe('3 天前'); expect(formatRelativeTime(now - 6 * 86400_000, now)).toBe('6 天前'); }); it('≥ 7d → 回落到绝对时间', () => { const old = new Date(2024, 5, 1, 12, 0, 0).getTime(); const got = formatRelativeTime(old, now); // 应该跟 formatDate(old) 一致 expect(got).toBe(formatDate(old)); expect(got).toBe('2024-06-01 12:00'); }); it('未来时间 → 当成 0 差("刚刚")', () => { expect(formatRelativeTime(now + 5_000, now)).toBe('刚刚'); }); it('非法输入 → 空串', () => { expect(formatRelativeTime(NaN, now)).toBe(''); expect(formatRelativeTime('xxx', now)).toBe(''); expect(formatRelativeTime(null, now)).toBe(''); expect(formatRelativeTime(undefined, now)).toBe(''); }); it('非法 nowMs → 退回 Date.now() 不报错', () => { // 不传 nowMs;should 不抛 expect(typeof formatRelativeTime(now - 1000)).toBe('string'); // 显式传非法 nowMs → 内部用 Date.now() 兜底 expect(typeof formatRelativeTime(now - 1000, NaN)).toBe('string'); }); }); describe('escapeHtml / escapeAttr', () => { it('escapeHtml 转义五个特殊字符', () => { expect(escapeHtml(''); expect(html).toContain('
alert(1)');
});
it('window 不可用 → 纯文本透传', () => {
const html = safeRenderMarkdown('safe text');
expect(html).toContain(' 包空内容', () => {
expect(safeRenderMarkdown('')).toContain('