addParticipants + updateParticipant returned log:null → invisible in
combat log. Contract test caught them. Now both return
log:{ message, undo:{ participants }}.
App.js handlers wired (handleAddAllCharacters, handleUpdateParticipant,
handleInlineInitiative) — call logAction on logged mutations.
Logging contract now complete: every mutating combat op produces a log
entry. No gaps. turn.logging.test.js proves it.
238 tests green.
204 lines
5.8 KiB
JavaScript
204 lines
5.8 KiB
JavaScript
// Logging contract: every mutating combat op returns { patch, log } where
|
|
// log = { message: string, undo: object|null }. No-op (no state change)
|
|
// returns { patch: null, log: null }. Display lifecycle ops return { patch }
|
|
// only (no log field expected).
|
|
//
|
|
// logAction handler does:
|
|
// if (log) logAction(log.message, ctx, { updates: log.undo })
|
|
// → null log = skipped = gap in audit trail.
|
|
//
|
|
// Contract = every combat mutation MUST produce a log entry.
|
|
|
|
'use strict';
|
|
|
|
const shared = require('@ttrpg/shared');
|
|
const {
|
|
makeParticipant, buildMonsterParticipant,
|
|
startEncounter, nextTurn, togglePause,
|
|
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
|
toggleParticipantActive, applyHpChange, deathSave, toggleCondition,
|
|
reorderParticipants, endEncounter,
|
|
} = shared;
|
|
|
|
function p(id, init) {
|
|
return makeParticipant({ id, name: id, type: 'monster',
|
|
initiative: init, maxHp: 100, currentHp: 100 });
|
|
}
|
|
function enc(ps, extra = {}) {
|
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
|
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
|
|
}
|
|
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
|
|
|
|
// Helper: assert a result is a logged mutation (patch + valid log).
|
|
function expectLogged(r) {
|
|
expect(r.patch).not.toBeNull();
|
|
expect(r.log).not.toBeNull();
|
|
expect(typeof r.log).toBe('object');
|
|
expect(typeof r.log.message).toBe('string');
|
|
expect(r.log.message.length).toBeGreaterThan(0);
|
|
}
|
|
function expectNoOp(r) {
|
|
expect(r.patch).toBeNull();
|
|
expect(r.log).toBeNull();
|
|
}
|
|
|
|
describe('Logging contract: mutating ops', () => {
|
|
let e;
|
|
beforeEach(() => {
|
|
e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
|
});
|
|
|
|
test('startEncounter logs', () => {
|
|
const r = startEncounter(e);
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('nextTurn logs', () => {
|
|
const started = apply(e, startEncounter(e));
|
|
const r = nextTurn(started);
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('togglePause logs', () => {
|
|
const r = togglePause(apply(e, startEncounter(e)));
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('addParticipant logs', () => {
|
|
const r = addParticipant(e, p('d', 5));
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('removeParticipant logs', () => {
|
|
const r = removeParticipant(e, 'b');
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('toggleParticipantActive logs', () => {
|
|
const r = toggleParticipantActive(e, 'b');
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('applyHpChange (damage) logs', () => {
|
|
const r = applyHpChange(e, 'b', 'damage', 10);
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('applyHpChange (heal) logs', () => {
|
|
const r = applyHpChange(e, 'b', 'heal', 5);
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('deathSave logs', () => {
|
|
const started = apply(e, startEncounter(e));
|
|
const dying = apply(started, applyHpChange(started, 'b', 'damage', 100));
|
|
const r = deathSave(dying, 'b', 1);
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('toggleCondition logs', () => {
|
|
const r = toggleCondition(e, 'b', 'poisoned');
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('reorderParticipants logs (BUG-7)', () => {
|
|
// same-init tie (both 10) for valid reorder
|
|
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
|
|
const r = reorderParticipants(e2, 'x', 'a');
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('endEncounter logs', () => {
|
|
const started = apply(e, startEncounter(e));
|
|
const r = endEncounter(started);
|
|
expectLogged(r);
|
|
});
|
|
});
|
|
|
|
describe('Logging contract: no-ops', () => {
|
|
let e;
|
|
beforeEach(() => {
|
|
e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
|
});
|
|
|
|
test('reorder same-id = no-op', () => {
|
|
expectNoOp(reorderParticipants(e, 'a', 'a'));
|
|
});
|
|
|
|
test('reorder cross-init = no-op', () => {
|
|
expectNoOp(reorderParticipants(e, 'a', 'b'));
|
|
});
|
|
});
|
|
|
|
describe('Logging undo payloads', () => {
|
|
let e;
|
|
beforeEach(() => {
|
|
e = enc([p('a', 10), p('b', 7), p('c', 3)]);
|
|
});
|
|
|
|
test('startEncounter undo restores pre-combat state', () => {
|
|
const r = startEncounter(e);
|
|
expect(r.log.undo).toBeDefined();
|
|
expect(r.log.undo.isStarted).toBe(false);
|
|
});
|
|
|
|
test('endEncounter undo restores combat state', () => {
|
|
const started = apply(e, startEncounter(e));
|
|
const r = endEncounter(started);
|
|
expect(r.log.undo).toBeDefined();
|
|
expect(r.log.undo.isStarted).toBe(true);
|
|
});
|
|
|
|
test('applyHpChange undo restores prior hp', () => {
|
|
const r = applyHpChange(e, 'b', 'damage', 10);
|
|
expect(r.log.undo).toBeDefined();
|
|
expect(r.log.undo.participants).toBeDefined();
|
|
});
|
|
|
|
test('reorder undo restores prior order (BUG-7)', () => {
|
|
const e2 = enc([p('a', 10), p('x', 10), p('c', 3)]);
|
|
const orig = e2.participants.map(p => p.id);
|
|
const r = reorderParticipants(e2, 'x', 'a');
|
|
expect(r.log.undo).toBeDefined();
|
|
const restored = { ...e2, ...r.log.undo };
|
|
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
|
});
|
|
});
|
|
|
|
describe('Logging: addParticipants + updateParticipant', () => {
|
|
let e;
|
|
beforeEach(() => {
|
|
e = enc([p('a', 10), p('b', 7)]);
|
|
});
|
|
|
|
test('addParticipants logs', () => {
|
|
const r = addParticipants(e, [p('c', 3)]);
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('updateParticipant (same slot) logs', () => {
|
|
const r = updateParticipant(e, 'b', { name: 'B' });
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('updateParticipant (init change) logs', () => {
|
|
const r = updateParticipant(e, 'b', { initiative: 5 });
|
|
expectLogged(r);
|
|
});
|
|
|
|
test('addParticipants undo restores prior list', () => {
|
|
const orig = e.participants.map(p => p.id);
|
|
const r = addParticipants(e, [p('c', 3)]);
|
|
const restored = { ...e, ...r.log.undo };
|
|
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
|
});
|
|
|
|
test('updateParticipant undo restores prior participant', () => {
|
|
const orig = e.participants.map(p => p.id);
|
|
const r = updateParticipant(e, 'b', { name: 'B' });
|
|
const restored = { ...e, ...r.log.undo };
|
|
expect(restored.participants.map(p => p.id)).toEqual(orig);
|
|
});
|
|
});
|