feat: Simplification of UI and icons creation

This commit is contained in:
gauvainboiche
2026-10-08 16:52:21 +02:00
parent a88b8f5f14
commit 2f7c307f0e
22 changed files with 1099 additions and 408 deletions
+42 -31
View File
@@ -1,66 +1,77 @@
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { ICONS_FILE } from '../scripts/icons.mjs';
import { allIconKeys, assetsSkeleton, fileFor, renderText, THEME_IDS } from '../scripts/icon-keys.js';
import { loadContext, PUBLIC_DIR } from './helpers.js';
import { README, withIconList } from '../scripts/icons.mjs';
import { allIconKeys, assetsSkeleton, iconGroups, THEME_IDS } from '../scripts/icon-keys.js';
const ctx = loadContext();
const normalise = (text) => text.replaceAll('\r\n', '\n');
test('the icon list in the README is up to date (run: npm run icons -- --write)', () => {
const readme = readFileSync(README, 'utf8').replaceAll('\r\n', '\n');
assert.equal(readme, withIconList(readme, ctx));
test('ICONS.txt is up to date (run: npm run icons -- --write)', () => {
assert.equal(normalise(readFileSync(ICONS_FILE, 'utf8')), renderText(ctx));
});
test('the README lists every single icon key, once per theme row', () => {
const readme = readFileSync(README, 'utf8');
test('the README points to ICONS.txt instead of embedding the list', () => {
const readme = readFileSync(new URL('../README.md', import.meta.url), 'utf8');
assert.ok(readme.includes('ICONS.txt'));
assert.ok(!readme.includes('icons:start'), 'no generated block in the README');
assert.ok(readme.split('\n').length < 200, 'the README stays light');
});
test('ICONS.txt lists every icon, once per theme, with its file name', () => {
const text = normalise(readFileSync(ICONS_FILE, 'utf8'));
const keys = allIconKeys(ctx);
assert.equal(new Set(keys).size, keys.length, 'icon keys are unique');
for (const key of keys) assert.ok(readme.includes(`| \`${key}\` |`), key);
for (const theme of THEME_IDS) {
const section = text.slice(text.indexOf(`THEME: ${theme}\n`));
const end = section.indexOf('\n\n\nTHEME: ', 1);
const body = end < 0 ? section : section.slice(0, end);
for (const key of keys) {
assert.ok(body.includes(` ${key} `) && body.includes(fileFor(key)), `${theme}: ${key}`);
}
}
});
test('every theme names every themed icon (nothing falls back to a raw key)', () => {
const full = readFileSync(README, 'utf8');
const generated = full.slice(full.indexOf('<!-- icons:start'), full.indexOf('<!-- icons:end'));
assert.ok(generated.length > 5000, 'the generated list is there');
assert.ok(!/\| [a-z]+(\.[a-z_0-9]+)+ \|/.test(generated.replace(/`[^`]*`/g, '')), 'no unresolved text key in a table');
assert.ok(!generated.includes('{@'), 'embedded theme words are resolved');
assert.ok(!/\{target\}|\{value\}/.test(generated), 'placeholders are resolved');
test('every achievement shares one trophy icon', () => {
assert.ok(allIconKeys(ctx).includes('trophy'));
assert.ok(!allIconKeys(ctx).some((key) => key.startsWith('ach.')), 'no per-achievement icons');
const view = readFileSync(`${PUBLIC_DIR}js/ui/achievementsView.js`, 'utf8');
assert.ok(view.includes("icon('trophy')"));
});
test('nothing in the list is left as a raw text key or placeholder', () => {
const text = readFileSync(ICONS_FILE, 'utf8');
assert.ok(!text.includes('{@') && !/\{target\}|\{value\}/.test(text));
assert.ok(!/ (theme|research|legacy|ach|endless)\.[\w.]+\s*$/m.test(text), 'every label resolved');
assert.ok(THEME_IDS.length >= 5);
});
test('the interface only uses icon keys that are documented', () => {
const documented = new Set(iconGroups(ctx).map((group) => group.id));
test('the interface only uses icon keys that are listed', () => {
const listed = new Set(allIconKeys(ctx).map((key) => key.split('.')[0]));
const used = new Set();
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory()) walk(path);
else if (entry.name.endsWith('.js')) {
// icon('gen.x') and icon(`inv.${id}`) calls, outside the helper's own definition
if (path.endsWith('ui/dom.js')) continue;
for (const m of readFileSync(path, 'utf8').matchAll(/\bicon\(\s*[`'"]([a-z]+)\./g)) used.add(m[1]);
else if (entry.name.endsWith('.js') && !path.endsWith('ui/dom.js')) {
// icon('trophy'), icon(`inv.${id}`) ...: the first segment of the key
for (const m of readFileSync(path, 'utf8').matchAll(/\bicon\(\s*[`'"]([a-z]+)/g)) used.add(m[1]);
}
}
};
walk(`${PUBLIC_DIR}js`);
for (const m of readFileSync(`${PUBLIC_DIR}index.html`, 'utf8').matchAll(/data-icon="([a-z]+)\./g)) used.add(m[1]);
for (const m of readFileSync(`${PUBLIC_DIR}index.html`, 'utf8').matchAll(/data-icon="([a-z]+)/g)) used.add(m[1]);
assert.ok(used.size >= 7, 'the scan found the icons');
const undocumented = [...used].filter((prefix) => !documented.has(prefix));
assert.deepEqual(undocumented, [], `icons used but not listed in the README: ${undocumented.join(', ')}`);
const unlisted = [...used].filter((prefix) => !listed.has(prefix));
assert.deepEqual(unlisted, [], `icons used but not in ICONS.txt: ${unlisted.join(', ')}`);
});
test('the generated manifest covers every key with a real file name', () => {
const assets = assetsSkeleton(ctx);
assert.deepEqual(Object.keys(assets), allIconKeys(ctx));
assert.equal(assets['gen.gen1'], 'img/gen-gen1.svg');
assert.equal(assets.trophy, 'img/trophy.svg');
assert.ok(Object.values(assets).every((file) => /^img\/[a-z0-9_-]+\.svg$/.test(file)));
});
test('the paths the game reads from theme.json are the same keys', () => {
// dom.js looks assets up by the exact key, so a skeleton entry must resolve as-is.
const domSource = readFileSync(fileURLToPath(new URL('../public/js/ui/dom.js', import.meta.url)), 'utf8');
assert.ok(domSource.includes('assets[key]'));
});
+1 -1
View File
@@ -396,7 +396,7 @@ test('events: an opportunity appears, can be claimed once, and its buff expires'
const d = derive(s, ctx);
stepEvents(s, ctx, 1, d, constant(0), false, totals); // first weighted type: surge
assert.equal(s.events.current.type, 'surge');
assert.ok(totals.notices.some((n) => n.kind === 'event_opportunity'));
assert.equal(totals.notices.length, 0, 'an opportunity is shown on its card and tab, not as a pop-up');
const demandBefore = derive(s, ctx).demandMultiplier;
assert.ok(claimEvent(s, ctx, derive(s, ctx)));