This commit is contained in:
2026-09-12 13:59:12 +08:00
commit 941ce4baab
71 changed files with 13037 additions and 0 deletions

182
test/echo-suppress.test.js Normal file
View File

@@ -0,0 +1,182 @@
'use strict';
/**
* 回声抑制状态机lib/echo-suppress.js—— 测的是 main.js 实际引用的本体。
*
* 这份逻辑以前内联在 main.js 里旧测试poll-suppress.test.js克隆了一份
* 状态机自己测自己main.js 改坏了测试照样绿,而这是丢数据级的逻辑
* (吞错一次 = 用户的一次真实复制静默消失)。现在抽到 lib测本体。
*
* 每个用例对应一条真实踩坑规则,注释里写了放掉会发生什么。
*/
const assert = require('node:assert');
const { createEchoSuppress } = require('../lib/echo-suppress.js');
let passed = 0;
function check(name, fn) {
try { fn(); console.log(' ok -', name); passed++; }
catch (e) { console.error(' FAIL -', name, '\n', e.message); process.exitCode = 1; }
}
check('启动采样:基线内容第一次见到不算新', () => {
const s = createEchoSuppress();
s.seed('已经在剪贴板里的旧内容');
assert.strictEqual(s.observe('已经在剪贴板里的旧内容'), 'unchanged');
// 放开这条:每次重启都会把剪贴板里的旧内容重复入库一次
assert.strictEqual(s.observe('外部新复制'), 'new');
});
check('自己写进去的回声:下一 tick 不会把它当新内容', () => {
const s = createEchoSuppress();
s.markOwnWrite('A'); // pending=A同时基线预置为 A
// 读到的回声与预置基线一致 → 'unchanged'(同样不入库)。
// suppressed 分支出现在基线与 pending 分叉之后(见下条)。
assert.strictEqual(s.observe('A'), 'unchanged');
assert.strictEqual(s.observe('A'), 'unchanged');
});
check('suppressed 分支:基线与 pending 分叉后,匹配 pending 的内容被吞', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
// 外部中途清空剪贴板:基线变 ''pending 不被消化
assert.strictEqual(s.observe(''), 'empty');
// 此后读到与 pending 一致的内容 → suppressed设计取舍见 lib 头注释:
// 宁可漏掉"清空后外部再复制同内容",也不让回声重复入库)
assert.strictEqual(s.observe('A'), 'suppressed');
});
check('回声被外部复制顶掉:外部那次必须入库(占位 flag 实现会在这里丢数据)', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
// 两次 tick 之间用户在外面复制了 Btick 直接读到 B
assert.strictEqual(s.observe('B'), 'new', '外部复制 B 不能被回声标记吞掉');
});
check('不匹配的新内容消化 pending之后相同内容的外部复制不被误伤', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
assert.strictEqual(s.observe('B'), 'new');
// 用户又复制回 A外部—— pending 已被 B 消化A 正常入库
// 放开这条:点复制 A → 外部 B → 外部再复制 A第二个 A 被静默吞掉
assert.strictEqual(s.observe('A'), 'new');
});
check('剪贴板被清空不消化 pending清空后复制同内容仍被识别为回声', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
assert.strictEqual(s.observe(''), 'empty');
// 外部中途清空剪贴板不该让回声标记失效,否则紧接着读到 A 会重复入库
assert.strictEqual(s.observe('A'), 'suppressed');
});
check('基线已是空串再读空unchanged 而不是 empty保住 main.js 的图片分支)', () => {
const s = createEchoSuppress();
s.seed(''); // 启动时剪贴板没有文本 —— 纯图片场景的常态
assert.strictEqual(s.observe(''), 'unchanged');
// 放开这条(把 empty 判断挪到 unchanged 之前):文本恒空时每个 tick 都在
// 文本分支早退main.js 永远走不到图片分支 —— 用户复制图片整条静默丢失。
assert.strictEqual(s.observe('外部复制'), 'new');
});
check('写入失败清标记:失败后用户的真实复制不被吞', () => {
const s = createEchoSuppress();
s.markOwnWrite('A');
s.clearPending(); // 只清 pending不动基线
assert.strictEqual(s.observe(''), 'empty'); // 让基线与已清的pending 分叉
// pending 已清 → 读 A 走 'new' 而不是 'suppressed',真实复制不丢
assert.strictEqual(s.observe('A'), 'new');
});
check('写入失败回滚基线:失败后真实旧内容不被当新内容重复入库', () => {
const s = createEchoSuppress();
s.seed('旧内容(已在库里)');
const undo = s.markOwnWrite('新内容'); // 模拟点"复制":预置基线 + 记回声
undo(); // 模拟 clipboard.writeText 抛错后回滚
// 剪贴板上其实还是旧内容,下个 tick 读到它必须判 unchanged。
// 放开这条markOwnWrite 不提供回滚):基线停在'新内容',下个 tick 读到
// 真实旧内容判 'new',早已在库里的旧内容被重复插入一条。
assert.strictEqual(s.observe('旧内容(已在库里)'), 'unchanged');
// 回滚同时清了回声标记:用户之后真的复制了那个内容,照常入库不被吞
assert.strictEqual(s.observe('新内容'), 'new');
});
check('基线随每次变化更新:重复读同内容不重复入库', () => {
const s = createEchoSuppress();
assert.strictEqual(s.observe('X'), 'new');
assert.strictEqual(s.observe('X'), 'unchanged');
assert.strictEqual(s.observe('X'), 'unchanged');
assert.strictEqual(s.observe('Y'), 'new');
});
check('resetBaseline1×1 空图之后任何真图都算新', () => {
const s = createEchoSuppress();
s.seed('hash-of-small');
s.resetBaseline();
assert.strictEqual(s.observe('hash-of-real-image'), 'new');
});
check('abortLastObserve入库失败回滚后同一条内容能再次入库', () => {
// 用户报告的"卡住"场景observe 判 'new' → 入库失败SQLite 临时锁、
// 磁盘满、字段非法等)→ 下次 tick 读到同内容判 'unchanged' → 永久吞掉。
// abortLastObserve 必须撤销 observe 对基线的修改,让下次 tick 再判 'new'。
const s = createEchoSuppress();
s.seed('之前的内容');
assert.strictEqual(s.observe('新内容'), 'new', '第一次观察应判 new');
// 模拟 stmt.insertText 抛错:入库失败方在 catch 里调 abortLastObserve
s.abortLastObserve();
// 下次 tick 读到同样内容 → 必须重新判 'new',让 pollClipboard 再试入库
assert.strictEqual(s.observe('新内容'), 'new', 'abort 后应允许重试,不该被吞');
// 这次入库成功后基线更新,下次再读 → 'unchanged',正常路径
assert.strictEqual(s.observe('新内容'), 'unchanged', '入库成功后基线已建立');
});
check('abortLastObserve同时清掉 pending之前 markOwnWrite 留下的回声标记)', () => {
// 极小概率但可能:上一次 tick 已经 markOwnWrite自己写入留了 pending
// 紧接着入库失败 abort —— 这时 pending 是上次的回声标记,不该在 abort
// 后继续生效(否则下次 tick 会把外部真实复制当回声吞掉)。
const s = createEchoSuppress();
s.markOwnWrite('A'); // pending='A', last='A'
s.observe('B'); // 'new'pending 被消化)
s.markOwnWrite('C'); // pending='C', last='C'
s.observe('C'); // 'unchanged'(预置基线命中)
// 上面没有 'new' 路径,构造一个会判 'new' 的场景:
s.seed('基线');
s.markOwnWrite('X'); // pending='X', last='X'
s.observe('Y'); // pending 被消化, last='Y', 'new'
// 现在状态last='Y', pending=null。再来一次会判 'new' 的写入:
s.markOwnWrite('Z'); // pending='Z', last='Z'
assert.strictEqual(s.observe('Z'), 'unchanged');
// 模拟 pollClipboard 在 'new' 路径上失败 → 这里没有 new 路径,但语义上
// abortLastObserve 应同时把 last / pending 都清掉。下次 tick 读到任何
// 内容都重新判 'new'pendign 也不会错误地吞掉外部真实复制。
s.abortLastObserve();
assert.strictEqual(s.observe('anything'), 'new', 'abort 后 last 必须为空');
// 再 markOwnWrite 后能正常工作(说明 pending 也被清掉了,没残留)
s.markOwnWrite('P');
assert.strictEqual(s.observe('P'), 'unchanged', 'markOwnWrite 在 abort 后照常工作');
});
check('abortLastObserve无副作用last/pending 本来就 null 时调用不破坏)', () => {
const s = createEchoSuppress();
s.abortLastObserve(); // last=null, pending=null无副作用
assert.strictEqual(s.observe('A'), 'new');
s.abortLastObserve(); // last='A' → null下次再判 'new'
assert.strictEqual(s.observe('A'), 'new');
});
check('图片场景端到端:自己的图不入库、外部的图入库', () => {
const s = createEchoSuppress();
s.markOwnWrite('our-hash');
assert.strictEqual(s.observe('our-hash'), 'unchanged'); // 与预置基线一致,不入库
assert.strictEqual(s.observe('external-hash'), 'new'); // 外部换了图
assert.strictEqual(s.observe('external-hash'), 'unchanged');
});
check('文本与图片两份状态互不串扰main.js 用两个独立实例)', () => {
const text = createEchoSuppress();
const image = createEchoSuppress();
text.markOwnWrite('A');
assert.strictEqual(image.observe('A'), 'new', '图片状态机不该认识文本的 pending');
assert.strictEqual(text.observe('A'), 'unchanged', '文本状态机命中自己的预置基线');
});
console.log(`\n${passed} checks passed`);