import assert from "node:assert/strict"; import { defaults, climates, limits, simulateDay, summarize } from "../build/model.mjs"; import { StudyWorkspace, newStudy, validateStudy, saveStudy, parseStudy, evaluateStudy, studyBrief, comparisonSentence, peakInterval, signed, STUDY_LIMIT, } from "../build/study.mjs"; let count = 0; const test = (label, run) => { run(); count++; console.log("PASS", label); }; const near = (a, b, e = 1e-9) => assert.ok(Math.abs(a - b) < e, `${a} != ${b}`); const pair = (climate, A, B) => ({ ...newStudy(), climate, alternatives: { A: { name: "Starting point", design: A }, B: { name: "Test", design: B } }, }); test("Strict study round-trips all design boundaries and viewing fields", () => { let cases = 0; for (const climate of Object.keys(climates)) for (const active of ["A", "B"]) for (const lens of ["light", "heat", "air"]) for (const end of [0, 1]) { const design = Object.fromEntries( Object.entries(limits) .filter(([key]) => key !== "hour") .map(([key, bounds]) => [key, bounds[end]]), ); const s = { ...pair(climate, design, defaults), active, lens, hour: end ? 23 : 0 }; assert.deepEqual(parseStudy(saveStudy(s)), s); cases++; } assert.equal(cases, 36); }); test("Reject malformed alternatives, unknown/derived fields, coercions and unsafe names", () => { const base = newStudy(); for (const mutation of [ (s) => (s.summary = {}), (s) => (s.model = "future"), (s) => (s.active = "C"), (s) => (s.hour = 24), (s) => (s.climate = "constructor"), (s) => (s.lens = "daylight"), (s) => (s.alternatives.A.extra = 1), (s) => (s.alternatives.B.design.mass = "50"), (s) => delete s.alternatives.B.design.shade, (s) => (s.alternatives.B.design.solar = 8), (s) => (s.alternatives.A.name = ""), (s) => (s.alternatives.A.name = "x".repeat(61)), ...["\n", "\u0085", "\u2028", "\u2029", "\ud800"].map( (c) => (s) => (s.alternatives.A.name = "a" + c + "b"), ), ]) { const s = structuredClone(base); mutation(s); assert.throws(() => validateStudy(s)); } assert.throws(() => parseStudy("{")); assert.throws(() => parseStudy(" ".repeat(STUDY_LIMIT + 1)), /32 KiB/); assert.throws(() => parseStudy("界".repeat(STUDY_LIMIT / 2)), /32 KiB/); }); test("Comparison is B minus A, symmetric on swap, independent of names/view selection", () => { const s = newStudy(), r = evaluateStudy(s), swapped = evaluateStudy({ ...s, alternatives: { A: s.alternatives.B, B: s.alternatives.A } }); for (const key of Object.keys(r.delta)) near(r.delta[key], -swapped.delta[key]); const same = evaluateStudy(pair("humid", defaults, defaults)); for (const value of Object.values(same.delta)) near(value, 0); const rename = structuredClone(s); rename.active = "A"; rename.hour = 2; rename.lens = "air"; rename.alternatives.B.name = "Changed label"; assert.deepEqual(evaluateStudy(rename).delta, r.delta); const calls = []; evaluateStudy({ ...s, climate: "arid" }, (climate, design) => { calls.push(climate); const simulation = simulateDay(climates[climate], design); return { simulation, summary: summarize(simulation) }; }); assert.deepEqual(calls, ["arid", "arid"]); }); test("Shade, mass and ventilation trade-offs use actual model outputs, not blanket improvement claims", () => { let r = evaluateStudy(pair("temperate", { ...defaults, shade: 0 }, { ...defaults, shade: 2.4 })); assert.ok(r.delta.solarKWh < 0 && r.delta.peakC < 0 && r.delta.bandHours > 0); r = evaluateStudy(pair("arid", { ...defaults, mass: 0 }, { ...defaults, mass: 100 })); assert.ok(r.delta.peakC < 0 && r.delta.bandHours < 0); assert.match(comparisonSentence(r), /separate trade-offs/); assert.match(r.changes[0].mechanism, /need not/); const d = { ...defaults, orientation: 0, glazing: 12, shade: 0, mass: 100 }; r = evaluateStudy(pair("humid", { ...d, ventilation: 5 }, { ...d, ventilation: 100 })); assert.ok(r.delta.peakC < 0 && r.delta.meanACH < 0); assert.match(r.changes[0].mechanism, /need not/); }); test("Either unsettled day suppresses settled comparison claims; peak interval has step bounds", () => { const s = newStudy(); let i = 0; const r = evaluateStudy(s, (climate, design) => { const simulation = simulateDay(climates[climate], design, ++i === 1 ? { maximumDays: 1 } : {}); return { simulation, summary: summarize(simulation) }; }); assert.equal(r.settled, false); assert.match(comparisonSentence(r), /^Unsettled comparison/); assert.doesNotMatch(comparisonSentence(r), /B minus A/); assert.equal( peakInterval({ simulation: { minutes: 10 }, summary: { peak: { hour: 16 + 5 / 60 } } }), "4:00 PM–4:10 PM", ); assert.equal(signed(-0.0001), "0.00"); }); test("Raw invalid names block commands and exports; applying invalid drafts is atomic", () => { const w = new StudyWorkspace(), before = w.read().study; w.editName("A", ""); const frozen = JSON.stringify(w.read()); for (const action of [ () => w.applyNames(), () => w.configure({ shade: 2 }), () => w.select("A"), () => w.copy(), () => w.request(newStudy(), "Reset"), () => w.beginFile(), () => w.save(), () => w.brief(), ]) { assert.throws(action); assert.equal(JSON.stringify(w.read()), frozen); } w.discardNames(); assert.deepEqual(w.read().study, before); w.editName("A", " First idea "); w.applyNames(); assert.equal(w.read().study.alternatives.A.name, "First idea"); const applied = JSON.stringify(w.read()); assert.throws(() => w.configure({ shade: 1, climate: "bad" })); assert.equal(JSON.stringify(w.read()), applied); }); test("Copy/reset confirmations preserve names and values until explicit acceptance", () => { const w = new StudyWorkspace(); w.editName("A", "Keep this name"); w.applyNames(); const before = w.read().study; w.copy(); assert.deepEqual(w.read().study, before); assert.throws(() => w.configure({ shade: 2 })); assert.throws(() => w.editName("A", "No")); assert.throws(() => w.save()); w.dismiss(); assert.deepEqual(w.read().study, before); w.copy(); w.confirm(); assert.deepEqual(w.read().study.alternatives.A.design, before.alternatives.B.design); assert.equal(w.read().study.alternatives.A.name, "Keep this name"); w.request(newStudy(), "Reset"); w.dismiss(); assert.equal(w.read().study.alternatives.A.name, "Keep this name"); w.request(newStudy(), "Reset"); w.confirm(); assert.deepEqual(w.read().study, newStudy()); }); test("File locks, malformed/cancelled imports and stale reads cannot overwrite work", () => { const w = new StudyWorkspace(), original = w.read().study, ticket = w.beginFile(); for (const action of [ () => w.select("A"), () => w.configure({ hour: 2 }), () => w.editName("A", "New"), () => w.beginFile(), () => w.save(), ]) assert.throws(action); assert.throws(() => w.stageFile(ticket, "bad")); assert.deepEqual(w.read().study, original); w.cancelFile(ticket); w.configure({ hour: 3 }); const newer = w.read(), frozen = JSON.stringify(newer); assert.throws(() => w.stageFile(ticket, saveStudy(original)), /stale/); w.cancelFile(ticket); assert.equal(JSON.stringify(w.read()), frozen); const next = w.beginFile(); w.stageFile(next, saveStudy(original)); assert.equal(w.read().study.hour, 3); w.dismiss(); assert.equal(w.read().study.hour, 3); const last = w.beginFile(); w.stageFile(last, saveStudy(original)); w.confirm(); assert.deepEqual(w.read().study, original); }); test("Reports are deterministic, include both inputs/trade-offs and isolate supplied names", () => { const s = newStudy(); s.alternatives.A.name = ''; const brief = studyBrief(s); assert.equal(brief, studyBrief(parseStudy(saveStudy(s)))); assert.ok(brief.includes("A: " + JSON.stringify(s.alternatives.A.name))); assert.match(brief, /Multiple settings changed together/); assert.match(brief, /10-minute step-average/); assert.match(brief, /not an editable backup/); assert.match(brief, /Nothing is autosaved/); assert.match(brief, /No HVAC/); assert.match(brief, /SHARED ASSUMPTIONS/); assert.match(brief, /glazing/); assert.match(studyBrief(pair("humid", defaults, defaults)), /identical settings/); }); console.log(`\n${count} consumer test groups passed.`);