Spaces:
Running
Running
| // 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<string, unknown>).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"); | |
| }); | |
| }); | |