update
This commit is contained in:
@@ -734,4 +734,64 @@ describe('coerceLoadedSettings(schema 兜底)', () => {
|
||||
expect(coerceLoadedSettings(undefined)).toEqual({ ...DEFAULT_CONFIG });
|
||||
expect(coerceLoadedSettings('garbage')).toEqual({ ...DEFAULT_CONFIG });
|
||||
});
|
||||
});
|
||||
|
||||
// aiApiKey 包络降级路径(loadConfig 解密失败 → pendingAiKeyEnvelope)。
|
||||
// 单测环境没有 Electron safeStorage → secretStore.decrypt 对 enc:v1: 包络
|
||||
// 恒返回 ''(「不可用」契约),天然进入降级分支,无需额外 mock。
|
||||
describe('aiApiKey 包络降级(decrypt 失败)', () => {
|
||||
const ENVELOPE = 'enc:v1:Zm9vYmFy';
|
||||
|
||||
it('解密失败:内存 aiApiKey 置空(明文契约)+ hasPendingAiKeyEnvelope 为 true', () => {
|
||||
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: ENVELOPE, theme: 'dark' }), 'utf-8');
|
||||
configStore.init();
|
||||
expect(configStore.getConfig().aiApiKey).toBe('');
|
||||
expect(configStore.hasPendingAiKeyEnvelope()).toBe(true);
|
||||
});
|
||||
|
||||
it('saveConfig 未触碰 aiApiKey:包络原样写回磁盘,不被空串覆盖', async () => {
|
||||
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: ENVELOPE, theme: 'dark' }), 'utf-8');
|
||||
configStore.init();
|
||||
const res = await configStore.saveConfig({ theme: 'light' });
|
||||
expect(res.ok).toBe(true);
|
||||
const raw = JSON.parse(readFileSync(join(mockUserDataDir, 'config.json'), 'utf-8'));
|
||||
expect(raw.aiApiKey).toBe(ENVELOPE);
|
||||
expect(raw.theme).toBe('light');
|
||||
// 内存侧仍守「aiApiKey 是明文或 ''」契约:不把包络塞回去
|
||||
expect(configStore.getConfig().aiApiKey).toBe('');
|
||||
});
|
||||
|
||||
it('saveConfig 显式重填 Key:写新值、清掉旧包络', async () => {
|
||||
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: ENVELOPE }), 'utf-8');
|
||||
configStore.init();
|
||||
const res = await configStore.saveConfig({ aiApiKey: 'sk-new' });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(configStore.hasPendingAiKeyEnvelope()).toBe(false);
|
||||
// 测试环境 safeStorage 不可用 → encrypt 降级为明文透传
|
||||
const raw = JSON.parse(readFileSync(join(mockUserDataDir, 'config.json'), 'utf-8'));
|
||||
expect(raw.aiApiKey).toBe('sk-new');
|
||||
expect(configStore.getConfig().aiApiKey).toBe('sk-new');
|
||||
});
|
||||
|
||||
it('saveConfig 显式清空 Key:写空串,包络不复活', async () => {
|
||||
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: ENVELOPE }), 'utf-8');
|
||||
configStore.init();
|
||||
const res = await configStore.saveConfig({ aiApiKey: '' });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(configStore.hasPendingAiKeyEnvelope()).toBe(false);
|
||||
const raw = JSON.parse(readFileSync(join(mockUserDataDir, 'config.json'), 'utf-8'));
|
||||
expect(raw.aiApiKey).toBe('');
|
||||
});
|
||||
|
||||
it('明文 passthrough(无前缀)正常解密:不留包络', () => {
|
||||
writeFileSync(join(mockUserDataDir, 'config.json'), JSON.stringify({ aiApiKey: 'sk-plain' }), 'utf-8');
|
||||
configStore.init();
|
||||
expect(configStore.getConfig().aiApiKey).toBe('sk-plain');
|
||||
expect(configStore.hasPendingAiKeyEnvelope()).toBe(false);
|
||||
});
|
||||
|
||||
it('首次启动(无 config.json)不残留包络状态', () => {
|
||||
configStore.init();
|
||||
expect(configStore.hasPendingAiKeyEnvelope()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,44 @@ describe('FileList 拖拽投放', () => {
|
||||
expect(fileLi.classList.contains('is-dragging')).toBe(false);
|
||||
expect(list._dragEntry).toBeNull();
|
||||
});
|
||||
|
||||
it('dragstart 设 effectAllowed=all(拖行中补按 Ctrl 仍可 copy 落地)', () => {
|
||||
// Chromium 会把 dragover 的 dropEffect 夹进 effectAllowed:dragstart 若按
|
||||
// 当时 Ctrl 态锁成 'move',途中补按 Ctrl 要 copy 时 'copy' ⊄ 'move',
|
||||
// drop 被静默吞掉(Chromium 实测)。意图实时判定由 dragover/drop 的
|
||||
// ctrlKey 分支负责。
|
||||
const ev = fire(liFor('/d/note.md'), 'dragstart');
|
||||
expect(ev.dataTransfer.effectAllowed).toBe('all');
|
||||
});
|
||||
|
||||
it('_isInvalidDropTarget:源在目标目录内 → 拒绝(原地移动会变成 name (2) 静默改名)', () => {
|
||||
// 方向回归守卫:此前条件写反成「target 在 src 之下」,该守卫从未真正生效。
|
||||
expect(list._isInvalidDropTarget(
|
||||
{ path: '/d/sub/x.md', entryType: 'file' },
|
||||
{ path: '/d/sub', entryType: 'folder' },
|
||||
)).toBe(true);
|
||||
// 兄弟目录的正常拖放不受影响
|
||||
expect(list._isInvalidDropTarget(
|
||||
{ path: '/d/note.md', entryType: 'file' },
|
||||
{ path: '/d/sub', entryType: 'folder' },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('setClipboardCut 高亮跨 setFiles 重渲染保留,传 null 清除', () => {
|
||||
list.setClipboardCut('/d/note.md');
|
||||
expect(liFor('/d/note.md').classList.contains('is-clipboard-cut')).toBe(true);
|
||||
// fs-watcher 推新快照触发重渲染:命令式加的 class 会被 _fillItem 的
|
||||
// wantClass 重赋值抹掉 —— 高亮必须由渲染期从 clipboardCutPath 补回。
|
||||
list.setFiles([
|
||||
{ name: 'note.md', path: '/d/note.md', entryType: 'file' },
|
||||
{ name: 'sub', path: '/d/sub', entryType: 'folder' },
|
||||
{ name: 'other.md', path: '/d/other.md', entryType: 'file' },
|
||||
]);
|
||||
expect(liFor('/d/note.md').classList.contains('is-clipboard-cut')).toBe(true);
|
||||
expect(liFor('/d/sub').classList.contains('is-clipboard-cut')).toBe(false);
|
||||
list.setClipboardCut(null);
|
||||
expect(liFor('/d/note.md').classList.contains('is-clipboard-cut')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileList 空白区域右键菜单', () => {
|
||||
|
||||
@@ -150,6 +150,89 @@ describe('moveEntry / copyEntry', () => {
|
||||
expect(ctx.state.currentFile.path).toBe('/d/note.md');
|
||||
});
|
||||
|
||||
it('moveEntry 飞行中设置 state.movingPath,成功后 finally 清空(fs-watcher 竞态守卫)', async () => {
|
||||
const ctx = makeCtx();
|
||||
let seenDuringFlight;
|
||||
ctx.api.moveFile = vi.fn(async () => {
|
||||
seenDuringFlight = ctx.state.movingPath;
|
||||
return { ok: true, path: '/d/sub/note.md', name: 'note.md', entryType: 'file', mtimeMs: 123, size: 10, relDir: 'sub' };
|
||||
});
|
||||
globalThis.window = { api: ctx.api };
|
||||
const ops = createFileOps(ctx);
|
||||
|
||||
await ops.moveEntry({ path: '/d/note.md', name: 'note.md', entryType: 'file' }, '/d/sub');
|
||||
|
||||
expect(seenDuringFlight).toBe('/d/note.md');
|
||||
expect(ctx.state.movingPath).toBeNull();
|
||||
// 移动文件本身 → currentFileRelDir 同步为 result.relDir
|
||||
expect(ctx.state.currentFileRelDir).toBe('sub');
|
||||
});
|
||||
|
||||
it('moveEntry 失败同样清空 movingPath 守卫', async () => {
|
||||
const ctx = makeCtx();
|
||||
ctx.api.moveFile = vi.fn(async () => ({ ok: false, code: 'EACCES', message: '没有访问权限' }));
|
||||
globalThis.window = { api: ctx.api };
|
||||
const ops = createFileOps(ctx);
|
||||
|
||||
await ops.moveEntry({ path: '/d/note.md', name: 'note.md', entryType: 'file' }, '/d/sub');
|
||||
|
||||
expect(ctx.state.movingPath).toBeNull();
|
||||
});
|
||||
|
||||
it('moveEntry 祖先文件夹被移动(重名避让改名):新路径用 result.path 重拼、relDir 指向文件所在目录', async () => {
|
||||
const ctx = makeCtx();
|
||||
ctx.state.currentFile = { path: '/d/A/sub/note.md', name: 'note.md', mtimeMs: 7, size: 42 };
|
||||
ctx.state.currentFileRelDir = 'A/sub';
|
||||
// 移动 /d/A → /d/B,重名避让后实际落点 /d/B/A (2)
|
||||
ctx.api.moveFile = vi.fn(async () => ({
|
||||
ok: true, path: '/d/B/A (2)', name: 'A (2)', entryType: 'folder', relDir: 'B', mtimeMs: 123, size: 10,
|
||||
}));
|
||||
globalThis.window = { api: ctx.api };
|
||||
const ops = createFileOps(ctx);
|
||||
|
||||
await ops.moveEntry({ path: '/d/A', name: 'A', entryType: 'folder' }, '/d/B');
|
||||
|
||||
// 旧实现:destDir + remainder → 丢掉文件夹自身段,拼成 /d/B/sub/note.md(错)
|
||||
expect(ctx.state.currentFile.path).toBe('/d/B/A (2)/sub/note.md');
|
||||
expect(ctx.state.currentFile.name).toBe('note.md');
|
||||
// 旧实现:直接用 result.relDir='B'(被移动文件夹的新父目录,错)
|
||||
expect(ctx.state.currentFileRelDir).toBe('B/A (2)/sub');
|
||||
expect(ctx.controls.fileList.setActive).toHaveBeenCalledWith('/d/B/A (2)/sub/note.md');
|
||||
});
|
||||
|
||||
it('moveEntry 祖先文件夹被移动(打开文件直接在文件夹下):relDir = 新父/新名', async () => {
|
||||
const ctx = makeCtx();
|
||||
ctx.state.currentFile = { path: '/d/A/note.md', name: 'note.md', mtimeMs: 7, size: 42 };
|
||||
ctx.api.moveFile = vi.fn(async () => ({
|
||||
ok: true, path: '/d/B/A', name: 'A', entryType: 'folder', relDir: 'B', mtimeMs: 123, size: 10,
|
||||
}));
|
||||
globalThis.window = { api: ctx.api };
|
||||
const ops = createFileOps(ctx);
|
||||
|
||||
await ops.moveEntry({ path: '/d/A', name: 'A', entryType: 'folder' }, '/d/B');
|
||||
|
||||
expect(ctx.state.currentFile.path).toBe('/d/B/A/note.md');
|
||||
expect(ctx.state.currentFileRelDir).toBe('B/A');
|
||||
});
|
||||
|
||||
it('moveEntry 祖先判定带分隔符边界:/d/A 移动不误伤打开文件 /d/Abc.md', async () => {
|
||||
const ctx = makeCtx();
|
||||
ctx.state.currentFile = { path: '/d/Abc.md', name: 'Abc.md', mtimeMs: 7, size: 42 };
|
||||
ctx.state.currentFileRelDir = '';
|
||||
// result.entryType folder 且 cfPath 与被移动文件夹共享前缀但不同目录
|
||||
ctx.api.moveFile = vi.fn(async () => ({
|
||||
ok: true, path: '/d/B/A', name: 'A', entryType: 'folder', relDir: 'B', mtimeMs: 123, size: 10,
|
||||
}));
|
||||
globalThis.window = { api: ctx.api };
|
||||
const ops = createFileOps(ctx);
|
||||
|
||||
await ops.moveEntry({ path: '/d/A', name: 'A', entryType: 'folder' }, '/d/B');
|
||||
|
||||
// 旧实现裸 startsWith('/d/A') 会误命中 '/d/Abc.md' 并把状态改坏
|
||||
expect(ctx.state.currentFile.path).toBe('/d/Abc.md');
|
||||
expect(ctx.state.currentFileRelDir).toBe('');
|
||||
});
|
||||
|
||||
it('moveEntry / copyEntry 入参缺字段时直接返回 null', async () => {
|
||||
const ctx = makeCtx();
|
||||
const ops = createFileOps(ctx);
|
||||
|
||||
129
tests/unit/markdown-editor-undo-redo.test.js
Normal file
129
tests/unit/markdown-editor-undo-redo.test.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// 2026-10 撤销 / 重做按钮(#btn-undo / #btn-redo)背后的 MarkdownEditor 门面:
|
||||
// - canUndo / canRedo 跟随 CodeMirror history 深度
|
||||
// - undo / redo 执行命令并返回是否生效
|
||||
// - loadFile 复用 view 切文件时 history 重置(每文件一份干净 undo/redo 栈,
|
||||
// 按钮不会在刚打开的文件上误导「有可撤销」)
|
||||
// app.js 顶层不静态 import CM6(audit P2-9),按钮接线测不到这里,
|
||||
// 语义正确性全部收敛在本文件的 MarkdownEditor 方法层。
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MarkdownEditor } from '../../src/markdown-editor.js';
|
||||
|
||||
if (!HTMLElement.prototype.scrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = function () {};
|
||||
}
|
||||
if (typeof window.requestAnimationFrame === 'undefined') {
|
||||
window.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
window.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
}
|
||||
if (typeof Range.prototype.getClientRects !== 'function') {
|
||||
Range.prototype.getClientRects = function () {
|
||||
const arr = [];
|
||||
arr.item = (i) => arr[i] || null;
|
||||
return arr;
|
||||
};
|
||||
}
|
||||
if (typeof Range.prototype.getBoundingClientRect !== 'function') {
|
||||
Range.prototype.getBoundingClientRect = function () {
|
||||
return { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
function createEditor() {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
return new MarkdownEditor({
|
||||
element: el,
|
||||
onChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
// 模拟一次用户输入(独立 history 事件:userEvent 不同不会被 newGroupDelay 合并)
|
||||
function type(editor, text) {
|
||||
editor.view.dispatch({
|
||||
changes: { from: editor.view.state.doc.length, insert: text },
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
}
|
||||
|
||||
describe('MarkdownEditor 撤销/重做门面(2026-10)', () => {
|
||||
let editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = createEditor();
|
||||
});
|
||||
|
||||
it('view 未创建(还没 loadFile)→ canUndo / canRedo 均 false,undo/redo 返回 false', () => {
|
||||
expect(editor.canUndo()).toBe(false);
|
||||
expect(editor.canRedo()).toBe(false);
|
||||
expect(editor.undo()).toBe(false);
|
||||
expect(editor.redo()).toBe(false);
|
||||
});
|
||||
|
||||
it('首次 loadFile 后(fresh view)→ 无可撤无可重做', () => {
|
||||
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'hello');
|
||||
expect(editor.canUndo()).toBe(false);
|
||||
expect(editor.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it('输入 → canUndo true;undo() 回退 doc 且 canRedo true;redo() 再前滚', () => {
|
||||
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'hello');
|
||||
type(editor, ' world');
|
||||
expect(editor.view.state.doc.toString()).toBe('hello world');
|
||||
expect(editor.canUndo()).toBe(true);
|
||||
expect(editor.canRedo()).toBe(false);
|
||||
|
||||
expect(editor.undo()).toBe(true);
|
||||
expect(editor.view.state.doc.toString()).toBe('hello');
|
||||
expect(editor.canUndo()).toBe(false);
|
||||
expect(editor.canRedo()).toBe(true);
|
||||
|
||||
expect(editor.redo()).toBe(true);
|
||||
expect(editor.view.state.doc.toString()).toBe('hello world');
|
||||
expect(editor.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it('历史耗尽时 undo 空操作返回 false,不吞事件', () => {
|
||||
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'x');
|
||||
expect(editor.undo()).toBe(false);
|
||||
expect(editor.view.state.doc.toString()).toBe('x');
|
||||
});
|
||||
|
||||
it('切文件(复用 view)→ 前一文件的编辑记录被清空,doc 是新文件内容', () => {
|
||||
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'AAA');
|
||||
type(editor, '!'); // A 有可撤的一步
|
||||
expect(editor.canUndo()).toBe(true);
|
||||
|
||||
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'BBB');
|
||||
expect(editor.view.state.doc.toString()).toBe('BBB');
|
||||
// 关键断言:不能把 A 的编辑 history 带进 B(否则 ↶ 在新文件上亮着,
|
||||
// 按下去会退回 A 的内容 —— 旧实现的跨文件撤销 bug)
|
||||
expect(editor.canUndo()).toBe(false);
|
||||
expect(editor.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it('切文件后在新文件里编辑 → 撤销只回到新文件初始内容', () => {
|
||||
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'AAA');
|
||||
type(editor, '!!!');
|
||||
editor.loadFile({ path: '/b.md', name: 'b.md' }, 'BBB');
|
||||
type(editor, '?');
|
||||
editor.undo();
|
||||
expect(editor.view.state.doc.toString()).toBe('BBB');
|
||||
});
|
||||
|
||||
it('loadFile 切文件不触发 onChange(setState 非 transaction,不 fire updateListener)', () => {
|
||||
// 2026-10 语义变更:旧实现「dispatch 替换 doc」每次切文件都会伪报一次
|
||||
// onChange(触发多余的 live preview / auto-save 空转)。setState 重建 state
|
||||
// 不派发事务 → onChange 不跑;脏态复位由 openFile 尾部显式完成。
|
||||
const onChange = vi.fn();
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
const ed = new MarkdownEditor({ element: el, onChange, onSave: vi.fn() });
|
||||
ed.loadFile({ path: '/a.md', name: 'a.md' }, 'AAA'); // 首次建 view:0 次
|
||||
ed.loadFile({ path: '/b.md', name: 'b.md' }, 'BBB'); // setState 复用:0 次
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -306,11 +306,16 @@ describe('MarkdownEditor 单实例 view 持久化 (audit R7b P1-18)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('loadFile 重复同一文件:onChange 触发次数精确等于 dispatch 次数(listener 不重复挂)', () => {
|
||||
it('loadFile 重复同一文件:不触发 onChange(setState 语义),listener 不重复挂', () => {
|
||||
// 回归锚点:旧实现每次 loadFile 都 addEventListener 三次(compositionstart
|
||||
// / end / cancel),但 dispose 才 remove —— contentDOM 上 listener 数量
|
||||
// 线性增长。N 次切文件 → onChange 被触发 3N 次。新实现下 view 不重建,
|
||||
// listener 只一份,N 次 loadFile 正好 N 次 onChange。
|
||||
// 线性增长,N 次切文件 → onChange 被触发 3N 次。单实例化后 listener 只一份,
|
||||
// 当时的断言是「N 次 loadFile 正好 N 次 onChange」。
|
||||
// 2026-10 撤销/重做引入时复用路径再进化:从「dispatch 替换 doc」改为
|
||||
// 「setState 重建 state」——把上一个文件的编辑 history 清零(否则切到 B 后
|
||||
// Ctrl+Z 会退回 A 的内容,#btn-undo 也会在刚打开的文件上误亮)。
|
||||
// setState 不派发 transaction → 一次 onChange 都不该有:加载 = 导航不是编辑,
|
||||
// 脏态复位由 openFile 尾部显式完成。
|
||||
const { editor, onChange } = createEditor();
|
||||
editor.loadFile({ path: '/a.md', name: 'a.md' }, 'A');
|
||||
const baseCount = onChange.mock.calls.length;
|
||||
@@ -318,7 +323,7 @@ describe('MarkdownEditor 单实例 view 持久化 (audit R7b P1-18)', () => {
|
||||
editor.loadFile({ path: '/a.md', name: 'a.md' }, `A${i}`);
|
||||
}
|
||||
const delta = onChange.mock.calls.length - baseCount;
|
||||
expect(delta).toBe(5); // 精确 5,不是 10 / 15 / ...
|
||||
expect(delta).toBe(0); // 不是 5 / 10 / 15 —— setState 完全不触发
|
||||
});
|
||||
|
||||
it('A→B→A 场景下 lastSavedDoc 跟踪最终内容', () => {
|
||||
|
||||
@@ -93,6 +93,12 @@ describe('ALLOWED_URI_REGEXP 单测', () => {
|
||||
['image:001.jpg', true],
|
||||
['screenshot:1.png', true],
|
||||
['dir/sub:dir/file.png', true],
|
||||
// 回归(37664cb 重构引入):分支 B 段字符类曾排除 `#`,把「文件#锚点」
|
||||
// 跨文件跳转形态整类误拒(app.js resolveLocalFile 消费 # 后缀)。
|
||||
['foo/bar#baz', true],
|
||||
['page.md#chapter', true],
|
||||
['sprite.svg#icon-home', true],
|
||||
['notes/page.md#sec', true],
|
||||
// 即使危险 scheme 后只接字母(无 `/`),正则仍拒绝:顶层 `(?!)` 拦掉。
|
||||
['javascript:foo', false],
|
||||
['vbscript:foo', false],
|
||||
@@ -101,6 +107,13 @@ describe('ALLOWED_URI_REGEXP 单测', () => {
|
||||
['vbscript:msgbox(1)', false],
|
||||
['data:text/html,xxx', false],
|
||||
['chrome-extension://x/y', false],
|
||||
// 分支 A 收紧:旧版不要求「冒号后带点扩展名」,`scheme:无点token` 整类
|
||||
// 放行(blob: / chrome: / ms-msdt: …),白名单被稀释成 4 scheme 黑名单。
|
||||
// 现在恢复默认拒绝;仅剩主进程导航守卫兜底的状态不复存在。
|
||||
['blob:x', false],
|
||||
['chrome:x', false],
|
||||
['ms-msdt:x', false],
|
||||
['view-source:x', false],
|
||||
// 协议相对 URL:// 或 \\ 开头会被浏览器解析为「继承当前 scheme」,
|
||||
// 攻击面是「静默导航到外站 / 把用户隐私发给 evil.com」。
|
||||
// 必须与绝对路径 /abs/x.png 区分开 —— 单 / 放行,双 / 拒绝。
|
||||
|
||||
@@ -220,12 +220,13 @@ describe('formatRelativeTime', () => {
|
||||
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('≥ 7d 且同年 → 紧凑 "MM-DD HH:mm"', () => {
|
||||
const old = new Date(2024, 5, 1, 12, 0, 0).getTime(); // now 也在 2024-06
|
||||
expect(formatRelativeTime(old, now)).toBe('06-01 12:00');
|
||||
});
|
||||
it('≥ 7d 且跨年 → "YYYY-MM-DD"(保年份,舍时分)', () => {
|
||||
const old = new Date(2023, 5, 1, 12, 0, 0).getTime();
|
||||
expect(formatRelativeTime(old, now)).toBe('2023-06-01');
|
||||
});
|
||||
it('未来时间 → 当成 0 差("刚刚")', () => {
|
||||
expect(formatRelativeTime(now + 5_000, now)).toBe('刚刚');
|
||||
|
||||
Reference in New Issue
Block a user