// Behavior pins: these values are computed from the prototype's formulas. // If a test fails on a contract change, that's a deliberate engine change — not a test to "fix". import { describe, expect, it } from "vitest"; import { EVENTS } from "./events"; import { acceptAutomation, applyBias, automationOffer, confidence, declineAutomation, defaultProfile, dispositionLabel, ensureSources, handlesEvent, hasAutomation, irks, rateSource, statsFor, voiceTags, } from "./learning"; import { nightBloom } from "./nightBloom"; import { applyMode, decide, score } from "./scoring"; import { learn, speak } from "./speech"; import { defaultState, defaultTaste, moodFor, rateFairy } from "./state"; import type { FairyState, FeedItem, Rating } from "./types"; const ev = (id: string) => { const e = EVENTS.find((x) => x.id === id); if (!e) throw new Error(`no event ${id}`); return e; }; const neutral: FairyState = { ...defaultState(), mischief: 50, protectiveness: 50, curiosity: 50 }; describe("score", () => { it("passes base scores through at neutral personality", () => { expect(score(ev("claude_done"), neutral)).toEqual(ev("claude_done").base); }); it("protectiveness raises annoyance and relevance; mischief lowers annoyance", () => { const s = score(ev("discord_noise"), { ...neutral, protectiveness: 70, mischief: 30, curiosity: 50, }); // annoyance: 86 + 20*0.25 - (-20)*0.15 = 94 expect(s.annoyance).toBe(94); // relevance: 18 + 20*0.1 = 20 expect(s.relevance).toBe(20); // interest: 14 + 0 + (-20)*0.15 = 11 expect(s.interest).toBe(11); }); it("clamps to 0..100", () => { const s = score(ev("discord_noise"), { ...neutral, protectiveness: 100, mischief: 0 }); expect(s.annoyance).toBe(100); }); }); describe("decide", () => { it("interrupts when urgency and relevance are both >= 80", () => { expect(decide({ urgency: 84, relevance: 86, novelty: 55, annoyance: 30, interest: 70 })).toBe( "interrupt", ); }); it("ignores low-relevance high-annoyance noise", () => { expect(decide(score(ev("discord_noise"), neutral))).toBe("ignore"); }); it("notifies on high-worth events (claude_done at neutral: worth 239)", () => { expect(decide(score(ev("claude_done"), neutral))).toBe("notify"); }); it("walks the bands: glow > 120, scroll > 70, else ignore", () => { const base = { novelty: 0, annoyance: 0 }; expect(decide({ ...base, urgency: 50, relevance: 50, interest: 50, annoyance: 0 })).toBe("glow"); // worth 150 expect(decide({ ...base, urgency: 30, relevance: 30, interest: 30, annoyance: 0 })).toBe("scroll"); // worth 90 expect(decide({ ...base, urgency: 20, relevance: 20, interest: 20, annoyance: 0 })).toBe("ignore"); // worth 60 }); }); describe("speak", () => { it("picks mythic when taste favors theatre and mischief is high (rng pinned)", () => { const u = speak( ev("claude_done"), { ...neutral, mischief: 80 }, { theatrical: 1, terse: 0, useful: 0, warm: 1 }, () => 0.99, // caprice equal across tiers; side-quest roll 0.99 > 0.4 → no side quest ); expect(u.tier).toBe("mythic"); expect(u.text).toBe(ev("claude_done").lines.mythic); }); it("picks plain when taste favors terse-useful (rng pinned)", () => { const u = speak(ev("claude_done"), neutral, { theatrical: 0, terse: 1, useful: 1, warm: 0 }, () => 0); expect(u.tier).toBe("plain"); expect(u.tags).toEqual(["useful", "terse"]); }); it("appends a side quest at high mischief when the roll hits", () => { const u = speak(ev("claude_done"), { ...neutral, mischief: 80 }, defaultTaste(), () => 0.1); expect(u.text.length).toBeGreaterThan(ev("claude_done").lines[u.tier].length); }); }); describe("learn (taste)", () => { it("helpful reinforces used tags and usefulness", () => { const t = learn(defaultTaste(), ["theatrical", "warm"], "helpful"); expect(t.theatrical).toBeCloseTo(0.58); expect(t.warm).toBeCloseTo(0.63); expect(t.useful).toBeCloseTo(0.58); }); it("annoying suppresses used tags, extra penalty on theatrical", () => { const t = learn(defaultTaste(), ["theatrical", "warm"], "annoying"); expect(t.theatrical).toBeCloseTo(0.38); // -0.08 then -0.04 expect(t.warm).toBeCloseTo(0.47); }); it("cute trades usefulness for theatre", () => { const t = learn(defaultTaste(), ["useful", "terse"], "cute"); expect(t.theatrical).toBeCloseTo(0.58); expect(t.useful).toBeCloseTo(0.46); }); it("clamps to 0..1", () => { let t = defaultTaste(); for (let i = 0; i < 20; i++) t = learn(t, ["theatrical"], "annoying"); expect(t.theatrical).toBe(0); }); }); describe("learning profile", () => { it("ratings push per-source bias with the prototype's step sizes", () => { let p = defaultProfile(); p = rateSource(p, "Mail", "helpful"); expect(p.sources.Mail).toMatchObject({ bias: 9, helpful: 1, samples: 1 }); p = rateSource(p, "Mail", "annoying"); expect(p.sources.Mail.bias).toBe(-3); p = rateSource(p, "Mail", "cute"); expect(p.sources.Mail.bias).toBe(-6); expect(p.sources.Mail.samples).toBe(3); }); it("bias clamps at ±50", () => { let p = defaultProfile(); for (let i = 0; i < 10; i++) p = rateSource(p, "Discord", "annoying"); expect(p.sources.Discord.bias).toBe(-50); }); it("survives a profile persisted before a source existed (Learning-tab crash regression)", () => { // simulate old localStorage: a profile whose sources map predates "Git" const old = defaultProfile(); delete (old.sources as Record).Git; // the unguarded paths that crashed the overlay must now be safe expect(statsFor(old, "Git")).toMatchObject({ bias: 0, samples: 0 }); expect(() => irks(old)).not.toThrow(); expect(ensureSources(old).sources.Git).toBeDefined(); // a profile that already has every source is returned unchanged (no needless writes) const full = defaultProfile(); expect(ensureSources(full)).toBe(full); }); it("applyBias shifts along the ladder, one rung per 18 bias", () => { expect(applyBias("glow", 18)).toBe("notify"); expect(applyBias("glow", -18)).toBe("scroll"); expect(applyBias("glow", 9)).toBe("notify"); // Math.round(0.5) = 1 expect(applyBias("ignore", -50)).toBe("ignore"); }); it("applyBias never manufactures an interrupt", () => { expect(applyBias("notify", 50)).toBe("notify"); expect(applyBias("interrupt", 50)).toBe("interrupt"); }); it("offers Discord automation after 2 annoying ratings, once only", () => { let p = defaultProfile(); // seeded with Discord.annoying = 1 expect(automationOffer(p, "Discord")).toBeNull(); p = rateSource(p, "Discord", "annoying"); const offer = automationOffer(p, "Discord"); expect(offer?.id).toBe("mute_channels"); p = declineAutomation(p, "Discord"); expect(automationOffer(p, "Discord")).toBeNull(); // declined = don't re-offer }); it("Build earns automation through helpful ratings instead", () => { let p = defaultProfile(); p = rateSource(p, "Build", "helpful"); expect(automationOffer(p, "Build")).toBeNull(); p = rateSource(p, "Build", "helpful"); expect(automationOffer(p, "Build")?.id).toBe("watch_builds"); p = acceptAutomation(p, "Build"); expect(hasAutomation(p, "Build")).toBe(true); expect(automationOffer(p, "Build")).toBeNull(); }); it("automations handle only their scoped events — a mention escapes the muted bog", () => { let p = defaultProfile(); p = acceptAutomation(p, "Discord"); expect(handlesEvent(p, "Discord", "discord_noise")?.id).toBe("mute_channels"); expect(handlesEvent(p, "Discord", "discord_mention")).toBeNull(); }); it("watching builds silently never eats a failure", () => { let p = defaultProfile(); p = acceptAutomation(p, "Build"); expect(handlesEvent(p, "Build", "build_done")?.id).toBe("watch_builds"); expect(handlesEvent(p, "Build", "build_fail")).toBeNull(); }); it("handlesEvent is null before the automation is accepted", () => { expect(handlesEvent(defaultProfile(), "Discord", "discord_noise")).toBeNull(); }); it("confidence caps at 1 after 5 samples", () => { expect(confidence(0)).toBe(0); expect(confidence(3)).toBeCloseTo(0.6); expect(confidence(9)).toBe(1); }); it("dispositionLabel bands", () => { expect(dispositionLabel(30).t).toBe("interrupt freely"); expect(dispositionLabel(10).t).toBe("speak up"); expect(dispositionLabel(0).t).toBe("log quietly"); expect(dispositionLabel(-20).t).toBe("rarely surface"); expect(dispositionLabel(-40).t).toBe("stay silent"); }); it("irks lists annoying sources, most-flagged first", () => { let p = defaultProfile(); p = rateSource(p, "Mail", "annoying"); p = rateSource(p, "Mail", "annoying"); const list = irks(p); expect(list.map((s) => s.id)).toEqual(["Mail", "Discord"]); }); it("voiceTags falls back when taste is undecided", () => { expect(voiceTags({ theatrical: 0.5, terse: 0.5, useful: 0.5, warm: 0.5 })).toEqual([ "still figuring you out", ]); expect(voiceTags({ theatrical: 0.3, terse: 0.7, useful: 0.7, warm: 0.7 })).toEqual([ "to the point", "brief", "warm", "low drama", ]); }); }); describe("nightBloom", () => { const item = (id: string, rating: Rating | null): FeedItem => ({ uid: id + rating, id, source: ev(id).source, say: "", tier: "plain", tags: [], decision: "notify", time: "12:00", rating, }); it("converts ratings into personality deltas", () => { const r = nightBloom( [item("claude_done", "helpful"), item("discord_noise", "annoying"), item("build_done", "cute")], () => 0, ); expect(r.deltas).toEqual({ protectiveness: 2 + 1, attachment: 1, chattiness: -3 - 1, mischief: -2 + 2, age: 1, }); expect(r.counts).toEqual({ helpful: 1, annoying: 1, cute: 1 }); expect(r.rules).toHaveLength(3); }); it("promotes memories from helpful/annoying ratings, deduped by event", () => { const r = nightBloom( [item("claude_done", "helpful"), item("claude_done", "helpful"), item("build_done", "cute")], () => 0, ); expect(r.memories).toEqual([ev("claude_done").memory]); }); it("a quiet day still drifts: curiosity +2, one tone memory", () => { const r = nightBloom([item("claude_done", null)], () => 0); expect(r.deltas).toEqual({ curiosity: 2, age: 1 }); expect(r.memories[0].type).toBe("tone"); }); it("an empty day yields no memories", () => { expect(nightBloom([], () => 0).memories).toEqual([]); }); }); describe("applyMode", () => { it("silent demotes notify/interrupt to scroll, leaves quiet rungs alone", () => { expect(applyMode("notify", "silent")).toBe("scroll"); expect(applyMode("interrupt", "silent")).toBe("scroll"); expect(applyMode("glow", "silent")).toBe("glow"); }); it("goblin promotes glow to notify; patrol/polite change nothing", () => { expect(applyMode("glow", "goblin")).toBe("notify"); expect(applyMode("notify", "goblin")).toBe("notify"); expect(applyMode("notify", "patrol")).toBe("notify"); expect(applyMode("glow", "polite")).toBe("glow"); }); }); describe("rateFairy", () => { it("nudges personality per rating (prototype steps)", () => { expect(rateFairy(neutral, "helpful")).toMatchObject({ protectiveness: neutral.protectiveness + 2, attachment: neutral.attachment + 1, }); expect(rateFairy(neutral, "annoying").chattiness).toBe(neutral.chattiness - 3); expect(rateFairy(neutral, "cute").attachment).toBe(neutral.attachment + 1); }); it("clamps at the 0..100 bounds", () => { expect(rateFairy({ ...neutral, protectiveness: 99 }, "helpful").protectiveness).toBe(100); expect(rateFairy({ ...neutral, chattiness: 1 }, "annoying").chattiness).toBe(0); }); }); describe("moodFor", () => { it("maps fairy state to mood with prototype thresholds", () => { expect(moodFor({ ...neutral, energy: 20 })).toBe("sleepy"); expect(moodFor({ ...neutral, mischief: 70 })).toBe("mischief"); expect(moodFor({ ...neutral, protectiveness: 80 })).toBe("proud"); expect(moodFor(neutral)).toBe("curious"); }); });