M0-M11 狩夜人笔记初始实现:11里程碑+86测试
This commit is contained in:
Vendored
+35
@@ -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
@@ -0,0 +1,12 @@
|
||||
# 引用
|
||||
|
||||
> 一层引用
|
||||
|
||||
> > 二层嵌套引用
|
||||
|
||||
> 引用内含列表:
|
||||
> - 项一
|
||||
> - 项二
|
||||
|
||||
> 引用内含代码:
|
||||
> `inline code`
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
# 硬换行
|
||||
|
||||
双空格硬换行第一行
|
||||
第二行。
|
||||
|
||||
反斜杠硬换行第一行\
|
||||
第二行。
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# 代码块
|
||||
|
||||
```js
|
||||
const x = 1; // 行内注释
|
||||
```
|
||||
|
||||
无语言代码块:
|
||||
|
||||
```
|
||||
plain text
|
||||
```
|
||||
|
||||
嵌套围栏:外层 4 反引号包裹内层 3 反引号:
|
||||
|
||||
````
|
||||
```js
|
||||
inner code
|
||||
```
|
||||
````
|
||||
|
||||
行内代码 `含 * 星号不解析`。
|
||||
Vendored
+29
@@ -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
@@ -0,0 +1,13 @@
|
||||
# 嵌套强调
|
||||
|
||||
**外层粗体 *内层斜体* 收尾**
|
||||
|
||||
***粗斜合一***
|
||||
|
||||
*斜体中含 **粗体** 嵌套*
|
||||
|
||||
**粗体中含 *斜体* 嵌套**
|
||||
|
||||
两端星号与下划线混用:_下划线斜体_ 与 *星号斜体*。
|
||||
|
||||
不配对的孤立星号 * 不影响其余文本。
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# 反斜杠转义
|
||||
|
||||
这里 \*不是斜体\* 而是 literal 星号。
|
||||
|
||||
这里 \`不是代码\` 而是 literal 反引号。
|
||||
|
||||
反斜杠本身:\\ 输出一个反斜杠。
|
||||
|
||||
反斜杠转义井号:\# 不是标题。
|
||||
|
||||
反斜杠转义句点:1\. 不是有序列表。
|
||||
|
||||
反斜杠转义管道:a \| b 在表格外应保留。
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
# 一级标题
|
||||
|
||||
## 二级标题
|
||||
|
||||
### 三级标题
|
||||
|
||||
#### 四级标题
|
||||
|
||||
##### 五级标题
|
||||
|
||||
###### 六级标题
|
||||
|
||||
带尾随井号的标题 ##
|
||||
|
||||
> 仅 ATX 标题;Setext 是否支持由 spike 决断。
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# 裸 HTML 处置
|
||||
|
||||
这是一段含 <strong>HTML</strong> 的文本。
|
||||
|
||||
<div>块级 HTML</div>
|
||||
|
||||
<!-- 注释 -->
|
||||
|
||||
> 期望:schema 禁用裸 HTML;spike 决断为转义为文本、代码围栏保留或透传。本 fixture 用于锁定该决断的往返行为。
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
# 图片
|
||||
|
||||

|
||||
|
||||
无 title 图片 
|
||||
|
||||
图片链接 [](https://example.com/full)
|
||||
Vendored
+11
@@ -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
@@ -0,0 +1,18 @@
|
||||
# 嵌套列表
|
||||
|
||||
- 无序一
|
||||
- 子项 a
|
||||
- 孙项 i
|
||||
- 子项 b
|
||||
- 无序二
|
||||
|
||||
1. 有序一
|
||||
1. 子序 1
|
||||
2. 子序 2
|
||||
2. 有序二
|
||||
|
||||
松散列表(项间空行):
|
||||
|
||||
- 项一
|
||||
|
||||
- 项二
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
# 数学公式
|
||||
|
||||
行内公式 $E = mc^2$ 嵌于文本。
|
||||
|
||||
块级公式:
|
||||
|
||||
$$
|
||||
\int_0^1 x^2 \, dx = \frac{1}{3}
|
||||
$$
|
||||
|
||||
> 依赖 remark-math;spike 确认序列化保留 $ 与 $$ 围栏。
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# GFM 表格
|
||||
|
||||
| 左对齐 | 居中 | 右对齐 |
|
||||
|:---|:---:|---:|
|
||||
| a | b | c |
|
||||
| d | e | f |
|
||||
|
||||
含特殊字符的单元格:
|
||||
|
||||
| 符号 | 值 |
|
||||
|---|---|
|
||||
| pipe \| 转义 | 1 |
|
||||
| *斜* 单元 | 2 |
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
# 任务列表
|
||||
|
||||
- [x] 已完成项
|
||||
- [ ] 未完成项
|
||||
- [ ] 另一个未完成
|
||||
|
||||
有序任务:
|
||||
|
||||
1. [x] 第一
|
||||
2. [ ] 第二
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# 分割线
|
||||
|
||||
---
|
||||
|
||||
***
|
||||
|
||||
___
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# 空白与空行
|
||||
|
||||
一段文本。
|
||||
|
||||
|
||||
|
||||
连续多个空行后的段落应保留为单一段落分隔。
|
||||
|
||||
尾部带两个空格的行
|
||||
应为硬换行延续到本行。
|
||||
|
||||
行首缩进三空格不影响段落。
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 侧回退文件名
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user