Files
ttrpg_manager/src/lib/assistant/signals.test.ts
T
NilsBriggen 3d2c68a1a1 V2 Phase 4: Signals engine — proactive, app-wide one-click assists
The "buttons, not chatbot" core: the app recognizes a situation and offers the
fix; the user never has to formulate a request.

- src/lib/assistant/signals.ts: buildSignals() aggregates the existing advisor
  detectors with new kernel-aware ones — quest-complete (all objectives done →
  "Mark complete"), caster out-of-slots → "Short rest", concentrating-while-
  bloodied warning — deduped and severity-sorted. 6 unit tests.
- extended SuggestAction with shortRest + completeQuest; one shared dispatcher
  (useSignalAction) used by both surfaces so behaviour can't drift.
- SignalsBell: an ambient bell + popover in the top bar, visible from every
  screen, with a count badge, one-click actions, and per-signal/clear-all
  dismiss. AssistantPage refactored onto the same buildSignals + dispatcher.
- the AI cards were already actionable (NpcGen → npcsRepo, EncounterTip → add
  to encounter, Assistant encounter builder → combat).

Also fixed a pre-existing invalid-HTML bug: ConditionPicker rendered a <Check>
SVG inside <option> (React hydration error spam) → plain "✓" text.

Build + 277 unit tests green; bell verified live (popover, actions, no errors).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:30:26 +02:00

64 lines
3.2 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { characterDefaults, type Character, type Quest } from '@/lib/schemas';
import { questSignals, casterSignals, buildSignals } from './signals';
function pc(over: Partial<Character> = {}): Character {
return {
id: 'pc', campaignId: 'c', system: '5e', kind: 'pc', name: 'Mage', ancestry: '', className: 'Wizard', level: 5,
abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
hp: { current: 30, max: 30, temp: 0 }, speed: 30, armorBonus: 0,
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
...over,
};
}
function quest(over: Partial<Quest> = {}): Quest {
return { id: 'q', campaignId: 'c', title: 'Find the relic', status: 'active', description: '', reward: '', objectives: [], createdAt: '', updatedAt: '', ...over };
}
describe('questSignals', () => {
it('offers to complete a quest whose objectives are all done', () => {
const q = quest({ objectives: [{ id: 'o1', text: 'a', done: true }, { id: 'o2', text: 'b', done: true }] });
const [s] = questSignals([q]);
expect(s?.action).toEqual({ type: 'completeQuest', questId: 'q', label: 'Mark complete' });
});
it('stays quiet when an objective is unfinished or there are none', () => {
expect(questSignals([quest({ objectives: [{ id: 'o1', text: 'a', done: false }] })])).toEqual([]);
expect(questSignals([quest({ objectives: [] })])).toEqual([]);
expect(questSignals([quest({ status: 'completed', objectives: [{ id: 'o1', text: 'a', done: true }] })])).toEqual([]);
});
});
describe('casterSignals', () => {
it('flags a caster with all slots expended and offers a short rest', () => {
const c = pc({ spellcasting: { slots: [{ level: 1, max: 3, current: 0 }, { level: 2, max: 2, current: 0 }], spells: [] } });
const sig = casterSignals([c]).find((s) => s.id === 'no-slots-pc');
expect(sig?.action).toEqual({ type: 'shortRest', characterId: 'pc', label: 'Short rest' });
});
it('does not flag when some slots remain', () => {
const c = pc({ spellcasting: { slots: [{ level: 1, max: 3, current: 1 }], spells: [] } });
expect(casterSignals([c]).some((s) => s.id === 'no-slots-pc')).toBe(false);
});
it('warns when concentrating while bloodied', () => {
const c = pc({ hp: { current: 10, max: 30, temp: 0 }, concentration: { spellId: 's', spellName: 'Bless' } });
expect(casterSignals([c]).some((s) => s.id === 'conc-risk-pc')).toBe(true);
});
});
describe('buildSignals', () => {
it('merges detectors, de-dupes, and sorts danger → warn → info', () => {
const downed = pc({ id: 'a', name: 'A', hp: { current: 0, max: 30, temp: 0 } });
const noSlots = pc({ id: 'b', name: 'B', spellcasting: { slots: [{ level: 1, max: 2, current: 0 }], spells: [] } });
const out = buildSignals({ characters: [downed, noSlots], notes: [], quests: [], activeEncounter: undefined });
expect(out.length).toBeGreaterThan(0);
// severity is non-decreasing (danger=0 first)
const ranks = out.map((s) => ({ danger: 0, warn: 1, info: 2 })[s.severity]);
expect(ranks).toEqual([...ranks].sort((x, y) => x - y));
// unique ids
expect(new Set(out.map((s) => s.id)).size).toBe(out.length);
});
});