Files
roast_command_center/test/machine-profile.test.js
snowspeederandClaude Sonnet 5 2237c199c1
Test and deploy / test-and-deploy (push) Successful in 1m26s
Add per-user learned machine profile; fix methodology honesty issues
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]>
2026-07-31 12:40:03 -04:00

146 lines
5.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { setup, signup } from "./helpers.js";
function planBody(overrides = {}) {
return {
fields: { "2.1": "single", "1.4": "7:30", "1.6": "0", "5.6": "" },
planActual: {
charge: { actualBt: "" },
tp: { actualBt: "" },
yellow: { actualBt: "" },
fc: { actualTime: "", actualBt: "" },
drop: { actualBt: "" },
},
afterRoast: { oneChange: "" },
inventory: { lotId: "", lotLabel: "", consumed: null },
...overrides,
};
}
test("machine-profile: requires auth", async () => {
const { app } = await setup();
const anon = request.agent(app);
assert.equal((await anon.get("/api/machine-profile")).status, 401);
});
test("machine-profile: a brand-new account gets the reference defaults, not an error", async () => {
const { app } = await setup();
const agent = request.agent(app);
await signup(agent, "[email protected]");
const r = await agent.get("/api/machine-profile");
assert.equal(r.status, 200);
assert.equal(r.body.ok, true);
assert.equal(r.body.profile.pace.value, 1);
assert.equal(r.body.profile.pace.source, "reference");
assert.equal(r.body.profile.totalPlans, 0);
});
test("machine-profile: learns a pace factor from this account's own completed roasts, and only this account's", async () => {
const { app } = await setup();
const owner = request.agent(app);
const { csrf } = await signup(owner, "[email protected]");
// Every roast here ran 20% slower than its own anchor (7:30=450s -> actual 9:00=540s, ratio 1.2).
await owner
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: planBody({ planActual: { ...planBody().planActual, fc: { actualTime: "9:00", actualBt: "" } } }) });
await owner
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({
plan: planBody({
fields: { "2.1": "single", "1.4": "8:00", "1.6": "0", "5.6": "" }, // 480s
planActual: { ...planBody().planActual, fc: { actualTime: "9:36", actualBt: "" } }, // 576s, ratio 1.2
}),
});
const other = request.agent(app);
await signup(other, "[email protected]");
const ownerProfile = (await owner.get("/api/machine-profile")).body.profile;
assert.equal(ownerProfile.pace.n, 2);
assert.equal(ownerProfile.pace.source, "learned");
assert.ok(Math.abs(ownerProfile.pace.value - 1.2) < 0.001, `expected ~1.2, got ${ownerProfile.pace.value}`);
// The other account's history is empty — it must not see the owner's learned pace.
const otherProfile = (await other.get("/api/machine-profile")).body.profile;
assert.equal(otherProfile.pace.source, "reference");
assert.equal(otherProfile.pace.value, 1);
});
test("last-refine: requires auth and 404s on a lot that doesn't belong to the caller", async () => {
const { app } = await setup();
const ownerAgent = request.agent(app);
const { csrf } = await signup(ownerAgent, "[email protected]");
const lot = (
await ownerAgent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Huila", initialWeightG: 1000 })
).body.lot;
const anon = request.agent(app);
assert.equal((await anon.get(`/api/inventory/${lot.id}/last-refine`)).status, 401);
const otherAgent = request.agent(app);
await signup(otherAgent, "[email protected]");
assert.equal((await otherAgent.get(`/api/inventory/${lot.id}/last-refine`)).status, 404);
});
test("last-refine: null when no past roast against the lot has a filled-in one-change note", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const lot = (
await agent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Huila", initialWeightG: 1000 })
).body.lot;
await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: planBody({ inventory: { lotId: lot.id, lotLabel: "Huila", consumed: null } }) });
const r = await agent.get(`/api/inventory/${lot.id}/last-refine`);
assert.equal(r.status, 200);
assert.equal(r.body.refine, null);
});
test("last-refine: returns the most recent filled-in note for that lot, ignoring blank ones and other lots", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const lotA = (
await agent.post("/api/inventory").set("x-csrf-token", csrf).send({ origin: "Lot A", initialWeightG: 1000 })
).body.lot;
const lotB = (
await agent.post("/api/inventory").set("x-csrf-token", csrf).send({ origin: "Lot B", initialWeightG: 1000 })
).body.lot;
// Older roast on lot A, with a note.
await agent.post("/api/plans").set("x-csrf-token", csrf).send({
plan: planBody({
inventory: { lotId: lotA.id, lotLabel: "Lot A", consumed: null },
afterRoast: { oneChange: "First crack +0:30" },
}),
});
// A blank-note roast on lot A (should never win over a real note, regardless of recency).
await agent.post("/api/plans").set("x-csrf-token", csrf).send({
plan: planBody({ inventory: { lotId: lotA.id, lotLabel: "Lot A", consumed: null } }),
});
// A roast on the OTHER lot with a note — must not leak into lot A's answer.
await agent.post("/api/plans").set("x-csrf-token", csrf).send({
plan: planBody({
inventory: { lotId: lotB.id, lotLabel: "Lot B", consumed: null },
afterRoast: { oneChange: "Development 0:15" },
}),
});
const r = await agent.get(`/api/inventory/${lotA.id}/last-refine`);
assert.equal(r.status, 200);
assert.equal(r.body.refine.oneChange, "First crack +0:30");
});