Files
roast_command_center/test/roasts.test.js
T
Shane MaynardandClaude Fable 5 4a3d192c2c
Test and deploy / test-and-deploy (push) Successful in 56s
Move After-the-Roast onto actual roasts; add Artisan-importable updated .alog download
One system: post-roast observations (weights, colour, DTR, cup notes,
'one change next batch', disproof) now live on the uploaded roast
(actual_roasts.after, migration 011), edited on the roast detail view
with weights/DTR prefilled from the .alog itself. The planner's screen
section is removed (the print worksheet keeps its hand-fill copy), and
the lot 'last refine' suggestion reads the note from both the new home
and legacy plans, newest wins.

Every roast now downloads two ways: the untouched original .alog, and
an updated supplemental copy with the app's data written back as valid
Artisan fields (weight, beans, roastingnotes incl. the LLM review,
cuppingnotes incl. linked cupping score/flavors) — serialized as a
Python literal so Artisan's ast.literal_eval re-imports it.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-09 08:35:11 -04:00

329 lines
12 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { setup, signup } from "./helpers.js";
// Minimal but realistic Artisan .alog: 21 samples at 30s intervals, milestones marked via
// timeindex (charge, dry end, first crack, drop).
function makeAlog(title = "Test Roast") {
const timex = [];
const temp1 = [];
const temp2 = [];
for (let i = 0; i <= 20; i++) {
const t = i * 30;
timex.push(t);
temp1.push(200 + i * 2);
// BT: drop to a turning point then climb
temp2.push(i < 3 ? 180 - i * 30 : 90 + (i - 3) * 7);
}
return JSON.stringify({
title,
roastdate: "08.08.2026",
roastertype: "Hottop KN-8828B-2K+",
mode: "C",
weight: [250, 212, "g"],
timex,
temp1,
temp2,
timeindex: [1, 8, 14, 0, 0, 0, 20, 0],
});
}
async function pollStatus(agent, id, want, tries = 100) {
for (let i = 0; i < tries; i++) {
const res = await agent.get(`/api/roasts/${id}`);
if (res.body.roast?.evaluationStatus === want) return res.body.roast;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`evaluation never reached status "${want}"`);
}
function evaluationStub() {
const calls = [];
let impl = async () => ({
summary: "Clean roast, slightly long drying.",
grade: "good",
highlights: ["DTR 20% in band"],
concerns: [],
suggestions: ["Charge 5C hotter"],
planComparison: "Tracked the plan within 20s.",
});
const stub = (parsed, plan) => {
calls.push({ parsed, plan });
return impl(parsed, plan);
};
stub.calls = calls;
stub.setImpl = (fn) => {
impl = fn;
};
return stub;
}
test("upload, evaluate, list, detail, download, delete", async () => {
const stub = evaluationStub();
const { agent } = await setup({}, { evaluateRoast: stub });
const { csrf } = await signup(agent, "[email protected]");
const plan = await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: { 0.1: "Kenya AA" } } });
assert.equal(plan.status, 201);
const planId = plan.body.plan.id;
// Upload attached to the plan
const up1 = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ roastPlanId: planId, filename: "roast-1.alog", content: makeAlog("Roast one") });
assert.equal(up1.status, 201);
assert.equal(up1.body.roast.evaluationStatus, "pending");
assert.equal(up1.body.roast.roast.title, "Roast one");
assert.equal(up1.body.roast.derived.dtrPct > 0, true);
// The async deep evaluation lands on the row
const done = await pollStatus(agent, up1.body.roast.id, "done");
assert.equal(done.evaluation.grade, "good");
assert.equal(done.evaluation.summary, "Clean roast, slightly long drying.");
// The stub was handed the linked plan for plan-vs-actual comparison
assert.equal(stub.calls[0].plan.fields["0.1"], "Kenya AA");
// A second upload against the same plan is allowed (multiple actuals per plan)
const up2 = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ roastPlanId: planId, filename: "roast-2.alog", content: makeAlog("Roast two") });
assert.equal(up2.status, 201);
await pollStatus(agent, up2.body.roast.id, "done");
// List shows both, newest first, with plan title but no heavy payloads
const list = await agent.get("/api/roasts");
assert.equal(list.body.roasts.length, 2);
assert.equal(list.body.roasts[0].planTitle, "Kenya AA");
assert.equal(list.body.roasts[0].parsed, undefined);
const filtered = await agent.get(`/api/roasts?plan=${planId}`);
assert.equal(filtered.body.roasts.length, 2);
// Detail includes full parsed curve, evaluation, and the linked plan
const detail = await agent.get(`/api/roasts/${up1.body.roast.id}`);
assert.equal(detail.status, 200);
assert.equal(detail.body.roast.parsed.curve.length > 0, true);
assert.equal(detail.body.roast.parsed.milestones.some((m) => m.key === "fc"), true);
assert.equal(detail.body.roast.plan.fields["0.1"], "Kenya AA");
// Download returns the original bytes as an attachment
const download = await agent.get(`/api/roasts/${up1.body.roast.id}/download`);
assert.equal(download.status, 200);
assert.match(download.headers["content-disposition"], /attachment; filename="roast-1.alog"/);
assert.equal(download.body.toString("utf8"), makeAlog("Roast one"));
// Delete
const del = await agent
.delete(`/api/roasts/${up1.body.roast.id}`)
.set("x-csrf-token", csrf);
assert.equal(del.status, 200);
assert.equal((await agent.get("/api/roasts")).body.roasts.length, 1);
});
test("upload without a plan, garbage rejected, csrf required", async () => {
const stub = evaluationStub();
const { agent } = await setup({}, { evaluateRoast: stub });
const { csrf } = await signup(agent, "[email protected]");
const up = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ filename: "standalone.alog", content: makeAlog("No plan") });
assert.equal(up.status, 201);
assert.equal(up.body.roast.roastPlanId, null);
const done = await pollStatus(agent, up.body.roast.id, "done");
assert.equal(done.plan, null);
assert.equal(stub.calls[0].plan, null);
const bad = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ filename: "junk.alog", content: "definitely not an alog {{{" });
assert.equal(bad.status, 422);
assert.equal(bad.body.code, "unparseable_alog");
const empty = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ filename: "x.alog" });
assert.equal(empty.status, 400);
const noCsrf = await agent
.post("/api/roasts")
.send({ filename: "x.alog", content: makeAlog() });
assert.equal(noCsrf.status, 403);
});
test("failed evaluation records the error and can be retried", async () => {
const stub = evaluationStub();
stub.setImpl(async () => {
const err = new Error("No model available from ~/.pi/agent config.");
err.code = "no_model";
throw err;
});
const { agent } = await setup({}, { evaluateRoast: stub });
const { csrf } = await signup(agent, "[email protected]");
const up = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ filename: "r.alog", content: makeAlog() });
assert.equal(up.status, 201);
const failed = await pollStatus(agent, up.body.roast.id, "failed");
assert.match(failed.evaluationError, /no_model/);
// Model becomes available; retry succeeds
stub.setImpl(async () => ({
summary: "Recovered.",
grade: "fair",
highlights: [],
concerns: [],
suggestions: [],
planComparison: null,
}));
const retry = await agent
.post(`/api/roasts/${up.body.roast.id}/evaluate`)
.set("x-csrf-token", csrf);
assert.equal(retry.status, 200);
const done = await pollStatus(agent, up.body.roast.id, "done");
assert.equal(done.evaluation.summary, "Recovered.");
assert.equal(done.evaluationError, null);
});
test("/roasts shell page requires a session and is never served by filename", async () => {
const { agent } = await setup({}, { evaluateRoast: evaluationStub() });
const anonymous = await agent.get("/roasts");
assert.equal(anonymous.status, 302);
assert.equal(anonymous.headers.location, "/login");
assert.equal((await agent.get("/roasts.html")).status, 302);
await signup(agent, "[email protected]");
const page = await agent.get("/roasts");
assert.equal(page.status, 200);
assert.match(page.text, /Roast history/);
});
test("after-the-roast lives on the actual roast and flows into the updated .alog", async () => {
const stub = evaluationStub();
const { agent } = await setup({}, { evaluateRoast: stub });
const { csrf } = await signup(agent, "[email protected]");
const plan = await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: { "0.1": "After test coffee" }, inventory: {} } });
const up = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ roastPlanId: plan.body.plan.id, filename: "after.alog", content: makeAlog("After roast") });
assert.equal(up.status, 201);
const id = up.body.roast.id;
// Save observations; junk body rejected
const put = await agent.put(`/api/roasts/${id}`).set("x-csrf-token", csrf).send({
after: {
greenIn: "250",
out: "212",
colour: "62/78",
oneChange: "first crack +0:30",
cupNotes: "papery, thin",
method: "V60 1:16",
nonsense: "dropped",
},
});
assert.equal(put.status, 200);
assert.equal(put.body.after.oneChange, "first crack +0:30");
assert.equal(put.body.after.nonsense, undefined);
assert.equal(
(await agent.put(`/api/roasts/${id}`).set("x-csrf-token", csrf).send({ after: "x" })).status,
400,
);
const detail = (await agent.get(`/api/roasts/${id}`)).body.roast;
assert.equal(detail.after.colour, "62/78");
// Original download unchanged; updated download is a Python literal with the app's data
const original = await agent.get(`/api/roasts/${id}/download`);
assert.equal(original.body.toString("utf8"), makeAlog("After roast"));
const updated = await agent.get(`/api/roasts/${id}/download?variant=updated`);
assert.equal(updated.status, 200);
assert.match(updated.headers["content-disposition"], /after-updated\.alog/);
const text = updated.body.toString("utf8");
assert.match(text, /"weight": \[250, 212, "g"\]/);
assert.match(text, /One change next batch: first crack \+0:30/);
assert.match(text, /papery, thin/);
assert.match(text, /"beans": "After test coffee"/);
// Round-trips through our own Artisan parser (same tolerant path Artisan's literal_eval uses)
const { parseAlog } = await import("../server/alog.js");
const reparsed = parseAlog(text, "after-updated.alog");
assert.equal(reparsed.roast.weightInG, 250);
assert.equal(reparsed.roast.title, "After roast");
// No JSON booleans/nulls leak into the Python literal
assert.equal(/\b(true|false|null)\b/.test(text), false);
});
test("last-refine reads the one-change note from actual roasts (new home) and legacy plans", async () => {
const stub = evaluationStub();
const { agent } = await setup({}, { evaluateRoast: stub });
const { csrf } = await signup(agent, "[email protected]");
const lot = (
await agent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Kenya", initialWeightG: 1000 })
).body.lot;
const plan = (
await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: { "0.1": "Lot plan" }, inventory: { lotId: lot.id } } })
).body.plan;
// Note stored on the actual roast — the new home
const up = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ roastPlanId: plan.id, filename: "r.alog", content: makeAlog("R") });
await agent
.put(`/api/roasts/${up.body.roast.id}`)
.set("x-csrf-token", csrf)
.send({ after: { oneChange: "development +0:15" } });
const refine = await agent.get(`/api/inventory/${lot.id}/last-refine`);
assert.equal(refine.status, 200);
assert.equal(refine.body.refine.oneChange, "development +0:15");
assert.equal(refine.body.refine.planTitle, "Lot plan");
});
test("roasts are private to their owner", async () => {
const stub = evaluationStub();
const { app, agent } = await setup({}, { evaluateRoast: stub });
const { csrf } = await signup(agent, "[email protected]");
const up = await agent
.post("/api/roasts")
.set("x-csrf-token", csrf)
.send({ filename: "mine.alog", content: makeAlog("Private") });
assert.equal(up.status, 201);
const id = up.body.roast.id;
const request = (await import("supertest")).default;
const stranger = request.agent(app);
const { csrf: strangerCsrf } = await signup(stranger, "[email protected]");
assert.equal((await stranger.get("/api/roasts")).body.roasts.length, 0);
assert.equal((await stranger.get(`/api/roasts/${id}`)).status, 404);
assert.equal((await stranger.get(`/api/roasts/${id}/download`)).status, 404);
assert.equal(
(await stranger.delete(`/api/roasts/${id}`).set("x-csrf-token", strangerCsrf)).status,
404,
);
// A stranger's plan id can't be attached to my upload either
const plan = await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: {} } });
const crossPlan = await stranger
.post("/api/roasts")
.set("x-csrf-token", strangerCsrf)
.send({ roastPlanId: plan.body.plan.id, filename: "x.alog", content: makeAlog() });
assert.equal(crossPlan.status, 404);
});