diff --git a/server/prefill.js b/server/prefill.js index da7a1d5..1f11314 100644 --- a/server/prefill.js +++ b/server/prefill.js @@ -24,6 +24,7 @@ Schema: { "coffeeName": string|null, "cultivar": string|null, // e.g. "Bourbon", "Gesha" — the single named variety, if any + "cultivarGroup": string|null, // one of: "typica","bourbon","ethiopian","hybrid" — the variety's genetic/roast-behavior family. Use your knowledge of coffee genetics to classify the named variety even when it is obscure (e.g. Wush Wush -> "ethiopian", Villa Sarchi -> "bourbon", Ruiru 11 -> "hybrid"). null only if no variety is stated or you genuinely cannot classify it "origin": string|null, // country/region as stated "producer": string|null, "process": string|null, // one of: "washed","yellow-honey","red-black-honey","natural","anaerobic" — pick the closest match, or null @@ -132,8 +133,9 @@ function parseModelJson(raw) { } /** Deterministic mapping from extracted page facts -> worksheet field IDs. The model never - * supplies a time/duration directly — every number here comes from shared/reference-data.js. */ -function deriveFields(extracted) { + * supplies a time/duration directly — every number here comes from shared/reference-data.js. + * Exported for unit tests. */ +export function deriveFields(extracted) { const fields = {}; const provenance = {}; const warnings = []; @@ -159,7 +161,20 @@ function deriveFields(extracted) { set("1.7", formatSigned(row.devModS), "cultivar-table"); } else { set("1.1", extracted.cultivar, "page"); - warnings.push(`Cultivar "${extracted.cultivar}" is not in the reference table — fields 1.2/1.4/1.5/1.7 left blank; pick the nearest group by hand.`); + // Unknown cultivar: fall back to its GROUP's first-crack anchor so the ledger still + // computes, instead of leaving 1.4 blank and silently breaking every downstream number. + // The group comes from the model's genetics classification, but the NUMBER still only + // ever comes from the reference table. + const group = findGroup(extracted.cultivarGroup); + const groupAnchorS = group ? parseRangeMidpoint(group.fcAnchor) : null; + if (group && groupAnchorS !== null) { + set("1.2", group.key, "group-table"); + set("1.4", formatDuration(groupAnchorS), "group-table"); + set("1.7", "+0:00", "group-table"); + warnings.push(`Cultivar "${extracted.cultivar}" is not in the reference table — using the ${group.label} group anchor (${group.fcAnchor}) as the first-crack target. Adjust field 1.4 if you know this variety behaves differently.`); + } else { + warnings.push(`Cultivar "${extracted.cultivar}" is not in the reference table — fields 1.2/1.4/1.5/1.7 left blank; pick the nearest group by hand.`); + } } } diff --git a/test/prefill-derive.test.js b/test/prefill-derive.test.js new file mode 100644 index 0000000..6d8b949 --- /dev/null +++ b/test/prefill-derive.test.js @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { deriveFields } from "../server/prefill.js"; +import { computeLedger } from "../shared/ledger.js"; + +const base = { + coffeeName: "Test Coffee", + cultivar: null, + cultivarGroup: null, + origin: "Colombia", + producer: null, + process: "washed", + roastLevel: "light", + tastingNotes: [], + altitudeMasl: null, + screen: null, + moisturePct: null, + isBlend: false, + blendComponents: [], +}; + +test("known cultivar uses the cultivar table row", () => { + const { fields, provenance } = deriveFields({ ...base, cultivar: "Gesha" }); + assert.equal(fields["1.1"], "Gesha"); + assert.equal(fields["1.2"], "ethiopian"); + assert.equal(fields["1.4"], "7:30"); + assert.equal(provenance["1.4"], "cultivar-table"); +}); + +test("unknown cultivar with a classified group falls back to the group anchor so the ledger computes", () => { + const { fields, provenance, warnings } = deriveFields({ + ...base, + cultivar: "Wush Wush", + cultivarGroup: "ethiopian", + }); + assert.equal(fields["1.1"], "Wush Wush"); + assert.equal(fields["1.2"], "ethiopian"); + assert.equal(fields["1.4"], "7:25"); // midpoint of the ethiopian group anchor 7:20-7:30 + assert.equal(provenance["1.4"], "group-table"); + assert.match(warnings.join(" "), /group anchor/); + // The whole point: first crack is now computable + const ledger = computeLedger({ fields }); + assert.equal(ledger.A !== null, true); +}); + +test("unknown cultivar in the unparseable hybrid group still degrades gracefully", () => { + const { fields, warnings } = deriveFields({ + ...base, + cultivar: "Mystery F1", + cultivarGroup: "hybrid", // group anchor is "follows parent" — not a number + }); + assert.equal(fields["1.1"], "Mystery F1"); + assert.equal(fields["1.4"], undefined); + assert.match(warnings.join(" "), /pick the nearest group by hand/); +}); + +test("unknown cultivar with no group keeps the old blank-fields warning", () => { + const { fields, warnings } = deriveFields({ ...base, cultivar: "Zebra SP" }); + assert.equal(fields["1.4"], undefined); + assert.match(warnings.join(" "), /pick the nearest group by hand/); +});