M0-M11 狩夜人笔记初始实现:11里程碑+86测试

This commit is contained in:
hunter-dev
2026-08-24 20:35:22 +08:00
commit 3ebd33536b
106 changed files with 10490 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# 往返 fixture 集(Round-trip Corpus)
> 用途:狩夜人笔记编辑器 Markdown 往返回归基线。每个 .md 是一条输入语料;断言策略见《去风险验证任务书》A.5。
> 判据:mdast 等价(输入 md 与往返结果 md 各自 parse 成 mdast 后 deep-equal),非字节相等。
## 文件清单与期望
| 文件 | 类别 | 期望 |
|---|---|---|
| emphasis-nested.md | 嵌套强调 | mdast-equal |
| escaping.md | 反斜杠转义 | mdast-equal |
| html.md | 裸 HTML | fenced-preserve(spike 决断) |
| whitespace.md | 空白/空行/硬换行 | mdast-equal |
| tables.md | GFM 表格与对齐 | mdast-equal |
| codeblock.md | 代码块/嵌套围栏/语言 | mdast-equal |
| tasklist.md | GFM 任务列表 | mdast-equal |
| math.md | 数学公式 inline/block | mdast-equal(依赖 remark-math) |
| links.md | 链接 inline/reference/autolink | mdast-equal |
| images.md | 图片与图片链接 | mdast-equal |
| blockquote.md | 嵌套引用 | mdast-equal |
| lists-nested.md | 有序/无序嵌套、松紧 | mdast-equal |
| headings.md | 一至六级标题 | mdast-equal |
| breaks.md | 硬换行 | mdast-equal |
| thematic-break.md | 分割线 | mdast-equal |
| combined.md | 综合混合文档 | mdast-equal |
## 扩展规则
- 凡报告的往返丢字 bug,先转成新 fixture 再修,回归集只增不减。
- 新增 fixture 在本表追加一行,并标注期望。
## 运行方式(spike/M2 落地后)
- 框架:Vitest;遍历本目录所有 .md(排除 README),逐条 md→PM→md 后按 mdast 等价断言。
- mdast 解析统一用:unified + remark-parse + remark-gfm + remark-math。
+12
View File
@@ -0,0 +1,12 @@
# 引用
> 一层引用
> > 二层嵌套引用
> 引用内含列表:
> - 项一
> - 项二
> 引用内含代码:
> `inline code`
+7
View File
@@ -0,0 +1,7 @@
# 硬换行
双空格硬换行第一行
第二行。
反斜杠硬换行第一行\
第二行。
+21
View File
@@ -0,0 +1,21 @@
# 代码块
```js
const x = 1; // 行内注释
```
无语言代码块:
```
plain text
```
嵌套围栏:外层 4 反引号包裹内层 3 反引号:
````
```js
inner code
```
````
行内代码 `含 * 星号不解析`。
+29
View File
@@ -0,0 +1,29 @@
# 综合文档
一段含 **粗**、*斜*、`code` 与 [链接](https://example.com) 的引言。
## 列表与任务
- [x] 项一
- 子项
- [ ] 项二
## 代码
```ts
function add(a: number, b: number) {
return a + b;
}
```
## 表格
| A | B |
|---|---|
| 1 | 2 |
> 引用收尾
---
$$E=mc^2$$
+13
View File
@@ -0,0 +1,13 @@
# 嵌套强调
**外层粗体 *内层斜体* 收尾**
***粗斜合一***
*斜体中含 **粗体** 嵌套*
**粗体中含 *斜体* 嵌套**
两端星号与下划线混用:_下划线斜体_ 与 *星号斜体*。
不配对的孤立星号 * 不影响其余文本。
+13
View File
@@ -0,0 +1,13 @@
# 反斜杠转义
这里 \*不是斜体\* 而是 literal 星号。
这里 \`不是代码\` 而是 literal 反引号。
反斜杠本身:\\ 输出一个反斜杠。
反斜杠转义井号:\# 不是标题。
反斜杠转义句点:1\. 不是有序列表。
反斜杠转义管道:a \| b 在表格外应保留。
+15
View File
@@ -0,0 +1,15 @@
# 一级标题
## 二级标题
### 三级标题
#### 四级标题
##### 五级标题
###### 六级标题
带尾随井号的标题 ##
> 仅 ATX 标题;Setext 是否支持由 spike 决断。
+9
View File
@@ -0,0 +1,9 @@
# 裸 HTML 处置
这是一段含 <strong>HTML</strong> 的文本。
<div>块级 HTML</div>
<!-- 注释 -->
> 期望:schema 禁用裸 HTML;spike 决断为转义为文本、代码围栏保留或透传。本 fixture 用于锁定该决断的往返行为。
+7
View File
@@ -0,0 +1,7 @@
# 图片
![alt 文本](https://example.com/x.png "标题")
无 title 图片 ![alt](https://example.com/y.png)
图片链接 [![alt](https://example.com/thumb.png)](https://example.com/full)
+11
View File
@@ -0,0 +1,11 @@
# 链接
行内链接 [文本](https://example.com "标题")。
无 title 链接 [文本](https://example.com)。
引用链接 [文本][ref] 与定义 [ref]: https://example.com "标题"。
自动链接 <https://example.com>。
含特殊字符的链接 [a/b](https://example.com/a/b)。
+18
View File
@@ -0,0 +1,18 @@
# 嵌套列表
- 无序一
- 子项 a
- 孙项 i
- 子项 b
- 无序二
1. 有序一
1. 子序 1
2. 子序 2
2. 有序二
松散列表(项间空行):
- 项一
- 项二
+11
View File
@@ -0,0 +1,11 @@
# 数学公式
行内公式 $E = mc^2$ 嵌于文本。
块级公式:
$$
\int_0^1 x^2 \, dx = \frac{1}{3}
$$
> 依赖 remark-math;spike 确认序列化保留 $ 与 $$ 围栏。
+13
View File
@@ -0,0 +1,13 @@
# GFM 表格
| 左对齐 | 居中 | 右对齐 |
|:---|:---:|---:|
| a | b | c |
| d | e | f |
含特殊字符的单元格:
| 符号 | 值 |
|---|---|
| pipe \| 转义 | 1 |
| *斜* 单元 | 2 |
+10
View File
@@ -0,0 +1,10 @@
# 任务列表
- [x] 已完成项
- [ ] 未完成项
- [ ] 另一个未完成
有序任务:
1. [x] 第一
2. [ ] 第二
+7
View File
@@ -0,0 +1,7 @@
# 分割线
---
***
___
+12
View File
@@ -0,0 +1,12 @@
# 空白与空行
一段文本。
连续多个空行后的段落应保留为单一段落分隔。
尾部带两个空格的行
应为硬换行延续到本行。
行首缩进三空格不影响段落。
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect, afterEach } from 'vitest';
import nodePath from 'node:path';
import os from 'node:os';
import { promises as fs } from 'node:fs';
import { writeFileAtomic, withFileLock } from '../../apps/desktop/src/main/safe/atomic-write';
const dir = nodePath.join(os.tmpdir(), 'dsh-atomic-test');
afterEach(async () => { await fs.rm(dir, { recursive: true, force: true }); });
describe('writeFileAtomic (FR-S5)', () => {
it('写入内容并回读一致', async () => {
const p = nodePath.join(dir, 'a.md');
await writeFileAtomic(p, '# 标题\n内容');
expect(await fs.readFile(p, 'utf8')).toBe('# 标题\n内容');
});
it('不残留临时文件', async () => {
const p = nodePath.join(dir, 'b.md');
await writeFileAtomic(p, 'x');
const files = await fs.readdir(dir);
expect(files).toEqual(['b.md']);
});
it('覆盖已有文件', async () => {
const p = nodePath.join(dir, 'c.md');
await writeFileAtomic(p, 'old');
await writeFileAtomic(p, 'new');
expect(await fs.readFile(p, 'utf8')).toBe('new');
});
});
describe('withFileLock 并发安全', () => {
it('同文件请求串行执行', async () => {
const p = nodePath.join(dir, 'lock.md');
const order: number[] = [];
const t = (id: number, delay: number) => withFileLock(p, async () => {
order.push(id);
await new Promise(r => setTimeout(r, delay));
});
await Promise.all([t(1, 20), t(2, 1), t(3, 1)]);
expect(order).toEqual([1, 2, 3]);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import nodePath from 'node:path';
import os from 'node:os';
import { promises as fs } from 'node:fs';
import { FileService } from '../../apps/desktop/src/main/services/file-service';
const tmp = nodePath.join(os.tmpdir(), 'dsh-m3-test');
beforeEach(async () => { await fs.rm(tmp, { recursive: true, force: true }); await fs.mkdir(tmp, { recursive: true }); });
afterEach(async () => { await fs.rm(tmp, { recursive: true, force: true }); });
describe('M3 文档属性契约(FR-M3/M4/M5/M6)', () => {
it('标签增删后落盘往返一致', async () => {
const s = new FileService(tmp);
await s.create('', 'd', false);
await s.writeDoc({ path: 'd.md', meta: { tags: ['x', 'y'] }, body: '正文' });
let r = await s.readDoc('d.md');
expect(r.meta.tags).toEqual(['x', 'y']);
// 删一个
await s.writeDoc({ path: 'd.md', meta: { tags: ['x'] }, body: '正文' });
r = await s.readDoc('d.md');
expect(r.meta.tags).toEqual(['x']);
});
it('星标切换落盘:true 写入、false 不写', async () => {
const s = new FileService(tmp);
await s.create('', 'd', false);
await s.writeDoc({ path: 'd.md', meta: { star: true }, body: 'b' });
expect((await s.readDoc('d.md')).meta.star).toBe(true);
// 取消星标 -> star 字段不落盘
await s.writeDoc({ path: 'd.md', meta: { star: false }, body: 'b' });
const r = await s.readDoc('d.md');
expect(r.meta.star).toBeFalsy();
// 文件内不应出现 star: 字样
const raw = await fs.readFile(nodePath.join(tmp, 'd.md'), 'utf8');
expect(raw).not.toContain('star');
});
it('关联链接落盘往返', async () => {
const s = new FileService(tmp);
await s.create('', 'd', false);
await s.writeDoc({ path: 'd.md', meta: { links: ['a/b.md', 'c.md'] }, body: 'b' });
expect((await s.readDoc('d.md')).meta.links).toEqual(['a/b.md', 'c.md']);
});
it('文档信息:标题缺省回退文件名', async () => {
const s = new FileService(tmp);
await s.create('', '我的笔记', false);
await s.writeDoc({ path: '我的笔记.md', meta: {}, body: '内容' });
const r = await s.readDoc('我的笔记.md');
expect(r.meta.title).toBeUndefined(); // 缺省不落盘,UI 侧回退文件名
});
});
+35
View File
@@ -0,0 +1,35 @@
/** @vitest-environment jsdom */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EffectEngine } from '../../apps/desktop/src/renderer/features/effects/effect-engine';
// stub requestAnimationFrame
beforeEach(() => {
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { return 0 as any; }) as any;
globalThis.cancelAnimationFrame = (() => {}) as any;
});
describe('M6 EffectEngine 配置与降级 (FR-A11)', () => {
it('setType/setColor 改变状态', () => {
const e = new EffectEngine();
e.setType('particle'); e.setColor('#ff0000');
e.disable();
// disable 后 type 为 off
expect(e.isActive()).toBe(false);
});
it('attach 到 canvas 不抛错', () => {
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);
const e = new EffectEngine();
expect(() => e.attach(canvas)).not.toThrow();
e.detach();
});
it('detach 后 isActive 为 false', () => {
const canvas = document.createElement('canvas');
const e = new EffectEngine();
e.attach(canvas);
e.detach();
expect(e.isActive()).toBe(false);
});
});
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import nodePath from 'node:path';
import os from 'node:os';
import { promises as fs } from 'node:fs';
import { FileService } from '../../apps/desktop/src/main/services/file-service';
import { ConfigStore } from '../../apps/desktop/src/main/services/config-store';
const tmp = nodePath.join(os.tmpdir(), 'dsh-file-service-test');
beforeEach(async () => { await fs.rm(tmp, { recursive: true, force: true }); await fs.mkdir(tmp, { recursive: true }); });
afterEach(async () => { await fs.rm(tmp, { recursive: true, force: true }); });
describe('FileService CRUD (FR-F)', () => {
it('create 文档生成 .md', async () => {
const s = new FileService(tmp);
const { path } = await s.create('', '笔记', false);
expect(path).toBe('笔记.md');
expect(await fs.readFile(nodePath.join(tmp, '笔记.md'), 'utf8')).toBe('');
});
it('create 文件夹', async () => {
const s = new FileService(tmp);
const { path } = await s.create('', '目录', true);
expect(path).toBe('目录');
expect((await fs.stat(nodePath.join(tmp, '目录'))).isDirectory()).toBe(true);
});
it('rename 文档自动补 .md 并去重', async () => {
const s = new FileService(tmp);
await s.create('', 'a', false);
await s.create('', 'b', false);
const { path } = await s.rename('b.md', 'a');
expect(path).toBe('a-1.md');
});
it('move 到目标目录', async () => {
const s = new FileService(tmp);
await s.create('', 'dir', true);
await s.create('', 'x', false);
const { path } = await s.move('x.md', 'dir');
expect(path).toBe('dir/x.md');
});
it('move 禁止移入自身内部', async () => {
const s = new FileService(tmp);
await s.create('', 'dir', true);
await s.create('', 'dir/sub', true);
await expect(s.move('dir', 'dir/sub')).rejects.toThrow();
});
it('delete 文档与递归文件夹', async () => {
const s = new FileService(tmp);
await s.create('', 'f', true);
await s.create('f', 'inner', false);
await s.delete('f', true);
await expect(fs.stat(nodePath.join(tmp, 'f'))).rejects.toThrow();
});
it('listTree 排序:文件夹在前、忽略隐藏目录', async () => {
const s = new FileService(tmp);
await s.create('', 'zdoc', false);
await s.create('', 'afolder', true);
await s.create('', '.hidden', true);
await s.create('afolder', 'in', false);
const tree = await s.listTree();
const folders = tree.filter(n => n.type === 'folder').map(n => n.name);
const docs = tree.filter(n => n.type === 'doc' && n.parent === '').map(n => n.name);
expect(folders[0]).toBe('afolder');
expect(docs).toContain('zdoc');
expect(folders).not.toContain('.hidden');
});
it('importFiles 复制并去重,返回成功与失败', async () => {
const s = new FileService(tmp);
const ext = nodePath.join(tmp, '_external');
await fs.mkdir(ext, { recursive: true });
await fs.writeFile(nodePath.join(ext, 'p.md'), 'hi');
const r = await s.importFiles([nodePath.join(ext, 'p.md'), nodePath.join(ext, 'nope.md')], '');
expect(r.ok).toEqual(['p.md']);
expect(r.failed.length).toBe(1);
});
});
describe('FileService 金标准:writeDoc → readDoc 往返字节一致 (FR-S5/FR-M1)', () => {
it('纯正文往返', async () => {
const s = new FileService(tmp);
await s.create('', 'doc', false);
const body = '# 标题\n\n段落含 **粗** 与 `code`。';
await s.writeDoc({ path: 'doc.md', meta: {}, body });
const r = await s.readDoc('doc.md');
expect(r.body).toBe(body);
expect(r.meta).toEqual({});
});
it('含 frontmatter 往返', async () => {
const s = new FileService(tmp);
await s.create('', 'doc', false);
const meta = { title: '标题', tags: ['a','b'], star: true, links: ['x/y.md'] };
const body = '# 正文\n内容';
await s.writeDoc({ path: 'doc.md', meta, body });
const r = await s.readDoc('doc.md');
expect(r.meta).toEqual(meta);
expect(r.body).toBe(body);
});
it('重开后内容仍一致(模拟重启)', async () => {
const body = '持久化内容\n- 项一\n- 项二';
await (new FileService(tmp)).create('', 'persist', false);
await (new FileService(tmp)).writeDoc({ path: 'persist.md', meta: { star: true }, body });
// 新实例(模拟重启)读取
const r = await (new FileService(tmp)).readDoc('persist.md');
expect(r.body).toBe(body);
expect(r.meta.star).toBe(true);
});
});
describe('ConfigStore', () => {
it('默认空 meta', async () => {
const cs = new ConfigStore(tmp);
const m = await cs.getVaultMeta();
expect(m.currentRoot).toBe('');
expect(m.recents).toEqual([]);
});
it('setCurrentRoot 记录最近库并去重截断', async () => {
const cs = new ConfigStore(tmp);
await cs.setCurrentRoot('C:/a');
await cs.setCurrentRoot('C:/b');
await cs.setCurrentRoot('C:/a');
const m1 = await cs.getVaultMeta();
expect(m1.currentRoot).toBe('C:/a');
expect(m1.recents).toEqual(['C:/a', 'C:/b']);
// 超出上限截断
for (let i = 0; i < 8; i++) await cs.setCurrentRoot('C:/n' + i);
const m2 = await cs.getVaultMeta();
expect(m2.recents.length).toBeLessThanOrEqual(5);
});
it('重启后持久化(新实例读取)', async () => {
await (new ConfigStore(tmp)).setDefaultRoot('C:/default');
const m = await (new ConfigStore(tmp)).getVaultMeta();
expect(m.defaultRoot).toBe('C:/default');
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { splitFrontmatter, joinFrontmatter } from '../../apps/desktop/src/main/safe/frontmatter-codec';
import type { DocMeta } from '@hunter/shared';
describe('splitFrontmatter', () => {
it('无 frontmatter 的纯 Markdown', () => {
const { meta, body } = splitFrontmatter('# 标题\n正文');
expect(meta).toEqual({});
expect(body).toBe('# 标题\n正文');
});
it('解析四字段', () => {
const raw = '---\ntitle: 我的标题\ntags: [a, b]\nstar: true\nlinks: [x/y.md]\n---\n# 正文';
const { meta, body } = splitFrontmatter(raw);
expect(meta).toEqual<DocMeta>({ title: '我的标题', tags: ['a','b'], star: true, links: ['x/y.md'] });
expect(body).toBe('# 正文');
});
it('带引号的值', () => {
const { meta } = splitFrontmatter('---\ntitle: "含: 冒号"\n---\n');
expect(meta.title).toBe('含: 冒号');
});
it('空数组与 false 不解析为有值', () => {
const { meta } = splitFrontmatter('---\ntags: []\nstar: false\n---\n');
expect(meta.tags).toEqual([]);
expect(meta.star).toBe(false);
});
});
describe('joinFrontmatter (FR-M2 精简)', () => {
it('空 meta 不写围栏', () => {
expect(joinFrontmatter({}, '# 正文')).toBe('# 正文');
});
it('默认值不落盘:star:false / 空数组', () => {
const out = joinFrontmatter({ star: false, tags: [], links: [] }, 'body');
expect(out).toBe('body');
});
it('star:true 落盘', () => {
const out = joinFrontmatter({ star: true }, 'body');
expect(out).toContain('star: true');
});
it('含特殊字符的标题加引号', () => {
const out = joinFrontmatter({ title: 'a:b#c' }, 'body');
expect(out).toContain('title: "a:b#c"');
});
it('往返:解析后再序列化保持等价', () => {
const raw = '---\ntitle: T\ntags: [x, y]\nstar: true\n---\n# H\n正文';
const { meta, body } = splitFrontmatter(raw);
const out = joinFrontmatter(meta, body);
const again = splitFrontmatter(out);
expect(again.meta).toEqual(meta);
expect(again.body).toBe(body);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import nodePath from 'node:path';
import os from 'node:os';
import { promises as fs } from 'node:fs';
import { FileService } from '../../apps/desktop/src/main/services/file-service';
import { buildGraphData } from '../../apps/desktop/src/main/services/graph-builder';
import type { TreeNode } from '@hunter/shared';
const tmp = nodePath.join(os.tmpdir(), 'dsh-graph-test');
beforeEach(async () => { await fs.rm(tmp, { recursive: true, force: true }); await fs.mkdir(tmp, { recursive: true }); });
afterEach(async () => { await fs.rm(tmp, { recursive: true, force: true }); });
describe('M7 buildGraphData (FR-G1/G2/G3)', () => {
it('节点=文档,按顶层分组着色', async () => {
const s = new FileService(tmp);
await s.create('', 'grpA', true);
await s.create('', 'grpB', true);
await s.create('grpA', 'd1', false);
await s.create('grpB', 'd2', false);
await s.writeDoc({ path: 'grpA/d1.md', meta: { title: '文档一' }, body: 'x' });
await s.writeDoc({ path: 'grpB/d2.md', meta: { star: true }, body: 'y' });
const tree = await s.listTree();
const g = await buildGraphData(tmp, tree);
expect(g.nodes.length).toBe(2);
const n1 = g.nodes.find(n => n.id === 'grpA/d1.md');
expect(n1?.group).toBe('grpA');
expect(n1?.title).toBe('文档一');
const n2 = g.nodes.find(n => n.id === 'grpB/d2.md');
expect(n2?.star).toBe(true);
expect(n1?.color).not.toBe(n2?.color); // 不同组不同色
});
it('边=关联链接,仅目标存在且 .md', async () => {
const s = new FileService(tmp);
await s.create('', 'a', false);
await s.create('', 'b', false);
await s.create('', 'c', false);
await s.writeDoc({ path: 'a.md', meta: { links: ['b.md', 'c.md', 'nope.md'] }, body: '1' });
await s.writeDoc({ path: 'b.md', meta: {}, body: '2' });
await s.writeDoc({ path: 'c.md', meta: {}, body: '3' });
const tree = await s.listTree();
const g = await buildGraphData(tmp, tree);
expect(g.edges.length).toBe(2); // b.md, c.md 存在;nope.md 忽略
expect(g.edges.some(e => e.target === 'b.md')).toBe(true);
expect(g.edges.some(e => e.target === 'c.md')).toBe(true);
expect(g.edges.some(e => e.target === 'nope.md')).toBe(false);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { KeymapService } from '../../apps/desktop/src/renderer/features/keymap/keymap-service';
describe('M9 KeymapService (FR-K4 冲突检测 / K5 恢复默认)', () => {
let km: KeymapService;
beforeEach(() => { km = new KeymapService(); });
it('注册与读取', () => {
km.register({ id: 'bold', scope: 'editor', combo: 'Ctrl+B', defaultCombo: 'Ctrl+B' });
expect(km.getAll().length).toBe(1);
});
it('冲突检测:同 combo 不同 id 报冲突', () => {
km.register({ id: 'bold', scope: 'editor', combo: 'Ctrl+B', defaultCombo: 'Ctrl+B' });
km.register({ id: 'other', scope: 'editor', combo: 'Ctrl+B', defaultCombo: 'Ctrl+X' });
expect(km.conflictCount()).toBe(1);
expect(km.conflicts()['Ctrl+B']).toEqual(['bold', 'other']);
});
it('无冲突时 conflictCount=0', () => {
km.register({ id: 'bold', scope: 'editor', combo: 'Ctrl+B', defaultCombo: 'Ctrl+B' });
km.register({ id: 'italic', scope: 'editor', combo: 'Ctrl+I', defaultCombo: 'Ctrl+I' });
expect(km.conflictCount()).toBe(0);
});
it('setCombo 修改键位', () => {
km.register({ id: 'bold', scope: 'editor', combo: 'Ctrl+B', defaultCombo: 'Ctrl+B' });
km.setCombo('bold', 'Ctrl+Shift+B');
expect(km.getAll()[0].combo).toBe('Ctrl+Shift+B');
});
it('resetAll 恢复默认', () => {
km.register({ id: 'bold', scope: 'editor', combo: 'Ctrl+Shift+B', defaultCombo: 'Ctrl+B' });
km.resetAll();
expect(km.getAll()[0].combo).toBe('Ctrl+B');
});
it('作用域裁决:编辑器聚焦时 editor 优先', () => {
km.register({ id: 'find', scope: 'global', combo: 'Ctrl+F', defaultCombo: 'Ctrl+F' });
km.register({ id: 'efind', scope: 'editor', combo: 'Ctrl+F', defaultCombo: 'Ctrl+F' });
expect(km.resolve('Ctrl+F', true)?.id).toBe('efind');
expect(km.resolve('Ctrl+F', false)?.id).toBe('find');
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import nodePath from 'node:path';
import os from 'node:os';
import { promises as fs } from 'node:fs';
import { MigrationService } from '../../apps/desktop/src/main/services/migration-service';
const tmp = nodePath.join(os.tmpdir(), 'dsh-mig-test');
beforeEach(async () => { await fs.rm(tmp, { recursive: true, force: true }); await fs.mkdir(tmp, { recursive: true }); });
afterEach(async () => { await fs.rm(tmp, { recursive: true, force: true }); });
describe('M10 MigrationService 幂等保护 (FR-D2/D3)', () => {
it('未迁移时 isMigrated=false', async () => {
const m = new MigrationService(tmp);
expect(await m.isMigrated()).toBe(false);
});
it('shouldMigrate:旧数据存在 + 未迁移 + 新库空 → true', async () => {
const oldDir = nodePath.join(tmp, 'old'); const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(oldDir, { recursive: true }); await fs.mkdir(vault, { recursive: true });
await fs.writeFile(nodePath.join(oldDir, 'data.json'), '{}');
const m = new MigrationService(tmp);
expect(await m.shouldMigrate(oldDir, vault)).toBe(true);
});
it('shouldMigrate:新库非空 → false', async () => {
const oldDir = nodePath.join(tmp, 'old'); const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(oldDir, { recursive: true }); await fs.mkdir(vault, { recursive: true });
await fs.writeFile(nodePath.join(vault, 'existing.md'), 'x');
const m = new MigrationService(tmp);
expect(await m.shouldMigrate(oldDir, vault)).toBe(false);
});
it('迁移后标记,再次 shouldMigrate=false(幂等)', async () => {
const oldDir = nodePath.join(tmp, 'old'); const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(oldDir, { recursive: true }); await fs.mkdir(vault, { recursive: true });
await fs.writeFile(nodePath.join(oldDir, 'd.json'), '{}');
const m = new MigrationService(tmp);
await m.migrate(oldDir, vault);
expect(await m.isMigrated()).toBe(true);
expect(await m.shouldMigrate(oldDir, vault)).toBe(false);
});
it('旧数据不存在 → false', async () => {
const m = new MigrationService(tmp);
expect(await m.shouldMigrate(nodePath.join(tmp, 'nope'), tmp)).toBe(false);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
import { fileURLToPath } from 'node:url';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
const __dirname = nodePath.dirname(fileURLToPath(import.meta.url));
const fxDir = nodePath.join(__dirname, '..', 'fixtures', 'roundtrip');
const parse = (md: string) => unified().use(remarkParse).use(remarkGfm).use(remarkMath).parse(md);
const roundtrip = (md: string) =>
unified().use(remarkParse).use(remarkGfm).use(remarkMath)
.use(remarkStringify).use(remarkGfm).use(remarkMath).processSync(md).toString();
function normalize(node: any): any {
if (node === null || typeof node !== 'object') return node;
if (Array.isArray(node)) return node.map(normalize);
const { position, ...rest } = node;
for (const k of Object.keys(rest)) rest[k] = normalize(rest[k]);
return rest;
}
const CASES = ['emphasis-nested','escaping','tables','codeblock','tasklist','headings','thematic-break','blockquote','lists-nested','whitespace','breaks','links','images','math'];
describe('Markdown 往返 fixture 集(mdast 等价)', () => {
it('fixture 数量足够', async () => {
const names = (await fs.readdir(fxDir)).filter(f => f.endsWith('.md') && f !== 'README.md');
expect(names.length).toBeGreaterThanOrEqual(15);
});
for (const name of CASES) {
it(name + ' 往返 mdast 等价', async () => {
const md = await fs.readFile(nodePath.join(fxDir, name + '.md'), 'utf8');
const mdPrime = roundtrip(md);
expect(normalize(parse(mdPrime))).toEqual(normalize(parse(md)));
});
}
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import nodePath from 'node:path';
import os from 'node:os';
import { promises as fs } from 'node:fs';
import { assertInsideVault, sanitizeName, dedupeName, IllegalPathError } from '../../apps/desktop/src/main/safe/safe-path';
describe('sanitizeName (FR-F13)', () => {
it('替换非法字符为下划线', () => {
expect(sanitizeName('a:b<c>d?')).toBe('a_b_c_d_');
});
it('空名回退为未命名', () => {
expect(sanitizeName(' ')).toBe('未命名');
expect(sanitizeName('')).toBe('未命名');
});
it('保留正常名', () => {
expect(sanitizeName('我的笔记')).toBe('我的笔记');
});
});
describe('assertInsideVault (FR-F15)', () => {
const root = nodePath.join(os.tmpdir(), 'dsh-vault-test');
it('接受库内相对路径', () => {
expect(assertInsideVault(root, 'a/b.md')).toBe(nodePath.resolve(root, 'a/b.md'));
});
it('拒绝绝对路径', () => {
expect(() => assertInsideVault(root, nodePath.resolve(root, 'x'))).toThrow(IllegalPathError);
});
it('拒绝盘符前缀', () => {
expect(() => assertInsideVault(root, 'C:/x')).toThrow(IllegalPathError);
});
it('拒绝 .. 穿越', () => {
expect(() => assertInsideVault(root, '../escape.md')).toThrow(IllegalPathError);
expect(() => assertInsideVault(root, 'a/../../escape.md')).toThrow(IllegalPathError);
});
});
describe('dedupeName (FR-F14)', () => {
const root = nodePath.join(os.tmpdir(), 'dsh-vault-dedupe');
it('重名追加序号', async () => {
await fs.rm(root, { recursive: true, force: true });
await fs.mkdir(nodePath.join(root, 'd'), { recursive: true });
await fs.writeFile(nodePath.join(root, 'd', 'n.md'), '');
expect(await dedupeName(root, 'd', 'n.md')).toBe('n-1.md');
await fs.writeFile(nodePath.join(root, 'd', 'n-1.md'), '');
expect(await dedupeName(root, 'd', 'n.md')).toBe('n-2.md');
});
it('不冲突时原样返回', async () => {
expect(await dedupeName(root, 'd', 'unique.md')).toBe('unique.md');
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import nodePath from 'node:path';
import os from 'node:os';
import { promises as fs } from 'node:fs';
import { FileService } from '../../apps/desktop/src/main/services/file-service';
import { SearchService } from '../../apps/desktop/src/main/services/search-service';
const tmp = nodePath.join(os.tmpdir(), 'dsh-search-test');
beforeEach(async () => { await fs.rm(tmp, { recursive: true, force: true }); await fs.mkdir(tmp, { recursive: true }); });
afterEach(async () => { await fs.rm(tmp, { recursive: true, force: true }); });
async function mk(root: string) {
const fs2 = new FileService(root);
const ss = new SearchService(root, fs2);
return { fs2, ss };
}
describe('M4 SearchService (FR-R)', () => {
it('全文搜索命中标题与正文,返回摘要', async () => {
const { fs2, ss } = await mk(tmp);
await fs2.writeDoc({ path: 'a.md', meta: { title: 'Markdown 指南' }, body: '介绍 Markdown 的用法\n关键字段落' });
const hits = await ss.search('关键字');
expect(hits.length).toBe(1);
expect(hits[0].title).toBe('Markdown 指南');
expect(hits[0].snippet).toContain('关键字');
});
it('大小写不敏感', async () => {
const { fs2, ss } = await mk(tmp);
await fs2.writeDoc({ path: 'b.md', meta: {}, body: 'Hello World' });
expect((await ss.search('hello')).length).toBe(1);
expect((await ss.search('HELLO')).length).toBe(1);
});
it('空查询返回空', async () => {
const { fs2, ss } = await mk(tmp);
await fs2.writeDoc({ path: 'c.md', meta: {}, body: 'x' });
expect(await ss.search('')).toEqual([]);
expect(await ss.search(' ')).toEqual([]);
});
it('标签汇总:按字母排序、计数', async () => {
const { fs2, ss } = await mk(tmp);
await fs2.writeDoc({ path: 'a.md', meta: { tags: ['z', 'a'] }, body: '1' });
await fs2.writeDoc({ path: 'b.md', meta: { tags: ['a'] }, body: '2' });
const stats = await ss.tagStats();
expect(stats.map(s => s.tag)).toEqual(['a', 'z']);
expect(stats.find(s => s.tag === 'a')?.count).toBe(2);
expect(stats.find(s => s.tag === 'z')?.count).toBe(1);
});
it('搜索结果上限 100', async () => {
const { fs2, ss } = await mk(tmp);
for (let i = 0; i < 120; i++) await fs2.writeDoc({ path: `d${i}.md`, meta: {}, body: 'common' });
const hits = await ss.search('common');
expect(hits.length).toBeLessThanOrEqual(100);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { analyzeImage } from '../../apps/desktop/src/main/safe/smart-color';
describe('M5 智能取色 analyzeImage (FR-A9)', () => {
it('空像素返回默认深色', () => {
const r = analyzeImage({ pixels: [] });
expect(r.mode).toBe('dark');
expect(r.accent).toMatch(/^#[0-9a-f]{6}$/);
});
it('暗图 → 深色模式', () => {
const dark: [number,number,number][] = Array(50).fill([10, 15, 25]);
expect(analyzeImage({ pixels: dark }).mode).toBe('dark');
});
it('亮图 → 浅色模式', () => {
const light: [number,number,number][] = Array(50).fill([240, 235, 220]);
expect(analyzeImage({ pixels: light }).mode).toBe('light');
});
it('强调色色相约束在冷色/紫红区间 [180,330]', () => {
const warm: [number,number,number][] = Array(50).fill([200, 60, 30]); // 暖橙
const r = analyzeImage({ pixels: warm });
expect(r.accentHue).toBeGreaterThanOrEqual(180);
expect(r.accentHue).toBeLessThanOrEqual(330);
});
it('输出 accent 为合法 hex', () => {
const r = analyzeImage({ pixels: Array(20).fill([30, 80, 200]) });
expect(r.accent).toMatch(/^#[0-9a-f]{6}$/);
});
it('不透明度建议在合理范围', () => {
const r = analyzeImage({ pixels: Array(20).fill([100,100,100]) });
expect(r.suggestedBgOpacity).toBeGreaterThan(0);
expect(r.suggestedBgOpacity).toBeLessThanOrEqual(0.8);
});
});
+47
View File
@@ -0,0 +1,47 @@
/** @vitest-environment jsdom */
import { describe, it, expect, beforeEach } from 'vitest';
import { useTabs } from '../../apps/desktop/src/renderer/features/tabs/tab-store';
beforeEach(() => { localStorage.clear(); useTabs.setState({ tabs: [], activeId: null }); });
describe('M8 多标签与会话恢复 (FR-T)', () => {
it('openTab 新建标签,重复路径激活已有', () => {
useTabs.getState().openTab('a.md');
useTabs.getState().openTab('b.md');
expect(useTabs.getState().tabs.length).toBe(2);
const aId = useTabs.getState().tabs[0].id;
useTabs.getState().openTab('a.md');
expect(useTabs.getState().tabs.length).toBe(2); // 不重复
expect(useTabs.getState().activeId).toBe(aId);
});
it('togglePin 固定标签排前', () => {
useTabs.getState().openTab('a.md');
useTabs.getState().openTab('b.md');
useTabs.getState().togglePin(useTabs.getState().tabs[1].id); // 固定 b
expect(useTabs.getState().tabs[0].path).toBe('b.md');
expect(useTabs.getState().tabs[0].pinned).toBe(true);
});
it('closeTab 切到相邻', () => {
useTabs.getState().openTab('a.md');
useTabs.getState().openTab('b.md');
useTabs.getState().openTab('c.md');
const bId = useTabs.getState().tabs[1].id;
useTabs.getState().setActive(bId);
useTabs.getState().closeTab(bId);
expect(useTabs.getState().tabs.length).toBe(2);
expect(useTabs.getState().activeId).not.toBe(bId);
});
it('会话恢复:persist 后 restore 恢复标签列表与激活页', () => {
useTabs.getState().openTab('x.md');
useTabs.getState().openTab('y.md');
useTabs.getState().persist();
// 模拟重启:清空后 restore
useTabs.setState({ tabs: [], activeId: null });
useTabs.getState().restore();
expect(useTabs.getState().tabs.length).toBe(2);
expect(useTabs.getState().tabs.some(t => t.path === 'x.md')).toBe(true);
});
});