import { describe, expect, it } from 'vitest'; import { computePushActions } from '../src/push.js'; import type { DiffEntry, MetaSide } from '../src/push.js'; import type { DocmostMdMeta } from '../packages/docmost-client/src/lib/markdown-document.js'; // FS→Docmost push, FIRST increment (SPEC §6). `computePushActions` is the PURE // half: it classifies each `git diff --name-status` row into a Docmost action by // `pageId` identity (SPEC §4/§8), with NO IO — the `metaAt` resolver is injected. // These tests cover every classification incl. edges. /** Build a `metaAt` resolver from a `path|side -> meta` table. */ function metaTable( table: Record, ): (path: string, side: MetaSide) => DocmostMdMeta | null { return (path, side) => { const key = `${path}|${side}`; return key in table ? table[key] : null; }; } function meta(partial: Partial): DocmostMdMeta { return { version: 1, ...partial }; } describe('computePushActions — A (added)', () => { it('added file with NO pageId -> create', () => { const changes: DiffEntry[] = [{ status: 'A', path: 'New.md' }]; const metaAt = metaTable({ 'New.md|current': meta({ title: 'New', spaceId: 'sp1' }), }); const actions = computePushActions({ changes, metaAt }); expect(actions.creates).toEqual([{ path: 'New.md' }]); expect(actions.updates).toEqual([]); expect(actions.deletes).toEqual([]); expect(actions.renamesMoves).toEqual([]); expect(actions.skipped).toEqual([]); }); it('added file with NO meta at all -> create (treated as new)', () => { const changes: DiffEntry[] = [{ status: 'A', path: 'Plain.md' }]; const actions = computePushActions({ changes, metaAt: metaTable({}) }); expect(actions.creates).toEqual([{ path: 'Plain.md' }]); }); it('added file WITH a pageId (restored/copied) -> update (page exists)', () => { const changes: DiffEntry[] = [{ status: 'A', path: 'Restored.md' }]; const metaAt = metaTable({ 'Restored.md|current': meta({ pageId: 'p-restored', title: 'R' }), }); const actions = computePushActions({ changes, metaAt }); // The page already exists -> push content as an UPDATE, never a duplicate. expect(actions.updates).toEqual([ { pageId: 'p-restored', path: 'Restored.md' }, ]); expect(actions.creates).toEqual([]); }); }); describe('computePushActions — M (modified)', () => { it('modified file with a pageId -> update content', () => { const changes: DiffEntry[] = [{ status: 'M', path: 'Doc.md' }]; const metaAt = metaTable({ 'Doc.md|current': meta({ pageId: 'p-doc' }), }); const actions = computePushActions({ changes, metaAt }); expect(actions.updates).toEqual([{ pageId: 'p-doc', path: 'Doc.md' }]); expect(actions.skipped).toEqual([]); }); it('modified file with NO pageId -> skipped (no target to update)', () => { const changes: DiffEntry[] = [{ status: 'M', path: 'Untracked.md' }]; const actions = computePushActions({ changes, metaAt: metaTable({}) }); expect(actions.updates).toEqual([]); expect(actions.skipped).toEqual([ { path: 'Untracked.md', status: 'M', reason: 'modified file has no pageId in meta', }, ]); }); }); describe('computePushActions — D (deleted)', () => { it('deleted file recovers pageId from the PRE-IMAGE meta -> delete', () => { const changes: DiffEntry[] = [{ status: 'D', path: 'Gone.md' }]; // The file is gone from `current`; its pageId lives in the `prev` pre-image. const metaAt = metaTable({ 'Gone.md|prev': meta({ pageId: 'p-gone' }), }); const actions = computePushActions({ changes, metaAt }); expect(actions.deletes).toEqual([{ pageId: 'p-gone' }]); expect(actions.skipped).toEqual([]); }); it('deleted file with NO recoverable pageId -> skipped (untracked guard §8)', () => { const changes: DiffEntry[] = [{ status: 'D', path: 'Stray.md' }]; // No pre-image pageId -> the untracked-file guard skips it (never deletes a // page that was never tracked, SPEC §8). const actions = computePushActions({ changes, metaAt: metaTable({}) }); expect(actions.deletes).toEqual([]); expect(actions.skipped).toEqual([ { path: 'Stray.md', status: 'D', reason: 'deleted file has no recoverable pageId (pre-image meta)', }, ]); }); it('uses the PREV side, not current, to recover the deleted pageId', () => { const changes: DiffEntry[] = [{ status: 'D', path: 'Gone.md' }]; // A stale `current` meta must NOT be used; only the pre-image counts. const metaAt = metaTable({ 'Gone.md|current': meta({ pageId: 'WRONG' }), 'Gone.md|prev': meta({ pageId: 'p-correct' }), }); const actions = computePushActions({ changes, metaAt }); expect(actions.deletes).toEqual([{ pageId: 'p-correct' }]); }); }); describe('computePushActions — R/C (renamed/moved)', () => { it('renamed file -> renamesMoves (record only; resolution deferred)', () => { const changes: DiffEntry[] = [ { status: 'R', path: 'New/Path.md', oldPath: 'Old/Path.md', score: 100 }, ]; const metaAt = metaTable({ 'New/Path.md|current': meta({ pageId: 'p-moved' }), }); const actions = computePushActions({ changes, metaAt }); expect(actions.renamesMoves).toEqual([ { pageId: 'p-moved', oldPath: 'Old/Path.md', newPath: 'New/Path.md' }, ]); // It is NOT also recorded as a create/update/delete. expect(actions.creates).toEqual([]); expect(actions.updates).toEqual([]); expect(actions.deletes).toEqual([]); }); it('copy (C) is recorded like a rename for the deferred apply', () => { const changes: DiffEntry[] = [ { status: 'C', path: 'Copy.md', oldPath: 'Src.md', score: 90 }, ]; const metaAt = metaTable({ 'Copy.md|current': meta({ pageId: 'p-copy' }), }); const actions = computePushActions({ changes, metaAt }); expect(actions.renamesMoves).toEqual([ { pageId: 'p-copy', oldPath: 'Src.md', newPath: 'Copy.md' }, ]); }); it('renamed file with NO pageId -> skipped', () => { const changes: DiffEntry[] = [ { status: 'R', path: 'New.md', oldPath: 'Old.md', score: 100 }, ]; const actions = computePushActions({ changes, metaAt: metaTable({}) }); expect(actions.renamesMoves).toEqual([]); expect(actions.skipped).toEqual([ { path: 'New.md', status: 'R', reason: 'renamed/moved file has no pageId in meta' }, ]); }); }); describe('computePushActions — mixed batch', () => { it('classifies a realistic mixed diff in one pass', () => { const changes: DiffEntry[] = [ { status: 'A', path: 'Fresh.md' }, // create { status: 'A', path: 'Restored.md' }, // update (has pageId) { status: 'M', path: 'Edited.md' }, // update { status: 'D', path: 'Removed.md' }, // delete { status: 'R', path: 'Dst.md', oldPath: 'Srcc.md', score: 100 }, // move ]; const metaAt = metaTable({ 'Fresh.md|current': meta({ title: 'Fresh', spaceId: 'sp' }), 'Restored.md|current': meta({ pageId: 'p-rest' }), 'Edited.md|current': meta({ pageId: 'p-edit' }), 'Removed.md|prev': meta({ pageId: 'p-rm' }), 'Dst.md|current': meta({ pageId: 'p-mv' }), }); const actions = computePushActions({ changes, metaAt }); expect(actions.creates).toEqual([{ path: 'Fresh.md' }]); expect(actions.updates).toEqual([ { pageId: 'p-rest', path: 'Restored.md' }, { pageId: 'p-edit', path: 'Edited.md' }, ]); expect(actions.deletes).toEqual([{ pageId: 'p-rm' }]); expect(actions.renamesMoves).toEqual([ { pageId: 'p-mv', oldPath: 'Srcc.md', newPath: 'Dst.md' }, ]); expect(actions.skipped).toEqual([]); }); });