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]>
117 lines
4.7 KiB
JavaScript
117 lines
4.7 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { computeLedger } from "../shared/ledger.js";
|
|
|
|
function plan(fields = {}) {
|
|
return {
|
|
fields: {
|
|
"2.1": "single",
|
|
"1.4": "8:00", // anchor: 480s
|
|
"1.6": "0", // refine
|
|
"5.6": "", // bean-condition
|
|
"6.4": "", // batch-size correction
|
|
"4.3": "2:00", // dev base: 120s
|
|
"3.2": "", // process modifier
|
|
"1.7": "", // cultivar dev modifier
|
|
...fields,
|
|
},
|
|
};
|
|
}
|
|
|
|
test("ledger: no machineProfile argument behaves exactly as before (pace 1.0)", () => {
|
|
const withNone = computeLedger(plan());
|
|
const withNull = computeLedger(plan(), null);
|
|
const withExplicit1 = computeLedger(plan(), { pace: { value: 1 } });
|
|
assert.equal(withNone.A, 480);
|
|
assert.deepEqual(withNone, withNull);
|
|
assert.deepEqual(withNone, withExplicit1);
|
|
});
|
|
|
|
test("ledger: this is the one pre-existing caller's exact shape — server/prefill.js calls computeLedger({fields}) with no second argument", () => {
|
|
// Guards against a future change to computeLedger's signature silently breaking the only
|
|
// caller that doesn't know about machineProfile at all.
|
|
const ledger = computeLedger({ fields: plan().fields });
|
|
assert.equal(ledger.A, 480);
|
|
assert.equal(Array.isArray(ledger.warnings), true);
|
|
});
|
|
|
|
test("ledger: pace multiplies the whole pre-pace sum, including the manual batch correction", () => {
|
|
// anchor 8:00 (480s) + 6.4 +1:00 (60s) = 540s base; pace 0.9 -> A = round(540*0.9) = 486s = 8:06.
|
|
// This is the fix for the double-counting bug: pace applies to l4 too, not just l1-l3.
|
|
const ledger = computeLedger(plan({ "6.4": "+1:00" }), { pace: { value: 0.9 } });
|
|
assert.equal(ledger.A, 486);
|
|
assert.equal(ledger.lines.l4, 60);
|
|
});
|
|
|
|
test("ledger: subtotalA and pace are exposed and reconstruct A exactly — this is what the printed worksheet's disclosure rows display", () => {
|
|
const ledger = computeLedger(plan({ "6.4": "+1:00" }), { pace: { value: 0.9 } });
|
|
assert.equal(ledger.subtotalA, 540); // 480 + 60, before pace
|
|
assert.equal(ledger.pace, 0.9);
|
|
assert.equal(ledger.A, Math.round(ledger.subtotalA * ledger.pace));
|
|
});
|
|
|
|
test("ledger: pace of 1.1 on a plan with refine and bean-condition lines", () => {
|
|
// anchor 8:00 (480) + refine +0:15 (15) + condition -0:05 (-5) = 490s base; pace 1.1 -> round(539) = 539.
|
|
const ledger = computeLedger(plan({ "1.6": "+0:15", "5.6": "-0:05" }), {
|
|
pace: { value: 1.1 },
|
|
});
|
|
assert.equal(ledger.A, 539);
|
|
});
|
|
|
|
test("ledger: yellow and Maillard are derived from the paced A, not the pre-pace sum", () => {
|
|
const ledger = computeLedger(plan(), { pace: { value: 0.9 } });
|
|
assert.equal(ledger.A, 432); // 480 * 0.9
|
|
assert.equal(ledger.yellow, Math.round(432 * 0.56));
|
|
assert.equal(ledger.maillard, ledger.A - ledger.yellow);
|
|
});
|
|
|
|
test("ledger: a missing pace value on a malformed profile object falls back to 1.0, not NaN", () => {
|
|
const ledger = computeLedger(plan(), {});
|
|
assert.equal(ledger.A, 480);
|
|
assert.equal(Number.isNaN(ledger.A), false);
|
|
});
|
|
|
|
test("ledger: drying and Maillard checks are informational; only DTR and ceiling can actually fail", () => {
|
|
const ledger = computeLedger(plan());
|
|
assert.equal(ledger.checks.drying.informational, true);
|
|
assert.equal(ledger.checks.maillard.informational, true);
|
|
assert.equal(ledger.checks.dtr.informational, undefined);
|
|
assert.equal(ledger.checks.ceiling.informational, undefined);
|
|
});
|
|
|
|
test("ledger: drying and Maillard can never fail when DTR passes — the tautology the reclassification documents", () => {
|
|
// Sweep a range of plausible plans and confirm the algebraic guarantee: whenever DTR is
|
|
// in-band, drying and Maillard are too (this is *why* they're informational, not a check).
|
|
for (const anchor of ["7:00", "7:30", "8:00", "8:45", "9:30"]) {
|
|
for (const dev of ["1:30", "2:00", "2:45", "3:15"]) {
|
|
const ledger = computeLedger(plan({ "1.4": anchor, "4.3": dev }));
|
|
if (ledger.checks.dtr.pass) {
|
|
assert.equal(
|
|
ledger.checks.drying.pass,
|
|
true,
|
|
`anchor=${anchor} dev=${dev}: DTR passed but drying failed`,
|
|
);
|
|
assert.equal(
|
|
ledger.checks.maillard.pass,
|
|
true,
|
|
`anchor=${anchor} dev=${dev}: DTR passed but Maillard failed`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test("ledger: no anchor means A, D and every downstream value are null, with a warning", () => {
|
|
const ledger = computeLedger(plan({ "1.4": "" }));
|
|
assert.equal(ledger.A, null);
|
|
assert.equal(ledger.yellow, null);
|
|
assert.equal(ledger.D, null);
|
|
assert.ok(ledger.warnings.some((w) => w.includes("cultivar/blend anchor")));
|
|
});
|
|
|
|
test("ledger: blend mode reads 2.4 instead of 1.4 for the anchor", () => {
|
|
const ledger = computeLedger(plan({ "2.1": "blend", "2.4": "8:30", "1.4": "1:00" }));
|
|
assert.equal(ledger.isBlend, true);
|
|
assert.equal(ledger.A, 510); // 8:30, not 1:00 -- 1.4 is ignored while blend is selected
|
|
});
|