import { describe, expect, it } from 'vitest' import { describeConflict, describeCounts, describeUndo, totalChanges, type ChangeSet } from './history' function changeSet(over: Partial = {}): ChangeSet { return { id: 1, gardenId: 1, actorId: 1, actorName: 'Steve', source: 'ui', summary: 'Did a thing', counts: [], createdAt: '2026-07-21T00:00:00Z', ...over, } } describe('describeCounts', () => { it('pluralizes per entity and names the operation', () => { const cs = changeSet({ counts: [ { entityType: 'planting', op: 'create', n: 3 }, { entityType: 'object', op: 'update', n: 1 }, ], }) expect(describeCounts(cs)).toBe('3 plantings added, 1 bed changed') }) it('says nothing rather than "0 changes"', () => { expect(describeCounts(changeSet())).toBe('') expect(describeCounts(changeSet({ counts: [{ entityType: 'object', op: 'create', n: 0 }] }))).toBe('') }) }) describe('describeConflict', () => { it('names the entity when it has a name', () => { expect(describeConflict({ entityType: 'object', entityId: 4, reason: 'changed', name: 'North Bed' })).toBe( '“North Bed” was edited since, so it was left alone', ) }) it('falls back to the entity kind when it has no name', () => { expect(describeConflict({ entityType: 'planting', entityId: 9, reason: 'missing' })).toBe( 'that planting no longer exists', ) }) }) describe('describeUndo', () => { const target = changeSet({ counts: [{ entityType: 'object', op: 'update', n: 3 }] }) it('is a plain success when nothing conflicted', () => { const out = describeUndo(target, { changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 3 }] }), conflicts: [], }) expect(out).toEqual({ tone: 'ok', message: 'Undone.' }) }) // The server answers 200 with a null change set when every revision was // already a no-op — reachable by undoing a creation whose object is gone. // Claiming "Undone." there reports work that didn't happen. it("doesn't claim to have undone a no-op", () => { const out = describeUndo(target, { changeSet: null, conflicts: [] }) expect(out.tone).toBe('ok') expect(out.message).toBe('Nothing left to undo — this was already reversed.') }) // The case the issue was specific about: a partial revert really did change // things, so reporting a bare failure would be a lie about what applied. it('says how much applied and what was skipped', () => { const out = describeUndo(target, { changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 2 }] }), conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }], }) expect(out.tone).toBe('partial') expect(out.message).toBe('2 of 3 changes undone — “North Bed” was edited since, so it was left alone.') }) it('is an error when nothing applied at all', () => { const out = describeUndo(target, { changeSet: null, conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }], }) expect(out.tone).toBe('error') expect(out.message).toBe('Nothing was undone — “North Bed” was edited since, so it was left alone.') }) it('lists every conflict, not just the first', () => { const out = describeUndo(target, { changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 1 }] }), conflicts: [ { entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }, { entityType: 'planting', entityId: 8, reason: 'missing' }, ], }) expect(out.message).toContain('“North Bed”') expect(out.message).toContain('that planting no longer exists') }) }) describe('totalChanges', () => { it('sums every tally', () => { expect( totalChanges( changeSet({ counts: [ { entityType: 'planting', op: 'create', n: 12 }, { entityType: 'object', op: 'update', n: 1 }, ], }), ), ).toBe(13) }) }) describe('describeUndo with an unknown total', () => { // The chat panel offers Undo on a turn knowing only its change set id — the // agent's reply carries the id, not the tally. Inventing a denominator to // fill the sentence would be worse than not having one. it("doesn't invent a denominator it was never given", () => { const out = describeUndo( { id: 5 }, { changeSet: changeSet({ id: 6, counts: [{ entityType: 'planting', op: 'delete', n: 2 }] }), conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }], }, ) expect(out.tone).toBe('partial') expect(out.message).toBe('Partly undone — “North Bed” was edited since, so it was left alone.') expect(out.message).not.toMatch(/\d+ of \d+/) }) it('still reports a clean undo the same way', () => { const out = describeUndo( { id: 5 }, { changeSet: changeSet({ id: 6, counts: [{ entityType: 'object', op: 'update', n: 1 }] }), conflicts: [] }, ) expect(out).toEqual({ tone: 'ok', message: 'Undone.' }) }) })