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]>
146 lines
5.4 KiB
JavaScript
146 lines
5.4 KiB
JavaScript
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");
|
||
});
|