3d2c68a1a1
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>
64 lines
3.2 KiB
TypeScript
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);
|
|
});
|
|
});
|