File size: 8,532 Bytes
ac47756
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
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.`);