@bongos/core 1.24.86 → 1.24.88
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +76 -66
- package/docs/adr/0402-helm-is-the-halls-look-and-the-hall-is-dark-only.md +15 -0
- package/docs/copy-inventory.md +6 -6
- package/docs/copy-registry.json +6 -6
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -0
- package/docs/page-readings.json +36 -36
- package/modules/hall-ui/public/ai-studio.html +1 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city-draw.js +127 -69
- package/modules/hall-ui/public/city.css +37 -3
- package/modules/hall-ui/public/city.html +16 -5
- package/modules/hall-ui/public/city.js +26 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/goal-map.html +2 -2
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/home-city-draw.js +88 -54
- package/modules/hall-ui/public/home-city.css +25 -3
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +6 -4
- package/modules/hall-ui/public/market-watch-item.html +1 -1
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/money.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/sky-time.js +206 -0
- package/modules/hall-ui/public/street.html +2 -2
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +86 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +2 -2
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/visor.css +13 -14
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/home-city.md +13 -0
- package/modules/hall-ui/records/living-city.md +12 -0
- package/modules/hall-ui/records/visor.md +1 -1
- package/modules/ui-design/public/brand.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/hall_city.mjs +72 -22
- package/tests/hall_city_living.mjs +10 -3
- package/tests/hall_sky_time.mjs +361 -0
- package/tests/hall_street.mjs +2 -1
- package/tests/hall_visor.mjs +18 -8
- package/tests/home_city.mjs +54 -5
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// tests/hall_sky_time.mjs — the city in the clouds keeps the hour (task 1005196; ADR 0402).
|
|
2
|
+
//
|
|
3
|
+
// WHAT IT GUARDS. The city on the City page and on Home is painted from four key
|
|
4
|
+
// palettes — night, dawn, midday, dusk — blended for the hour by sky-time.js and spent
|
|
5
|
+
// by the painters as --sky-now-* tokens. What can go wrong:
|
|
6
|
+
// * the blend: a key hour not giving its key palette, a midpoint that is not the
|
|
7
|
+
// midpoint, the clock not wrapping past midnight;
|
|
8
|
+
// * the hour source: a pinned hour (?hour=, #hour=, data-sky-hour-at) not winning, or
|
|
9
|
+
// a bad value moving the clock;
|
|
10
|
+
// * the tokens: a palette literal typed anywhere but the hall tier's :root, a key
|
|
11
|
+
// missing a role, a painter or a sheet typing a colour instead of spending a token,
|
|
12
|
+
// the art tokens leaking into a control or a sentence;
|
|
13
|
+
// * motion: the palette animating, or being recomputed more than once a minute;
|
|
14
|
+
// * the page: the City page failing to draw at one of the four hours (in a browser).
|
|
15
|
+
//
|
|
16
|
+
// Run: node --test tests/hall_sky_time.mjs
|
|
17
|
+
import { test, after } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import vm from 'node:vm';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
28
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8').replace(/\r\n/g, '\n');
|
|
29
|
+
const Sky = require(path.join(PUB, 'sky-time.js'));
|
|
30
|
+
const kit = require('../modules/ui-design/kit/lib.js');
|
|
31
|
+
const strip = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/<!--[\s\S]*?-->/g, '').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1');
|
|
32
|
+
|
|
33
|
+
// ---- the token block, read out of style.css ----
|
|
34
|
+
const STYLE = read('style.css');
|
|
35
|
+
function rootBlock() {
|
|
36
|
+
const at = STYLE.indexOf('\n:root {');
|
|
37
|
+
let depth = 0;
|
|
38
|
+
for (let i = STYLE.indexOf('{', at); i < STYLE.length; i++) {
|
|
39
|
+
if (STYLE[i] === '{') depth += 1;
|
|
40
|
+
else if (STYLE[i] === '}' && --depth === 0) return STYLE.slice(at, i + 1);
|
|
41
|
+
}
|
|
42
|
+
throw new Error('style.css has no :root block');
|
|
43
|
+
}
|
|
44
|
+
const ROOT_BLOCK = rootBlock();
|
|
45
|
+
const TOKENS = {};
|
|
46
|
+
for (const m of strip(ROOT_BLOCK).matchAll(/(--sky-[\w-]+)\s*:\s*([^;]+);/g)) TOKENS[m[1]] = m[2].trim();
|
|
47
|
+
const KEYS = {};
|
|
48
|
+
for (const key of Sky.KEYS) {
|
|
49
|
+
KEYS[key] = {};
|
|
50
|
+
for (const role of Sky.ROLES) KEYS[key][role] = TOKENS[`--sky-${key}-${role}`];
|
|
51
|
+
for (const n of Sky.NUMBERS) KEYS[key][n] = Number(TOKENS[`--sky-${key}-${n}`]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── the tokens ───────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
test('four key palettes, ten roles and two strengths each, as hex literals in the hall tier\'s :root', () => {
|
|
57
|
+
assert.deepEqual([...Sky.KEYS], ['night', 'dawn', 'midday', 'dusk']);
|
|
58
|
+
assert.ok(Sky.ROLES.length <= 10, 'the role count stays small');
|
|
59
|
+
for (const key of Sky.KEYS) {
|
|
60
|
+
for (const role of Sky.ROLES) assert.match(String(KEYS[key][role]), /^#[0-9a-f]{6}$/, `--sky-${key}-${role}`);
|
|
61
|
+
for (const n of Sky.NUMBERS) assert.ok(KEYS[key][n] >= 0 && KEYS[key][n] <= 1, `--sky-${key}-${n} is a strength, 0 to 1`);
|
|
62
|
+
}
|
|
63
|
+
const named = Object.keys(TOKENS).filter((t) => /^--sky-(night|dawn|midday|dusk)-/.test(t));
|
|
64
|
+
assert.equal(named.length, Sky.KEYS.length * (Sky.ROLES.length + Sky.NUMBERS.length), 'no key token the module does not read');
|
|
65
|
+
// the hour's palette rests at night until sky-time.js says otherwise, and carries two mixes
|
|
66
|
+
for (const name of [...Sky.ROLES, ...Sky.NUMBERS]) assert.equal(TOKENS[`--sky-now-${name}`], `var(--sky-night-${name})`, `--sky-now-${name} rests at night`);
|
|
67
|
+
assert.match(TOKENS['--sky-now-haze'], /^color-mix\(in srgb, var\(--sky-now-mid\) \d+%, var\(--sky-now-cloud\)\)$/);
|
|
68
|
+
assert.match(TOKENS['--sky-now-glow'], /var\(--sky-now-lamp\).*var\(--sky-now-windows\)/);
|
|
69
|
+
// night is deep and its windows are lit; midday is bright and warm, with no stars
|
|
70
|
+
const lum = (h) => { const v = Sky.rgbOf(h); return 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2]; };
|
|
71
|
+
assert.ok(lum(KEYS.midday.cloud) > 245 && lum(KEYS.midday.mid) > lum(KEYS.dawn.mid) && lum(KEYS.night.mid) < 20);
|
|
72
|
+
const mid = Sky.rgbOf(KEYS.midday.cloud);
|
|
73
|
+
assert.ok(mid[0] > mid[2], 'midday\'s cloud is warm: more red than blue');
|
|
74
|
+
assert.ok(KEYS.night.windows > KEYS.dusk.windows && KEYS.dusk.windows > KEYS.midday.windows, 'windows glow strongest at night');
|
|
75
|
+
assert.equal(KEYS.night.stars, 1); assert.equal(KEYS.midday.stars, 0);
|
|
76
|
+
assert.ok(KEYS.dusk.stars > 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the palette literals live only in that block: no other sheet, page or script types a sky colour', () => {
|
|
80
|
+
const outside = STYLE.replace(ROOT_BLOCK, '');
|
|
81
|
+
assert.doesNotMatch(outside, /--sky-(night|dawn|midday|dusk)-[\w-]+\s*:/, 'style.css defines the four palettes once, in :root');
|
|
82
|
+
const hexes = new Set(Sky.KEYS.flatMap((k) => Sky.ROLES.map((r) => KEYS[k][r].toLowerCase())));
|
|
83
|
+
const hits = [];
|
|
84
|
+
for (const f of fs.readdirSync(PUB).filter((n) => /\.(css|js|html)$/.test(n) && n !== 'style.css')) {
|
|
85
|
+
const src = strip(read(f));
|
|
86
|
+
if (/--sky-(night|dawn|midday|dusk)-[\w-]+\s*:/.test(src)) hits.push(`${f} defines a key palette`);
|
|
87
|
+
for (const m of src.matchAll(/#[0-9a-f]{6}\b/gi)) if (hexes.has(m[0].toLowerCase())) hits.push(`${f}: ${m[0]}`);
|
|
88
|
+
}
|
|
89
|
+
assert.deepEqual(hits, []);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('the two city painters and the hour module type in no colour at all', () => {
|
|
93
|
+
for (const f of ['city-draw.js', 'home-city-draw.js', 'sky-time.js']) {
|
|
94
|
+
const code = strip(read(f));
|
|
95
|
+
// sky-time.js parses colours, so its regexes name the notations; nothing else may
|
|
96
|
+
const body = f === 'sky-time.js' ? code.replace(/\/\^[^\n]*?\/i?\.exec/g, '') : code;
|
|
97
|
+
assert.deepEqual(body.match(/#[0-9a-f]{6}\b|#[0-9a-f]{3}\b(?![0-9a-z-])|rgba?\(\s*\d|hsla?\(\s*\d/gi) || [], [], `${f} holds no colour literal`);
|
|
98
|
+
}
|
|
99
|
+
for (const f of ['city-draw.js', 'home-city-draw.js']) {
|
|
100
|
+
const code = strip(read(f));
|
|
101
|
+
assert.match(code, /'var\(--sky-now-' \+ role \+ '\)'/, `${f} names the hour's tokens`);
|
|
102
|
+
assert.doesNotMatch(code, /--sky-(night|dawn|midday|dusk)-/, `${f} never reaches for a key palette: only the hour's`);
|
|
103
|
+
assert.doesNotMatch(code, /mode === 'light'/, `${f} has no light palette: the hall is dark only`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('the sky tokens are art: no control, no text and no chrome spends one', () => {
|
|
108
|
+
const hits = [];
|
|
109
|
+
for (const f of fs.readdirSync(PUB).filter((n) => /\.css$/.test(n))) {
|
|
110
|
+
for (const m of strip(read(f)).matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
111
|
+
if (!/--sky-(now|night|dawn|midday|dusk)-/.test(m[2])) continue;
|
|
112
|
+
const sel = m[1].trim();
|
|
113
|
+
if (f === 'style.css' && /(^|\n):root$/.test(sel)) continue; // the token block itself
|
|
114
|
+
// only the two scenes spend them: the City page's frame, stage and cloud; Home's scene, its
|
|
115
|
+
// stage, and (in game mode) the ground of the Home view
|
|
116
|
+
if (!/^(\.city-frame|\.city-stage|\.city-cloud|\.city-stars|\.hc-svg|\.hc-cloud|\.hc-stage|html\[data-game="on"\] body\[data-page="home"\]$)/.test(sel)) { hits.push(`${f}: ${sel}`); continue; }
|
|
117
|
+
for (const d of m[2].split(';')) {
|
|
118
|
+
const prop = d.split(':')[0].trim();
|
|
119
|
+
if (/--sky-/.test(d) && (prop === 'color' || /^border|^outline|^caret|^text-/.test(prop))) hits.push(`${f}: ${sel} { ${prop} }`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
assert.deepEqual(hits, [], 'chrome, panels, text and controls stay Helm night at every hour');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ── the blend (pure) ─────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
test('a key hour gives its key palette exactly', () => {
|
|
129
|
+
for (const key of Sky.KEYS) {
|
|
130
|
+
const p = Sky.blend(Sky.KEY_HOURS[key], KEYS);
|
|
131
|
+
for (const role of Sky.ROLES) assert.equal(p[role], KEYS[key][role].toLowerCase(), `${key} ${role}`);
|
|
132
|
+
for (const n of Sky.NUMBERS) assert.equal(p[n], KEYS[key][n], `${key} ${n}`);
|
|
133
|
+
}
|
|
134
|
+
// a key holds across its plateau: deep night to 4, midday from 12 to 14
|
|
135
|
+
assert.deepEqual(Sky.blend(3, KEYS), Sky.blend(0, KEYS));
|
|
136
|
+
assert.deepEqual(Sky.blend(12, KEYS), Sky.blend(14, KEYS));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('between two keys the palette is their blend: half-way is the midpoint, channel by channel', () => {
|
|
140
|
+
const half = (a, b) => '#' + Sky.rgbOf(a).map((v, i) => Math.round((v + Sky.rgbOf(b)[i]) / 2).toString(16).padStart(2, '0')).join('');
|
|
141
|
+
const morning = Sky.blend(9, KEYS), evening = Sky.blend(21, KEYS);
|
|
142
|
+
for (const role of Sky.ROLES) {
|
|
143
|
+
assert.equal(morning[role], half(KEYS.dawn[role], KEYS.midday[role]), `09:00 ${role} is half dawn, half midday`);
|
|
144
|
+
if (role !== 'line') assert.equal(evening[role], half(KEYS.dusk[role], KEYS.night[role]), `21:00 ${role} is half dusk, half night`);
|
|
145
|
+
}
|
|
146
|
+
// a line is drawn against the cloud, so it never passes through the cloud's own grey: it keeps
|
|
147
|
+
// its day colour until the cloud has gone most of the way dark, then turns to its night colour
|
|
148
|
+
assert.equal(evening.line, KEYS.dusk.line.toLowerCase(), '21:00: still the dusk line');
|
|
149
|
+
assert.equal(Sky.blend(5, KEYS).line, KEYS.dawn.line.toLowerCase(), '05:00: already the dawn line');
|
|
150
|
+
assert.equal(Sky.blend(23.5, KEYS).line, KEYS.night.line.toLowerCase(), 'by 23:30 it is the night line');
|
|
151
|
+
assert.equal(Sky.blend(22.5, KEYS).line, half(KEYS.dusk.line, KEYS.night.line), 'and it turns evenly in between');
|
|
152
|
+
assert.equal(morning.windows, Math.round((KEYS.dawn.windows + KEYS.midday.windows) / 2 * 1000) / 1000);
|
|
153
|
+
assert.deepEqual(Sky.spanAt(5), { a: 'night', b: 'dawn', t: 0.5 });
|
|
154
|
+
assert.deepEqual(Sky.spanAt(16), { a: 'midday', b: 'dusk', t: 0.5 });
|
|
155
|
+
// smooth: an hour's step never jumps a channel by more than the span's slope allows
|
|
156
|
+
for (let h = 0; h < 24; h += 0.25) {
|
|
157
|
+
const a = Sky.rgbOf(Sky.blend(h, KEYS).mid), b = Sky.rgbOf(Sky.blend(h + 0.25, KEYS).mid);
|
|
158
|
+
assert.ok(a.every((v, i) => Math.abs(v - b[i]) <= 40), `no jump in the sky between ${h} and ${h + 0.25}`);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('the clock wraps past midnight, both ways', () => {
|
|
163
|
+
assert.deepEqual(Sky.blend(24, KEYS), Sky.blend(0, KEYS));
|
|
164
|
+
assert.deepEqual(Sky.blend(25.5, KEYS), Sky.blend(1.5, KEYS));
|
|
165
|
+
assert.deepEqual(Sky.blend(-3, KEYS), Sky.blend(21, KEYS));
|
|
166
|
+
assert.deepEqual(Sky.blend(37, KEYS), Sky.blend(13, KEYS));
|
|
167
|
+
assert.equal(Sky.wrap(24), 0); assert.equal(Sky.wrap(-0.5), 23.5); assert.equal(Sky.wrap('x'), 0);
|
|
168
|
+
// the last hour of the day runs into night, not back to dusk
|
|
169
|
+
assert.deepEqual(Sky.spanAt(23), { a: 'dusk', b: 'night', t: 5 / 6 });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('the blend takes the colours however the page hands them over, and skips what a key lacks', () => {
|
|
173
|
+
const keys = { night: { top: 'rgb(0, 0, 0)', windows: 1 }, dawn: { top: [100, 200, 50], windows: 0 }, midday: {}, dusk: {} };
|
|
174
|
+
assert.deepEqual(Sky.blend(5, keys), { top: '#326419', windows: 0.5 });
|
|
175
|
+
assert.deepEqual(Sky.rgbOf('color(srgb 1 0.5 0)'), [255, 127.5, 0]);
|
|
176
|
+
assert.equal(Sky.rgbOf('var(--x)'), null);
|
|
177
|
+
assert.deepEqual(Sky.blend(13, keys), {}, 'a key with no roles gives nothing, never a guessed colour');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── the hour source ──────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
test('a pinned hour wins: the root attribute, then the query, then the hash; a bad value is ignored', () => {
|
|
183
|
+
assert.equal(Sky.hourFrom(null, '?hour=13', ''), 13);
|
|
184
|
+
assert.equal(Sky.hourFrom(null, '?week=3&hour=6.5', ''), 6.5);
|
|
185
|
+
assert.equal(Sky.hourFrom(null, '', '#hour=21'), 21);
|
|
186
|
+
assert.equal(Sky.hourFrom(null, '', '#decide&hour=9'), 9, 'beside the page\'s own hash route');
|
|
187
|
+
assert.equal(Sky.hourFrom('18', '?hour=13', '#hour=21'), 18, 'the attribute first');
|
|
188
|
+
assert.equal(Sky.hourFrom(null, '?hour=13', '#hour=21'), 13, 'then the query');
|
|
189
|
+
assert.equal(Sky.hourFrom(null, '?hour=26', ''), 2, 'past 24 wraps');
|
|
190
|
+
for (const bad of ['?hour=', '?hour=noon', '?behaviour=3', '?hours=3']) assert.equal(Sky.hourFrom(null, bad, ''), null, bad);
|
|
191
|
+
assert.equal(Sky.hourFrom('', '', ''), null);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// The page half, run against a stand-in document whose computed styles are style.css's own tokens.
|
|
195
|
+
function page({ search = '', hash = '', attr = null } = {}) {
|
|
196
|
+
const set = {}; const attrs = attr == null ? {} : { 'data-sky-hour-at': attr };
|
|
197
|
+
const timers = []; let probes = 0; let computed = 0;
|
|
198
|
+
const html = {
|
|
199
|
+
style: { setProperty(k, v) { set[k] = v; } },
|
|
200
|
+
setAttribute(k, v) { attrs[k] = v; }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
|
|
201
|
+
};
|
|
202
|
+
const document = {
|
|
203
|
+
documentElement: html, readyState: 'complete', hidden: false, addEventListener() {},
|
|
204
|
+
body: { appendChild() { probes += 1; } },
|
|
205
|
+
createElement() { return { style: { color: '' }, hidden: false, remove() {} }; },
|
|
206
|
+
};
|
|
207
|
+
const window = { document, location: { search, hash } };
|
|
208
|
+
document.defaultView = window;
|
|
209
|
+
const ctx = {
|
|
210
|
+
window, document,
|
|
211
|
+
getComputedStyle(el) {
|
|
212
|
+
computed += 1;
|
|
213
|
+
if (el === html) return { getPropertyValue: (n) => TOKENS[n] || '' };
|
|
214
|
+
const name = /var\((--[\w-]+)\)/.exec(el.style.color);
|
|
215
|
+
const v = Sky.rgbOf(TOKENS[name[1]]);
|
|
216
|
+
return { color: `rgb(${v[0]}, ${v[1]}, ${v[2]})` };
|
|
217
|
+
},
|
|
218
|
+
setInterval(fn, ms) { timers.push({ fn, ms }); return timers.length; },
|
|
219
|
+
Date, Number, String, Math, Object, Array, parseInt, decodeURIComponent, isFinite,
|
|
220
|
+
};
|
|
221
|
+
vm.runInNewContext(read('sky-time.js'), ctx, { filename: 'sky-time.js' });
|
|
222
|
+
return { api: window.OTBSkyTime, set, attrs, timers, document, stats: () => ({ probes, computed }) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test('on load the page wears the hour\'s palette as --sky-now-* on the root, from the tokens read once', () => {
|
|
226
|
+
const p = page({ search: '?hour=13' });
|
|
227
|
+
assert.equal(p.attrs['data-sky-hour'], '13');
|
|
228
|
+
for (const role of Sky.ROLES) assert.equal(p.set[`--sky-now-${role}`], KEYS.midday[role].toLowerCase(), role);
|
|
229
|
+
assert.equal(p.set['--sky-now-windows'], String(KEYS.midday.windows));
|
|
230
|
+
assert.equal(p.set['--sky-now-stars'], '0');
|
|
231
|
+
assert.deepEqual(Object.keys(p.set).sort(), [...Sky.ROLES, ...Sky.NUMBERS].map((n) => `--sky-now-${n}`).sort(), 'the two mixes (haze, glow) stay the sheet\'s to derive');
|
|
232
|
+
// one read of the tokens, cached: a later apply reads no computed style again
|
|
233
|
+
const before = p.stats();
|
|
234
|
+
assert.equal(before.probes, 1, 'one probe element');
|
|
235
|
+
p.api.apply(p.document, 0);
|
|
236
|
+
assert.deepEqual(p.stats(), before, 'the second palette costs no style read');
|
|
237
|
+
assert.equal(p.set['--sky-now-mid'], KEYS.night.mid.toLowerCase());
|
|
238
|
+
assert.equal(p.attrs['data-sky-hour'], '0');
|
|
239
|
+
assert.equal(p.api.hour(), 0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('the override reaches the page: the hash and the root attribute pin the hour too', () => {
|
|
243
|
+
assert.equal(page({ hash: '#hour=18' }).set['--sky-now-cloud'], KEYS.dusk.cloud.toLowerCase());
|
|
244
|
+
assert.equal(page({ attr: '6', search: '?hour=13' }).set['--sky-now-cloud'], KEYS.dawn.cloud.toLowerCase());
|
|
245
|
+
// with nothing pinned the viewer's clock is the source: a number from 0 up to 24
|
|
246
|
+
const live = page();
|
|
247
|
+
assert.ok(Number(live.attrs['data-sky-hour']) >= 0 && Number(live.attrs['data-sky-hour']) < 24);
|
|
248
|
+
assert.equal(live.api.hourNow({ document: live.document, location: { search: '?hour=4', hash: '' } }), 4);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('no motion: the palette is recomputed at most once a minute and nothing animates between two', () => {
|
|
252
|
+
const p = page({ search: '?hour=6' });
|
|
253
|
+
assert.equal(p.timers.length, 1, 'one timer');
|
|
254
|
+
assert.ok(p.timers[0].ms >= 60000, 'a minute or longer');
|
|
255
|
+
p.api.start(p.document);
|
|
256
|
+
assert.equal(p.timers.length, 1, 'starting twice starts one clock');
|
|
257
|
+
const src = strip(read('sky-time.js'));
|
|
258
|
+
assert.doesNotMatch(src, /requestAnimationFrame|setTimeout|\.animate\(|transition/, 'no frame loop, no tween');
|
|
259
|
+
for (const f of ['city.css', 'home-city.css']) {
|
|
260
|
+
for (const m of strip(read(f)).matchAll(/([^{}]+)\{([^{}]*--sky-now-[^{}]*)\}/g)) {
|
|
261
|
+
assert.doesNotMatch(m[2], /transition|animation/, `${f} ${m[1].trim()}: a sky colour is set, never animated`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('a page without the hall\'s token tier keeps the sheet\'s own defaults', () => {
|
|
267
|
+
const html = { style: { setProperty() { throw new Error('nothing to set'); } }, setAttribute() { throw new Error('no hour to say'); }, getAttribute: () => null };
|
|
268
|
+
const document = { documentElement: html, readyState: 'complete', addEventListener() {}, body: { appendChild() {} }, createElement: () => ({ style: {}, remove() {} }) };
|
|
269
|
+
const window = { document, location: { search: '', hash: '' } };
|
|
270
|
+
vm.runInNewContext(read('sky-time.js'), { window, document, getComputedStyle: () => ({ getPropertyValue: () => '', color: 'rgb(1, 2, 3)' }), setInterval() { return 1; }, Date, Number, String, Math, Object, Array, parseInt, decodeURIComponent, isFinite });
|
|
271
|
+
assert.equal(window.OTBSkyTime.apply(document, 13), null);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Owner, 2026-11-11: the hour's sky fills the whole view, edge to edge; nothing darkens its edges.
|
|
275
|
+
test('the sky runs edge to edge: no vignette, no night falloff, and what is read on it stands on a plate', () => {
|
|
276
|
+
const city = strip(read('city.css')), home = strip(read('home-city.css'));
|
|
277
|
+
assert.doesNotMatch(city, /\.city-frame::(after|before)/, 'nothing lies over the city\'s frame');
|
|
278
|
+
const frame = /\.city-frame \{([^}]*)\}/.exec(city.slice(city.indexOf('.city-frame { background: radial-gradient')))[1];
|
|
279
|
+
assert.doesNotMatch(frame, /var\(--bg|transparent|rgba?\(/, 'the frame is sky from its centre to its corners');
|
|
280
|
+
assert.match(frame, /var\(--sky-now-cloud\).*var\(--sky-now-mid\)/);
|
|
281
|
+
assert.match(city, /\.city-line, \.city-mark, \.city-plaque, \.city-archived \{ background: var\(--night-plate\); clip-path: var\(--cut-path-sm\); \}/);
|
|
282
|
+
// Home: with the bars up the sky fills the city's panel; in game mode it is the ground of the whole view
|
|
283
|
+
assert.match(home, /\.hc-stage \{ background: linear-gradient\(to bottom, var\(--sky-now-top\), var\(--sky-now-mid\) 46%, var\(--sky-now-haze\)\); \}/);
|
|
284
|
+
assert.match(home, /html\[data-game="on"\] body\[data-page="home"\] \{ --plate: linear-gradient\(to bottom, var\(--sky-now-top\)[^;]*\); --plate-veil: none;/, 'the hall\'s own ground seam carries the sky, with no veil over it');
|
|
285
|
+
for (const m of home.matchAll(/([^{}]+)\{([^{}]*--sky-now-[^{}]*)\}/g)) assert.doesNotMatch(m[2], /var\(--bg(-card)?\)/, `${m[1].trim()}: the sky never fades to night`);
|
|
286
|
+
assert.match(home, /\.hc-ladder::before \{[^}]*background: var\(--night-plate\)/, 'the ladder stands on a plate');
|
|
287
|
+
assert.match(home, /html\[data-game="on"\] body\[data-page="home"\] \.page-head--hall \.page-head__h \{[^}]*background: var\(--night-plate\)/, 'so does the greeting, on the sky');
|
|
288
|
+
assert.match(home, /\.hc-time \{[^}]*background: var\(--bg-card\)/, 'and the slider keeps its night strip');
|
|
289
|
+
assert.equal(TOKENS['--night-plate'], undefined);
|
|
290
|
+
assert.match(strip(ROOT_BLOCK), /--night-plate: color-mix\(in srgb, var\(--bg\) 88%, transparent\);/, 'the plate is the night itself, nearly solid, so ice and white text hold AA on it over any sky');
|
|
291
|
+
assert.match(strip(STYLE), /html\[data-game="on"\] \.upd-pill:not\(\[data-face="done"\]\) \{ background: var\(--bg-card\); \}/, 'the Update pill floats on the sky in game mode: it is solid night');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('both city pages load the hour before the painter that spends it', () => {
|
|
295
|
+
for (const [pageFile, painter] of [['city.html', 'city-draw'], ['index.html', 'home-city-draw']]) {
|
|
296
|
+
const scripts = [...read(pageFile).matchAll(/<script src="\/builders\/([a-z-]+)\.js[^"]*"( defer)?/g)].map((m) => m[1]);
|
|
297
|
+
assert.ok(scripts.includes('sky-time'), `${pageFile} loads sky-time.js`);
|
|
298
|
+
assert.ok(scripts.indexOf('sky-time') < scripts.indexOf(painter), `${pageFile}: before ${painter}.js`);
|
|
299
|
+
assert.match(read(pageFile), /<script src="\/builders\/sky-time\.js\?v=[\w-]+" defer><\/script>/, 'stamped and deferred');
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ── in a browser: the City page and Home draw at each of the four hours ──────
|
|
304
|
+
// The ui-design kit's stub serves the hall; skips when the machine has no system Edge or
|
|
305
|
+
// Chrome, as tests/hall_private_palettes.mjs does.
|
|
306
|
+
let browser = null;
|
|
307
|
+
try { browser = await kit.launchBrowser(); } catch (e) { if (!(e instanceof kit.NoBrowserError)) throw e; }
|
|
308
|
+
after(() => browser && browser.close());
|
|
309
|
+
|
|
310
|
+
test('the City page draws at night, dawn, midday and dusk with no error, in that hour\'s colours', { skip: !browser && 'no system Edge or Chrome', timeout: 180000 }, async () => {
|
|
311
|
+
const stub = await kit.startStub({ surface: 'hall-ui', prefix: '/builders/', auth: true });
|
|
312
|
+
try {
|
|
313
|
+
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: 'dark' });
|
|
314
|
+
await ctx.addInitScript(() => { try { sessionStorage.setItem('hall-craft-asked', '1'); } catch (e) { /* private mode */ } });
|
|
315
|
+
for (const key of Sky.KEYS) {
|
|
316
|
+
const page = await ctx.newPage();
|
|
317
|
+
const errors = [];
|
|
318
|
+
page.on('pageerror', (e) => errors.push(String(e.message)));
|
|
319
|
+
await page.goto(`${stub.base}/builders/city?hour=${Sky.KEY_HOURS[key]}`, { waitUntil: 'load' });
|
|
320
|
+
await page.waitForFunction(() => document.querySelectorAll('#cityBlds g[data-key]').length >= 9, null, { timeout: 15000 });
|
|
321
|
+
const r = await page.evaluate(() => {
|
|
322
|
+
const rgb = (c) => { const m = /(\d+)[, ]+(\d+)[, ]+(\d+)/.exec(c); return m ? [+m[1], +m[2], +m[3]] : null; };
|
|
323
|
+
const css = (sel, prop) => { const el = document.querySelector(sel); return el ? getComputedStyle(el)[prop] : null; };
|
|
324
|
+
const html = document.documentElement;
|
|
325
|
+
return {
|
|
326
|
+
hour: html.getAttribute('data-sky-hour'), theme: html.getAttribute('data-theme'),
|
|
327
|
+
mid: html.style.getPropertyValue('--sky-now-mid'), cloud: html.style.getPropertyValue('--sky-now-cloud'),
|
|
328
|
+
buildings: document.querySelectorAll('#cityBlds g[data-key]').length,
|
|
329
|
+
grads: document.querySelectorAll('#cityDefs linearGradient').length,
|
|
330
|
+
rows: document.querySelectorAll('#cityBlds > path.city-cloud--row').length,
|
|
331
|
+
banks: document.querySelectorAll('#citySkyBack .city-cloud, #cityCloudFront .city-cloud').length,
|
|
332
|
+
cloudFill: rgb(css('.city-cloud--near', 'fill')), lampFill: rgb(css('.city-stars', 'fill')),
|
|
333
|
+
faceFill: css('#cityBlds g[data-key="charter"] path', 'fill'),
|
|
334
|
+
stars: css('.city-stars', 'opacity'), frame: css('.city-frame', 'backgroundImage'),
|
|
335
|
+
veil: getComputedStyle(document.querySelector('.city-frame'), '::after').content,
|
|
336
|
+
plates: ['.city-mark', '.city-plaque', '.city-line', '.city-archived'].map((s) => { const c = getComputedStyle(document.querySelector(s)); return c.backgroundColor !== 'rgba(0, 0, 0, 0)' && /^polygon/.test(c.clipPath); }),
|
|
337
|
+
ink: rgb(css('.city-line', 'color')), chip: css('.city-line', 'backgroundColor'), bodyBg: rgb(css('body', 'backgroundColor')),
|
|
338
|
+
};
|
|
339
|
+
});
|
|
340
|
+
await page.close();
|
|
341
|
+
assert.deepEqual(errors, [], `${key}: no page error`);
|
|
342
|
+
assert.equal(r.hour, String(Sky.KEY_HOURS[key]));
|
|
343
|
+
assert.equal(r.theme, 'dark', 'the hall stays dark at every hour');
|
|
344
|
+
assert.equal(r.mid, KEYS[key].mid.toLowerCase(), `${key}: the sky`);
|
|
345
|
+
assert.ok(r.buildings >= 9 && r.grads >= 2 && r.rows >= 5 && r.banks === 5, `${key}: the city and its cloud are drawn (${JSON.stringify(r)})`);
|
|
346
|
+
assert.deepEqual(r.cloudFill, Sky.rgbOf(KEYS[key].cloud), `${key}: the near bank is the hour's cloud`);
|
|
347
|
+
assert.deepEqual(r.lampFill, Sky.rgbOf(KEYS[key].lamp), `${key}: a point of light is the hour's`);
|
|
348
|
+
assert.match(r.faceFill, /url\(.*#sky-face-l/, `${key}: a face on the ground fades into the cloud`);
|
|
349
|
+
assert.equal(Number(r.stars), KEYS[key].stars, `${key}: the stars`);
|
|
350
|
+
assert.match(r.frame, /radial-gradient/, 'the frame is the sky');
|
|
351
|
+
// the sky runs to all four edges of the frame: nothing lies over it, and its corners are sky
|
|
352
|
+
assert.equal(r.veil, 'none', `${key}: no vignette or band over the frame`);
|
|
353
|
+
assert.deepEqual(r.plates, [true, true, true, true], `${key}: the wordmark, the plaque, the line and the archive note each stand on a night plate`);
|
|
354
|
+
// the words keep the hall's ink, on a night chip, whatever the sky is doing
|
|
355
|
+
assert.ok(r.ink.every((v) => v > 120), `${key}: the line under the door keeps its light ink`);
|
|
356
|
+
assert.notEqual(r.chip, 'rgba(0, 0, 0, 0)', 'on a night chip');
|
|
357
|
+
assert.ok(r.bodyBg.every((v) => v < 40), `${key}: the page ground under the chrome stays night`);
|
|
358
|
+
}
|
|
359
|
+
await ctx.close();
|
|
360
|
+
} finally { stub.stop(); }
|
|
361
|
+
});
|
package/tests/hall_street.mjs
CHANGED
|
@@ -84,7 +84,8 @@ test('the street palette is the prototype theme() (less the iso half city-draw.j
|
|
|
84
84
|
const t = new Proto({ mode }).theme(mode);
|
|
85
85
|
delete t.iso;
|
|
86
86
|
assert.deepEqual(S.theme(mode), t, mode);
|
|
87
|
-
|
|
87
|
+
// the iso half is city-draw.js's, and since task 1005196 it is the hour's sky tokens (tests/hall_city.mjs)
|
|
88
|
+
assert.deepEqual(Object.keys(City.isoTheme(mode)).filter((k) => !(k in new Proto({ mode }).theme(mode).iso)), [], 'the iso half names nothing the prototype\'s did not');
|
|
88
89
|
}
|
|
89
90
|
});
|
|
90
91
|
|
package/tests/hall_visor.mjs
CHANGED
|
@@ -101,12 +101,16 @@ test('source: the minimal visor spends the Helm tokens and types in no colour, s
|
|
|
101
101
|
for (const m of css.match(/transition:[^;]+/g) || []) assert.match(m, /var\(--ds-dur-/, `${m}: a duration token, so reduced motion stills it`);
|
|
102
102
|
assert.match(css, /\.visor__main \{[^}]*background: var\(--accent\)[^}]*clip-path: var\(--cut-path\)/, 'the one accent button is cut');
|
|
103
103
|
assert.match(css, /\.visor__main:focus-visible[^{]*\{[^}]*box-shadow: var\(--press-focus\)/, 'a cut control takes the inset ring');
|
|
104
|
-
assert.match(css, /\.visor__hint \{[^}]*
|
|
104
|
+
assert.match(css, /\.visor__hint \{[^}]*border: 0/, 'a key hint has no drawn box');
|
|
105
105
|
assert.match(css, /\.visor__place, \.visor__name, \.visor__unit, \.visor__main, \.visor__hint[^{]*\{\s*font-family: var\(--name-face\)/, 'names are in the name voice');
|
|
106
|
-
// it reads over any sky with one treatment, never a per-hour style
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
assert.
|
|
106
|
+
// it reads over any sky with one treatment, never a per-hour style, and it darkens none of the
|
|
107
|
+
// sky (task 1005196, owner: the hour's sky runs to every edge of the view)
|
|
108
|
+
const glass = /html\[data-game="on"\] \.visor:not\(\[hidden\]\) \{([^}]*)\}/.exec(css)[1];
|
|
109
|
+
assert.doesNotMatch(glass, /background|box-shadow|backdrop-filter|filter:/, 'the glass itself paints nothing: no edge band, no vignette, no veil');
|
|
110
|
+
assert.doesNotMatch(css, /--night-glass|linear-gradient|radial-gradient\((?!circle, currentColor)|--type-halo/, 'no band of the night anywhere on the glass, and no dark glow round its type');
|
|
111
|
+
assert.match(css, /\.visor__place, \.visor__who, \.visor__hint \{ background: var\(--night-plate\); clip-path: var\(--cut-path-sm\); \}/, 'the place, each hint and your credits stand on a small cut plate of the night');
|
|
112
|
+
assert.match(css, /\.visor__line--under \{ stroke: var\(--bg\); stroke-opacity: 1/, 'every line is drawn over a solid stroke of the night, so it reads on cloud and on night');
|
|
113
|
+
assert.match(css, /\.visor__key \{[^}]*background: var\(--bg\)/, 'a key sits on a chip of the night');
|
|
110
114
|
assert.doesNotMatch(css, /data-hour|data-time|data-sky/, 'no per-hour style');
|
|
111
115
|
// the frame is two arcs, a notch and a sight; the compass, the side lines and the pills are gone
|
|
112
116
|
assert.equal((VISOR.match(/svgOpen\('visor__frame /g) || []).length, 2, 'two arcs');
|
|
@@ -318,6 +322,8 @@ test('the glass is the kit\'s: the cut accent button, ice hints with no box, the
|
|
|
318
322
|
return {
|
|
319
323
|
mainBg: cs('.visor__main').backgroundColor, accent: probe('var(--accent)'), mainClip: cs('.visor__main').clipPath,
|
|
320
324
|
mainCase: cs('.visor__main').textTransform, hintBg: cs('.visor__hint').backgroundColor, hintBorder: cs('.visor__hint').borderTopWidth,
|
|
325
|
+
plate: probe('var(--night-plate)'), placeBg: cs('.visor__place').backgroundColor, whoBg: cs('.visor__who').backgroundColor, hintClip: cs('.visor__hint').clipPath,
|
|
326
|
+
glassBg: cs('#visor').backgroundImage + ' ' + cs('#visor').backgroundColor,
|
|
321
327
|
hintColor: cs('.visor__hint').color, ice: probe('var(--ink-soft)'), num: cs('.visor__num').color, unit: cs('.visor__unit').color, white: probe('var(--ink)'),
|
|
322
328
|
placeShadow: cs('.visor__place').textShadow, radius: cs('.visor__sprite').borderTopLeftRadius, spriteClip: cs('.visor__sprite').clipPath,
|
|
323
329
|
cut: root.getPropertyValue('--cut-path').trim() !== '',
|
|
@@ -326,12 +332,16 @@ test('the glass is the kit\'s: the cut accent button, ice hints with no box, the
|
|
|
326
332
|
assert.equal(g.mainBg, g.accent, 'the one accent fill is the button you press');
|
|
327
333
|
assert.match(g.mainClip, /^polygon\(/, 'cut corner');
|
|
328
334
|
assert.equal(g.mainCase, 'uppercase', 'the name voice');
|
|
329
|
-
assert.equal(g.
|
|
330
|
-
assert.equal(g.
|
|
335
|
+
assert.equal(g.glassBg, 'none rgba(0, 0, 0, 0)', 'the glass darkens nothing: the scene shows to every edge');
|
|
336
|
+
assert.equal(g.hintBg, g.plate, 'a hint stands on a plate of the night');
|
|
337
|
+
assert.equal(g.placeBg, g.plate, 'so does the place');
|
|
338
|
+
assert.equal(g.whoBg, g.plate, 'and your credits');
|
|
339
|
+
assert.match(g.hintClip, /^polygon\(/, 'the plate is cut');
|
|
340
|
+
assert.equal(g.hintBorder, '0px', 'a hint has no drawn box');
|
|
331
341
|
assert.equal(g.hintColor, g.ice, 'hints are ice');
|
|
332
342
|
assert.equal(g.num, g.white, 'the count is white');
|
|
333
343
|
assert.equal(g.unit, g.ice, 'its word is ice');
|
|
334
|
-
assert.
|
|
344
|
+
assert.equal(g.placeShadow, 'none', 'type on a plate needs no halo');
|
|
335
345
|
assert.equal(g.radius, '0px', 'no round thing on the glass');
|
|
336
346
|
assert.match(g.spriteClip, /^polygon\(/, 'the sprite sits in a cut box');
|
|
337
347
|
await page.keyboard.press('Tab');
|
package/tests/home_city.mjs
CHANGED
|
@@ -383,7 +383,7 @@ await test('ledger text is never markup: titles and names travel as attribute va
|
|
|
383
383
|
people: [{ id: '9', name: '"><script>x</script>', build: [1], art: [0], idea: [0], gov: [0] }],
|
|
384
384
|
ideas: [{ id: '3', week: 0, by: '9', title: '<b>idea</b>' }], now: { sites: [{ goal_id: '1', builder_id: '9' }] } };
|
|
385
385
|
const ns = all(Draw.citySvg(Draw.cityModel(hostile), 0, { mode: 'light' }).nodes);
|
|
386
|
-
const SHAPES = new Set(['defs', 'radialGradient', 'stop', 'path', 'line', 'circle', 'ellipse', 'g', 'a', 'rect', 'text', 'clipPath', 'image']);
|
|
386
|
+
const SHAPES = new Set(['defs', 'radialGradient', 'linearGradient', 'filter', 'feGaussianBlur', 'stop', 'path', 'line', 'circle', 'ellipse', 'g', 'a', 'rect', 'text', 'clipPath', 'image']);
|
|
387
387
|
assert.deepEqual([...new Set(ns.map((n) => n.tag))].filter((t) => !SHAPES.has(t)), [], 'only SVG shapes, whatever a title says');
|
|
388
388
|
assert.ok(ns.some((n) => n.attrs['data-tip'] === '<img src=x onerror=alert(1)>'), 'the title rides as an attribute value, set with setAttribute');
|
|
389
389
|
assert.ok(ns.some((n) => n.tag === 'text' && n.text === '"'), 'a name\'s initial is a text node');
|
|
@@ -415,7 +415,11 @@ await test('on the builder\'s Home the core and the museum are faded; a craft\'s
|
|
|
415
415
|
assert.doesNotMatch(cls('museum', 'hc-museum'), /hc-quiet/);
|
|
416
416
|
assert.doesNotMatch(cls('core', 'hc-core'), /hc-quiet/);
|
|
417
417
|
const css = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/home-city.css'), 'utf8');
|
|
418
|
-
|
|
418
|
+
// quiet, and still readable against the cloud at every hour (task 1005196): part-faded and drained, never near-invisible
|
|
419
|
+
const quietRule = /\.hc-quiet\s*\{([^}]*)\}/.exec(css)[1];
|
|
420
|
+
const fade = Number(/opacity:\s*([.\d]+)/.exec(quietRule)[1]);
|
|
421
|
+
assert.ok(fade >= 0.55 && fade <= 0.75, 'faded, but it reads');
|
|
422
|
+
assert.match(quietRule, /saturate\(\.\d+\)/, 'and drained of colour');
|
|
419
423
|
assert.match(css, /\.hc-quiet:hover,\s*\.hc-svg \.hc-quiet:focus-visible\s*\{[^}]*filter:\s*none/, 'back to colour under the pointer or the keyboard');
|
|
420
424
|
});
|
|
421
425
|
|
|
@@ -445,10 +449,55 @@ await test('a week part-way through grows each tower part-way: the slider glides
|
|
|
445
449
|
assert.equal(at(3.5), (at(3) + at(4)) / 2);
|
|
446
450
|
});
|
|
447
451
|
|
|
448
|
-
|
|
452
|
+
// Task 1005196: the city in the clouds. One palette, the hour's sky tokens; the hall is dark only.
|
|
453
|
+
await test('the city is painted from the hour\'s sky tokens: no colour literal, whatever the mode', () => {
|
|
449
454
|
const m = Draw.cityModel(FIXTURE);
|
|
450
|
-
|
|
451
|
-
assert.
|
|
455
|
+
const dark = all(Draw.citySvg(m, m.W - 1, { mode: 'dark', gov: Gov }).nodes);
|
|
456
|
+
assert.deepEqual(all(Draw.citySvg(m, m.W - 1, { mode: 'light', gov: Gov }).nodes).map((n) => n.attrs.fill), dark.map((n) => n.attrs.fill), 'a mode argument changes nothing');
|
|
457
|
+
assert.deepEqual(Draw.theme('light'), Draw.theme('dark'));
|
|
458
|
+
const paints = dark.flatMap((n) => [n.attrs.fill, n.attrs.stroke, n.attrs['stop-color']]).filter((v) => v != null);
|
|
459
|
+
assert.ok(paints.length > 100);
|
|
460
|
+
for (const v of paints) assert.doesNotMatch(String(v), /#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(/i, `${v}: no colour literal`);
|
|
461
|
+
assert.ok(paints.some((v) => v === 'var(--sky-now-roof)') && paints.some((v) => v === 'var(--sky-now-lit)') && paints.some((v) => v === 'var(--sky-now-shade)'), 'a tower\'s top, lit face and shade face are the sky\'s');
|
|
462
|
+
for (const draw of [Draw.theme(), Draw.skyTheme()]) {
|
|
463
|
+
(function walk(v) { if (typeof v === 'string') assert.doesNotMatch(v, /#[0-9a-f]{3,8}\b|rgba?\(/i, v); else if (v && typeof v === 'object') Object.values(v).forEach(walk); })(draw);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const viewOX = (m) => Draw.viewBoxOf(m).OX;
|
|
468
|
+
await test('the city stands in cloud: no ground plane, banks behind and in front, a puff at each tower\'s foot', () => {
|
|
469
|
+
const m = Draw.cityModel(FIXTURE);
|
|
470
|
+
const out = Draw.citySvg(m, m.W - 1, { gov: Gov }).nodes;
|
|
471
|
+
const ns = all(out);
|
|
472
|
+
const clouds = ns.filter((n) => /\bhc-cloud\b/.test(n.attrs.class || ''));
|
|
473
|
+
const kinds = (k) => clouds.filter((n) => n.attrs.class === 'hc-cloud hc-cloud--' + k).length;
|
|
474
|
+
assert.deepEqual([kinds('far'), kinds('back'), kinds('near')], [1, 1, 1]);
|
|
475
|
+
assert.equal(kinds('row'), m.areas.length, 'one puff at the foot of each tower');
|
|
476
|
+
for (const c of clouds) { assert.equal(c.attrs.fill, undefined, 'the sheet gives a cloud its sky token'); assert.match(c.attrs.filter, /^url\(#hc-soft(er)?\)$/); }
|
|
477
|
+
// the banks behind come before every building, the banks in front after the last one
|
|
478
|
+
const idx = (re) => out.findIndex((n) => re.test(n.attrs.class || ''));
|
|
479
|
+
const lastTower = out.map((n) => /\bhc-tower\b/.test(n.attrs.class || '')).lastIndexOf(true);
|
|
480
|
+
assert.ok(idx(/hc-cloud--far/) < idx(/hc-tower|hc-core|hc-museum/) && idx(/hc-cloud--near/) > lastTower);
|
|
481
|
+
const defs = out[0];
|
|
482
|
+
assert.equal(defs.tag, 'defs');
|
|
483
|
+
assert.deepEqual(defs.kids.filter((k) => k.tag === 'linearGradient').map((k) => k.attrs.id), ['sky-face-l', 'sky-face-r'], 'the face gradients are the Governor City\'s own');
|
|
484
|
+
assert.deepEqual(defs.kids.filter((k) => k.tag === 'filter').map((k) => k.attrs.id), ['hc-soft', 'hc-softer']);
|
|
485
|
+
// a plinth stands in the cloud and fades into it; the tower above keeps its plain faces
|
|
486
|
+
const tower = out.find((n) => /\bhc-tower\b/.test(n.attrs.class || ''));
|
|
487
|
+
assert.equal(tower.kids[0].attrs.fill, 'url(#sky-face-l) var(--sky-now-lit)');
|
|
488
|
+
assert.equal(tower.kids[3].attrs.fill, 'var(--sky-now-lit)');
|
|
489
|
+
// the cloud's shape is city-draw.js's own, asked for and never copied
|
|
490
|
+
assert.equal(clouds.find((n) => /--far$/.test(n.attrs.class)).attrs.d.indexOf(Gov.cloudAt(viewOX(m) - 250, Draw.viewBoxOf(m).OY + 50, 0.55)), 0);
|
|
491
|
+
assert.doesNotMatch(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/home-city-draw.js'), 'utf8'), /const CLOUD\b|0 1 0/, 'no cloud or ellipse formula in Home\'s painter');
|
|
492
|
+
// drawn without the Governor City the scene still paints: plain faces as the fallback, and no cloud
|
|
493
|
+
const bare = Draw.citySvg(m, m.W - 1, {}).nodes;
|
|
494
|
+
assert.equal(bare[0].kids.filter((k) => k.tag === 'linearGradient').length, 0);
|
|
495
|
+
assert.equal(all(bare).filter((n) => /\bhc-cloud\b/.test(n.attrs.class || '')).length, 0);
|
|
496
|
+
// the idea stars are one group, so the sheet can show them at the hour's star strength
|
|
497
|
+
const sky = out.find((n) => /\bhc-sky\b/.test(n.attrs.class || ''));
|
|
498
|
+
assert.deepEqual(sky.kids.map((k) => k.tag + (k.attrs.class ? '.' + k.attrs.class : '')), ['rect', 'g.hc-stars']);
|
|
499
|
+
assert.ok(sky.kids[1].kids.length > 0 && sky.kids[1].kids.every((k) => k.tag === 'circle'));
|
|
500
|
+
assert.match(fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/home-city.css'), 'utf8'), /\.hc-svg \.hc-stars \{ opacity: var\(--sky-now-stars\); \}/, 'gone by day');
|
|
452
501
|
});
|
|
453
502
|
|
|
454
503
|
// ---------------------------------------------------------------------------
|