puck / frontend /src /engine /engine.test.ts
vu1n's picture
Puck — desktop fairy familiar (HF Build Small)
3c124f3
Raw
History Blame Contribute Delete
12.4 kB
// 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");
});
});