import test from "node:test"; import assert from "node:assert/strict"; import { computeMachineProfile } from "../shared/learn.js"; function plan({ fcAnchor = "7:30", refine = "0", condition = "", batchCorrection = "", actualFc, actualBt = {}, } = {}) { return { fields: { "2.1": "single", "1.4": fcAnchor, "1.6": refine, "5.6": condition, "6.4": batchCorrection }, planActual: { charge: { actualBt: actualBt.charge ?? "" }, tp: { actualBt: actualBt.tp ?? "" }, yellow: { actualBt: actualBt.yellow ?? "" }, fc: { actualTime: actualFc ?? "", actualBt: actualBt.fc ?? "" }, drop: { actualBt: actualBt.drop ?? "" }, }, }; } test("learn: empty history stays at reference defaults", () => { const profile = computeMachineProfile([]); assert.equal(profile.pace.value, 1); assert.equal(profile.pace.n, 0); assert.equal(profile.pace.source, "reference"); assert.equal(profile.bandsSource, "reference"); assert.equal(profile.totalPlans, 0); for (const m of ["charge", "tp", "yellow", "fc", "drop"]) { assert.equal(profile.bands[m].source, "reference"); assert.equal(profile.bands[m].medianC, null); } }); test("learn: non-array input is treated as no history, not a throw", () => { assert.doesNotThrow(() => computeMachineProfile(null)); assert.doesNotThrow(() => computeMachineProfile(undefined)); assert.equal(computeMachineProfile(null).totalPlans, 0); }); test("learn: a single completed roast is not enough to trust — pace stays 1.0", () => { // anchor 7:30 = 450s, actual FC 8:15 = 495s (ratio 1.1) — only one data point. const profile = computeMachineProfile([plan({ fcAnchor: "7:30", actualFc: "8:15" })]); assert.equal(profile.pace.value, 1); assert.equal(profile.pace.n, 1); assert.equal(profile.pace.source, "reference"); }); test("learn: two or more roasts running consistently slower learn a >1 pace factor", () => { // Every roast ran 10% slower than its own anchor predicted. const plans = [ plan({ fcAnchor: "7:30", actualFc: "8:15" }), // 450 -> 495, ratio 1.1 plan({ fcAnchor: "8:00", actualFc: "8:48" }), // 480 -> 528, ratio 1.1 plan({ fcAnchor: "9:00", actualFc: "9:54" }), // 540 -> 594, ratio 1.1 ]; const profile = computeMachineProfile(plans); assert.equal(profile.pace.n, 3); assert.equal(profile.pace.source, "learned"); assert.ok(Math.abs(profile.pace.value - 1.1) < 0.001, `expected ~1.1, got ${profile.pace.value}`); }); test("learn: refine and bean-condition corrections are folded into the predicted anchor before pacing", () => { // anchor 7:30 (450s) + refine +0:15 (15s) + condition -0:05 (-5s) = predicted 460s. // Actual exactly matches the corrected prediction, so pace should read as 1.0 even though // naively comparing actual against the raw uncorrected anchor would suggest otherwise. const plans = [ plan({ fcAnchor: "7:30", refine: "+0:15", condition: "-0:05", actualFc: "7:40" }), // 460s plan({ fcAnchor: "8:00", refine: "+0:15", condition: "-0:05", actualFc: "8:10" }), // 490s ]; const profile = computeMachineProfile(plans); assert.equal(profile.pace.source, "learned"); assert.ok(Math.abs(profile.pace.value - 1) < 0.001, `expected ~1.0, got ${profile.pace.value}`); }); test("learn: a plan with no anchor or no actual FC time is silently skipped, not counted or thrown on", () => { const plans = [ plan({ fcAnchor: "", actualFc: "8:15" }), // no anchor -> unpredictable, skipped plan({ fcAnchor: "7:30", actualFc: "" }), // never roasted -> skipped { fields: {}, planActual: null }, // malformed -> skipped {}, // completely empty -> skipped ]; assert.doesNotThrow(() => computeMachineProfile(plans)); const profile = computeMachineProfile(plans); assert.equal(profile.pace.n, 0); assert.equal(profile.pace.source, "reference"); assert.equal(profile.totalPlans, 4); }); test("learn: temperature bands require 3+ readings per milestone, independently per milestone", () => { const plans = [ plan({ actualBt: { charge: "160", fc: "180" } }), plan({ actualBt: { charge: "165", fc: "185" } }), plan({ actualBt: { charge: "170", fc: "190" } }), // charge and fc now have 3 each plan({ actualBt: { tp: "85" } }), // tp only has 1 reading ]; const profile = computeMachineProfile(plans); assert.equal(profile.bands.charge.source, "learned"); assert.equal(profile.bands.charge.n, 3); assert.equal(profile.bands.charge.medianC, 165); assert.deepEqual(profile.bands.charge.rangeC, [160, 170]); assert.equal(profile.bands.fc.source, "learned"); assert.equal(profile.bands.fc.medianC, 185); // Below the 3-sample threshold — stays reference, not a false "learned" reading from 1 point. assert.equal(profile.bands.tp.source, "reference"); assert.equal(profile.bands.tp.medianC, null); assert.equal(profile.bands.tp.n, 1); assert.equal(profile.bandsSource, "learned"); // true because AT LEAST ONE milestone learned }); test("learn: non-numeric actualBt values are ignored rather than poisoning the median", () => { const plans = [ plan({ actualBt: { charge: "160" } }), plan({ actualBt: { charge: "not a number" } }), plan({ actualBt: { charge: "" } }), plan({ actualBt: { charge: "170" } }), plan({ actualBt: { charge: "180" } }), ]; const profile = computeMachineProfile(plans); assert.equal(profile.bands.charge.n, 3); // only the 3 valid numeric readings counted assert.equal(profile.bands.charge.medianC, 170); }); test("learn: field 6.4 is included in the fitted prediction, so it isn't double-counted once pace is learned", () => { // anchor 8:00 (480s) + 6.4 +1:00 (60s) = 540s predicted; actual FC lands exactly there, every // time, so a correct fit reads pace as 1.0 — NOT ~1.125, which is what a fit that excluded 6.4 // (comparing 540s actual against a 480s prediction) would wrongly report. const plans = [ plan({ fcAnchor: "8:00", batchCorrection: "+1:00", actualFc: "9:00" }), plan({ fcAnchor: "8:30", batchCorrection: "+1:00", actualFc: "9:30" }), plan({ fcAnchor: "9:00", batchCorrection: "+1:00", actualFc: "10:00" }), ]; const profile = computeMachineProfile(plans); assert.equal(profile.pace.source, "learned"); assert.ok(Math.abs(profile.pace.value - 1) < 0.001, `expected ~1.0, got ${profile.pace.value}`); }); test("learn: a single wildly-off actual-FC entry is excluded as an outlier, not allowed to define the pace", () => { // Second roast's actual FC (0:05) against an 8:00 anchor is a near-certain typo, not a real // 96% speed-up — the ratio (0.0104) sits far outside the plausible band and must be dropped // before the median, not clamped into it as if it were real signal from a small sample. const plans = [ plan({ fcAnchor: "8:00", actualFc: "8:10" }), // ratio ~1.02, plausible plan({ fcAnchor: "8:00", actualFc: "0:05" }), // ratio ~0.01, implausible — excluded ]; const profile = computeMachineProfile(plans); // Only one plausible sample remains, below MIN_PACE_SAMPLES — falls back to reference rather // than "learning" a pace from what's actually just the one plausible reading. assert.equal(profile.pace.n, 1); assert.equal(profile.pace.source, "reference"); assert.equal(profile.pace.value, 1); }); test("learn: the learned pace is clamped to a defensible band even with several agreeing samples", () => { // Three roasts all reading a ~0.55 ratio — each one individually within the per-sample // plausible band (so none get excluded as an outlier), but a median that far below 1 is still // implausible for a real machine. The hard clamp exists for exactly this shape of history, // where per-sample filtering alone wouldn't catch it. const plans = [ plan({ fcAnchor: "8:00", actualFc: "4:20" }), // 260/480 = 0.542 plan({ fcAnchor: "8:00", actualFc: "4:24" }), // 264/480 = 0.55 plan({ fcAnchor: "8:00", actualFc: "4:28" }), // 268/480 = 0.558 ]; const profile = computeMachineProfile(plans); assert.equal(profile.pace.n, 3); // all three were plausible per-sample, none excluded assert.equal(profile.pace.source, "learned"); assert.equal(profile.pace.value, 0.6, "median ~0.55 should be clamped up to the 0.6 floor"); });