| 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 = '<script>alert("name")</script>'; |
| 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.`); |
|
|