longwind1984_galaxy-view/tests/noteFilter.test.ts
Rick Si b9ef2e4a0c feat: 过滤改为可点的文件夹图例,并修掉配色撞色 (#11)
推翻上一版的产品判断。上一版把 #11 提出者的**机制**(`-file:"Index"` 语法)当成了
**目的**,做了个文本框——在一个主张「看见你的库」的 3D 星图里,让用户背 `-file:`
语法是最反视觉的答案。而 PR #8 的 Tag Lens 早就把对的范式递过来了:点 chip、不打字。

改为:
- **主:可点的文件夹图例**。列出顶层文件夹(真实节点色+笔记数),点击切显隐、
  hover 出「只看」、标题右侧「全部显示」还原。节点一直按顶层文件夹上色,但面板
  从来没暴露过图例——用户看见一团团彩色,既不知道什么意思也没法对它做任何事。
  图例做成可点的,一个东西同时回答「这个颜色什么意思」和「只给我看这块」,零语法。
- **次:文本查询降级为折叠的逃生口**(+按名字过滤)。只留给图例表达不了的横切
  模式——散落在所有文件夹里的 Index,正是提出者的原始场景。解析器与测试原样保留。
- 三个开关(未解析/孤儿/标签)仍与图例同住:它们回答的是同一个问题。

顺带修掉一个既有缺陷(Rick 拍板随图例一起修):
回退色相原本是 `HUES[hash32(folder) % 9]`——与文件夹大小无关且乱序。实测 Rick 的库
(14 个顶层文件夹 / 9 个导入配色组):99Archive(545) / 90故纸堆(86) / Readwise(68)
撞成同一个蓝,合计 1184 篇=全库 37% 落在读不出区别的颜色上。图例会把这个缺陷直接
摆到用户眼前,故一起修:① 色相按笔记数排名发;② 只发给没被 colorGroups 吃掉的文件夹
(Rick 的 9 个导入组不占槽位 → 剩 5 个正好各拿一个独立色相)。>9 个待发时仍回收色轮,
但撞的是最小的几个而非最大的。

实现要点:
- `noteFilter` 扩成 NoteFilter{hiddenFolders, query},图例与文本框是 AND
- `folderStats` 由**未过滤**的全量文件算——否则点灭一个文件夹会让其他 chip 数字跳
- 图例顺序=笔记数降序,同数按名字定序(不稳定的话每次重建 chip 会跳)
- `assignFolderHues` 必须在 colorFn 生效前跑完;6 处 setColorFn 收口成 applyColorFn
- 图例取色走 colorFn 探针而非自己算——图例跟图对不上就不是图例是装饰

验证:77 测试通过(新增 palette 9 个含真实库回归、noteFilter 图例 6 个);tsc 通过;
lint 0 error;已部署 dev-vault。
⚠️ 面板渲染仍未眼验(computer-use 授权被拒),需 Rick 手动确认。
⚠️ 修正:demo 里先前标的 hash 回退色值是我用 python 朴素 HSL→RGB 算的,与 three 实际
不符(setHSL 在线性空间算再转 sRGB)。撞色结论不变,色值已按 three 实测更正。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:32:25 +08:00

141 lines
5.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

import { describe, expect, it } from 'vitest';
import { applyFilter, folderStats, matchesFilter, parseFilterQuery } from '../src/data/noteFilter';
const rec = (path: string) => ({ path, basename: path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/, '') });
const VAULT = [
rec('Daily/2026-07-15.md'),
rec('Daily/Index.md'),
rec('Projects/Galaxy View.md'),
rec('Projects/Index.md'),
rec('Archive/Old Index Notes.md'),
rec('star wars notes.md'),
];
const run = (q: string) => applyFilter(VAULT, { hiddenFolders: new Set<string>(), query: parseFilterQuery(q) }).map((f) => f.path);
const runHidden = (folders: string[]) => applyFilter(VAULT, { hiddenFolders: new Set(folders), query: [] }).map((f) => f.path);
describe('parseFilterQuery', () => {
it('treats a bare word as a path match', () => {
expect(parseFilterQuery('Index')).toEqual([{ field: 'path', value: 'index', negate: false }]);
});
it('parses field prefixes and negation', () => {
expect(parseFilterQuery('-file:Index')).toEqual([{ field: 'file', value: 'index', negate: true }]);
expect(parseFilterQuery('path:Daily')).toEqual([{ field: 'path', value: 'daily', negate: false }]);
});
it('keeps spaces inside quotes as one term', () => {
expect(parseFilterQuery('"star wars"')).toEqual([{ field: 'path', value: 'star wars', negate: false }]);
expect(parseFilterQuery('-file:"Old Index"')).toEqual([{ field: 'file', value: 'old index', negate: true }]);
});
it('splits multiple terms on whitespace', () => {
expect(parseFilterQuery(' file:a -path:b ')).toEqual([
{ field: 'file', value: 'a', negate: false },
{ field: 'path', value: 'b', negate: true },
]);
});
it('degrades unknown prefixes to a literal bare term rather than erroring', () => {
// 过滤框是边打边用的,中间态必然不合法——不能抛错
expect(parseFilterQuery('content:foo')).toEqual([{ field: 'path', value: 'content:foo', negate: false }]);
});
it('drops terms that carry no value', () => {
expect(parseFilterQuery('-')).toEqual([]);
expect(parseFilterQuery('file:')).toEqual([]);
expect(parseFilterQuery(' ')).toEqual([]);
});
it('reads an unclosed quote to the end instead of dropping the term', () => {
expect(parseFilterQuery('"star wars')).toEqual([{ field: 'path', value: 'star wars', negate: false }]);
});
});
describe('applyFilter', () => {
it('returns the array untouched when nothing is filtered', () => {
expect(applyFilter(VAULT, { hiddenFolders: new Set<string>(), query: [] })).toBe(VAULT); // 同一引用 = 没有无谓复制
});
it('includes only matches for a positive filename term (issue #11 example)', () => {
expect(run('file:"Index"')).toEqual(['Daily/Index.md', 'Projects/Index.md', 'Archive/Old Index Notes.md']);
});
it('excludes matches for a negative filename term (issue #11 example)', () => {
expect(run('-file:"Index"')).toEqual(['Daily/2026-07-15.md', 'Projects/Galaxy View.md', 'star wars notes.md']);
});
it('matches folder names via a bare term, since path includes them', () => {
expect(run('Daily')).toEqual(['Daily/2026-07-15.md', 'Daily/Index.md']);
});
it('is case-insensitive', () => {
expect(run('file:index')).toEqual(run('file:INDEX'));
});
it('ANDs multiple terms', () => {
expect(run('Index -path:Daily')).toEqual(['Projects/Index.md', 'Archive/Old Index Notes.md']);
});
it('distinguishes file: from path: (folder name must not satisfy file:)', () => {
expect(run('path:Projects')).toEqual(['Projects/Galaxy View.md', 'Projects/Index.md']);
expect(run('file:Projects')).toEqual([]);
});
it('matches a quoted phrase containing spaces', () => {
expect(run('"star wars"')).toEqual(['star wars notes.md']);
});
it('can exclude everything without throwing', () => {
expect(run('file:Index -file:Index')).toEqual([]);
});
});
describe('matchesFilter', () => {
it('passes any record when the query is empty', () => {
expect(matchesFilter(rec('anything.md'), [])).toBe(true);
});
});
describe('folderStats图例数据', () => {
it('按笔记数降序,根目录笔记归入 ""(同数时按名字,空串在前)', () => {
expect(folderStats(VAULT)).toEqual([
{ folder: 'Daily', count: 2 },
{ folder: 'Projects', count: 2 },
{ folder: '', count: 1 },
{ folder: 'Archive', count: 1 },
]);
});
it('数量相同时按名字定序——图例顺序必须稳定,否则每次重建 chip 会跳', () => {
const a = folderStats(VAULT).map((f) => f.folder);
const b = folderStats([...VAULT].reverse()).map((f) => f.folder);
expect(a).toEqual(b);
});
});
describe('文件夹显隐(图例点击)', () => {
it('点灭一个文件夹 → 它的笔记全部消失', () => {
expect(runHidden(['Daily'])).toEqual([
'Projects/Galaxy View.md',
'Projects/Index.md',
'Archive/Old Index Notes.md',
'star wars notes.md',
]);
});
it('「只看 Projects」= 点灭其余全部', () => {
expect(runHidden(['Daily', 'Archive', ''])).toEqual(['Projects/Galaxy View.md', 'Projects/Index.md']);
});
it('根目录笔记可单独点灭(键是空串,不能被当成 falsy 忽略)', () => {
expect(runHidden([''])).not.toContain('star wars notes.md');
expect(runHidden([''])).toHaveLength(5);
});
it('图例与文本框是 ANDProjects 里排除 Index', () => {
const out = applyFilter(VAULT, { hiddenFolders: new Set(['Daily', 'Archive', '']), query: parseFilterQuery('-file:Index') });
expect(out.map((f) => f.path)).toEqual(['Projects/Galaxy View.md']);
});
});