Test and deploy / test-and-deploy (push) Successful in 1m26s
Replaces the flat additive batch-size correction with a per-user multiplicative pace factor learned from each account's own logged roasts (shared/learn.js, GET /api/machine-profile), plus a lot-scoped "last refine" auto-suggestion. Also fixes the reference data being framed as "your own roasts" on a now-multi-user product, and reclassifies the drying/Maillard sanity checks as informational since they're algebraically derived from the DTR check rather than independent (yellow = 0.56 x first crack, not entered separately). Bug fixes found by an adversarial Opus review of the first pass: - Unicode minus sign (U+2212) broke duration parsing against the app's own generated refine-suggestion text - Printed sanity-checks table still showed a bare pass/fail glyph for the now-informational drying/Maillard rows - Printed time-ledger box didn't show the pace multiplication step, so it stopped reconciling by hand once pace != 1 - Field 6.4 (manual batch correction) was double-counted: excluded from the learned-pace fit but added back after the multiplication - Learned pace had no outlier rejection or hard clamp - computeLedger had no test coverage - Batch-size help copy overstated what the pace factor models (it's a single blanket ratio, not conditioned on batch weight) A follow-up Opus pass also caught the per-user profile cache surviving logout/account-switch in a shared browser; fixed by sweeping it alongside the existing plan-draft cleanup. Co-Authored-By: Claude Sonnet 5 <[email protected]>
171 lines
7.9 KiB
JavaScript
171 lines
7.9 KiB
JavaScript
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");
|
|
});
|