sotashimozono_obsidian-remo.../plugin/tests/LocalOpRegistry.test.ts
Souta 27d456d3b9 fix(sync): RPC writer self-reflect + echo de-dup (#341, fully closes)
#341 was only papered-over on RPC: the adapter fired no writer-side
trigger; the daemon fs.watch echo *eventually* reached the model via
FsChangeListener, but with a real race (editor could save to a dead
path in the window). SFTP was fixed in #346; this makes self-reflect
transport-independent so RPC is genuinely fixed too.

- LocalOpRegistry (new): short-TTL path set the writer records into
  synchronously after each applied op (rename records old+new). The
  daemon echo can only arrive strictly later, so no record/echo race.
- SftpDataAdapter: `applied(echoPaths, run)` helper folds the
  registry.record + the (exception-safe) reflect into one call at
  every applied-op site (write/writeBinary/appendBinary/mkdir/remove/
  rmdir/rename), reusing the !reconnecting guard from #346/C1.
- FsChangeListener: subscribe() takes an optional localOpRegistry
  (stored like lastPathMapper so reconnect-resume reuses the same
  instance); handleNotification drops a self-originated echo before
  invalidate+applyChange. One check on action.vaultPath covers every
  echo shape (incl. watchers that split rename into delete+create)
  because both old and new were recorded. Other clients' changes
  never pass through record, so multi-client propagation is intact.
- AdapterManager: the reflector is now wired on BOTH transports plus
  a per-patch LocalOpRegistry; RPC additionally subscribes the
  listener with that registry. Removed applyWriterReflectPolicy /
  afterSwapClient (and the main.ts swapClient call): the old
  RPC=null-reflector strategy that I5/N3 patched is obsolete — the
  reflector is transport-invariant and the adapter object outlives
  swapClient, so wiring once in patch() holds for the patched
  lifetime; double-fire is prevented by the registry, not by nulling.

Tests: self-reflect-rpc wires the reflector (now 4/4 green, docblock
rewritten); LocalOpRegistry.test.ts (TTL/no-consume/prune/boundary);
FsChangeListener echo-dedup unit (self drop / other-client apply /
no-registry legacy); removed the now-obsolete AdapterManager
afterSwapClient describe.

Validation: tsc --noEmit clean, eslint clean, vitest 70 files /
1047 passed / 1 skipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 15:27:57 +09:00

75 lines
2.6 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { LocalOpRegistry } from '../src/adapter/LocalOpRegistry';
/**
* Deterministic time control: the registry keys entirely off
* `Date.now()`, so a fake system clock makes TTL behaviour exact and
* non-flaky (no real sleeps).
*/
describe('LocalOpRegistry', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(0);
});
afterEach(() => {
vi.useRealTimers();
});
it('reports a recorded path as self-originated within the TTL', () => {
const reg = new LocalOpRegistry(1_000);
reg.record(['Notes/a.md']);
expect(reg.isSelfOriginated('Notes/a.md')).toBe(true);
});
it('reports an unrecorded path as not self-originated', () => {
const reg = new LocalOpRegistry(1_000);
reg.record(['a.md']);
expect(reg.isSelfOriginated('b.md')).toBe(false);
});
it('records every path in the batch (rename → old + new)', () => {
const reg = new LocalOpRegistry(1_000);
reg.record(['old.md', 'new.md']);
expect(reg.isSelfOriginated('old.md')).toBe(true);
expect(reg.isSelfOriginated('new.md')).toBe(true);
});
it('does NOT consume the entry — repeated checks stay true (split rename echo)', () => {
const reg = new LocalOpRegistry(1_000);
reg.record(['x.md']);
expect(reg.isSelfOriginated('x.md')).toBe(true);
expect(reg.isSelfOriginated('x.md')).toBe(true); // delete echo + create echo
});
it('expires an entry once the TTL elapses', () => {
const reg = new LocalOpRegistry(1_000);
reg.record(['gone.md']);
vi.setSystemTime(1_001);
expect(reg.isSelfOriginated('gone.md')).toBe(false);
});
it('keeps an entry live right up to (not past) the TTL boundary', () => {
const reg = new LocalOpRegistry(1_000);
reg.record(['edge.md']);
vi.setSystemTime(1_000); // == expiresAt, Date.now() > expiresAt is false
expect(reg.isSelfOriginated('edge.md')).toBe(true);
});
it('prunes stale entries on a later record so the map cannot grow unbounded', () => {
const reg = new LocalOpRegistry(1_000);
reg.record(['stale.md']);
vi.setSystemTime(2_000);
reg.record(['fresh.md']); // triggers prune of stale.md
expect(reg.isSelfOriginated('stale.md')).toBe(false);
expect(reg.isSelfOriginated('fresh.md')).toBe(true);
});
it('defaults to a 5s TTL when none is supplied', () => {
const reg = new LocalOpRegistry();
reg.record(['d.md']);
vi.setSystemTime(4_999);
expect(reg.isSelfOriginated('d.md')).toBe(true);
vi.setSystemTime(5_001);
expect(reg.isSelfOriginated('d.md')).toBe(false);
});
});