Test and deploy / test-and-deploy (push) Successful in 1m8s
The planner's Artisan .alog drawer now shows what's attached with a "Remove reference curve" control (re-rendered per drawer open), so adding a reference curve is no longer a one-way door; /api/alog shares the 8 MB body cap so real-sized logs parse instead of failing with "bad_request". Deep-evaluation fixes: editing a brew of an archived bean no longer silently detaches the bean; the roasts pending-review poll no longer wipes in-progress after-roast edits; roasters gain an Edit (rename/model) action; the gear page refuses to autosave over a failed load; duplicating a plan carries its custom name; cupping sessions can attach a plan after creation (ownership-checked PUT + selector); admin user deletion also refreshes plans/audit; cupping cup-count subtitle stays live; roasts error-row colspan corrected. Regression tests cover the new cupping PUT and the /api/alog body cap. Academy scenes drop the flat paper-cutout look: shared defs provide radial-gradient shading on every bean/half-bean/particle, flame gradients with radiant halos, soft ground shadows, and a warm-lit stage background; fill-shift animations now ride a partial-opacity tint overlay so shading survives the color change. Co-Authored-By: Claude Fable 5 <[email protected]>
345 lines
13 KiB
JavaScript
345 lines
13 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);
|
|
});
|
|
|
|
test("reference-curve parse endpoint accepts a real-sized (multi-MB) .alog", async () => {
|
|
const { agent } = await setup({}, { evaluateRoast: evaluationStub() });
|
|
const { csrf } = await signup(agent, "[email protected]");
|
|
// Real Artisan logs carry full telemetry arrays and legitimately exceed 1 MB —
|
|
// /api/alog must share the enlarged body cap that /api/roasts already has.
|
|
const doc = JSON.parse(makeAlog("Big log"));
|
|
doc.padding = "x".repeat(2 * 1024 * 1024);
|
|
const res = await agent
|
|
.post("/api/alog")
|
|
.set("x-csrf-token", csrf)
|
|
.send({ filename: "big.alog", content: JSON.stringify(doc) });
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.ok, true);
|
|
assert.equal(res.body.roast.title, "Big log");
|
|
});
|