'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 之间用户在外面复制了 B,tick 直接读到 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('resetBaseline:1×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`);