threshold / tests /study.test.mjs
citispire
Publish editable A/B climate studies and discussion briefs
ac47756
Raw
History Blame Contribute Delete
8.53 kB
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.`);