Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
670 changes: 365 additions & 305 deletions DECISIONS.md

Large diffs are not rendered by default.

62 changes: 56 additions & 6 deletions tests/e2e/app.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ test('the editor: drawing, undo that loses nothing, palettes, frames, a draft, s
// A line, undone with the keyboard, redone with the button.
await stroke([2, 2], [9, 2]);
assert.equal(await cell(5, 2), '#17151f');
assert.equal(await undoLabel(), 'Отменить: карандаш');
assert.equal(await undoLabel(), 'Отменить: штрих карандашом');
await p.keyboard.press('Control+z');
assert.equal(await cell(5, 2), null);
await p.click('.ed-bar .ib >> nth=2');
Expand Down Expand Up @@ -574,7 +574,7 @@ test('the editor: drawing, undo that loses nothing, palettes, frames, a draft, s
await p.keyboard.press('d');
await stroke([2, 20], [9, 20]);
assert.deepEqual([await cell(2, 20), await cell(3, 20), await cell(4, 20)], ['#17151f', null, '#17151f']);
assert.equal(await undoLabel(), 'Отменить: шахматка');
assert.equal(await undoLabel(), 'Отменить: штрих шахматкой');
await p.keyboard.press('d');

// Zooming brings the whole card closer; 0 puts it back.
Expand All @@ -591,7 +591,7 @@ test('the editor: drawing, undo that loses nothing, palettes, frames, a draft, s
await p.click('.bottom-sheet.on .btn.key');
assert.match(await p.textContent('.ed-pal-name'), /Sweetie 16/);
assert.notEqual(await cell(5, 2), '#17151f');
assert.equal(await undoLabel(), 'Отменить: другая палитра');
assert.equal(await undoLabel(), 'Отменить: смена палитры');

// A second frame, a title; the meter says one code.
await p.click('.ed-frame.add');
Expand All @@ -606,7 +606,7 @@ test('the editor: drawing, undo that loses nothing, palettes, frames, a draft, s
await tab(p, 'draw');
await p.locator('.ed-frame-list .ed-frame >> nth=1').waitFor();
assert.equal(await p.inputValue('.ed-title'), 'Лесенка');
assert.match(await undoLabel(), /название/);
assert.match(await undoLabel(), /смена названия/);

// Signed, it opens in the collection.
await p.click('.ed-sign');
Expand Down Expand Up @@ -1022,8 +1022,8 @@ test('the tab bar waits for the card to land in the archive and the strip to go'
});

test('the editor grid lies exactly on the cells, from edge to edge', async () => {
for (const desk of [false, true]) {
const p = await env.phone('pixelfox', undefined, desk);
for (const [desk, scale] of [[false, 1], [true, 1], [false, 2.625], [false, 3]]) {
const p = await env.phone('pixelfox', undefined, desk, scale);
await tab(p, 'draw');
await p.locator('.ed-art').waitFor();
await p.waitForTimeout(400);
Expand All @@ -1041,6 +1041,15 @@ test('the editor grid lies exactly on the cells, from edge to edge', async () =>
});
// The grid's lines start and end on the art's edges (within a pixel).
m.art.forEach((a, i) => assert.ok(Math.abs(a - m.grid[i]) <= 1.5, `${desk ? 'computer' : 'phone'}: ${JSON.stringify(m)}`));
// And the canvas they are on lies on whole device pixels, one of its
// pixels to each: anything else is resampled, and a line blurs over two.
// At 2.625 that is only every 8th CSS pixel.
const fit = await p.evaluate(() => {
const c = /** @type {HTMLCanvasElement} */ (document.querySelector('.ed-lines')), r = c.getBoundingClientRect(), k = devicePixelRatio;
const whole = (v) => Math.abs(v - Math.round(v)) < 1e-3;
return { ok: whole(r.left) && whole(r.top) && whole(r.left * k) && whole(r.top * k) && Math.abs(r.width * k - c.width) < 1e-3 && Math.abs(r.height * k - c.height) < 1e-3, r: [r.left, r.top, r.width, r.height], c: [c.width, c.height], k };
});
assert.ok(fit.ok, JSON.stringify(fit));
await p.context().close();
}
});
Expand Down Expand Up @@ -1359,3 +1368,44 @@ test('the code on a card picture takes the paper’s colours and still reads, on
assert.deepEqual(results, Array(16).fill(true), `every paper, at both sizes: ${results.map(Number).join('')}`);
await p.context().close();
});

test('the editor holds still: a pinch keeps its zoom as the fingers lift, a selection moves nothing', async () => {
const p = await env.phone('stillfox');
await tab(p, 'draw');
await p.locator('.ed-art').waitFor();
const width = () => p.evaluate(() => document.querySelector('.ed-art').getBoundingClientRect().width);
const w0 = await width();
/** A finger on the canvas, by its offset from the middle. */
const finger = (type, id, dx, dy = 0) => p.evaluate(([type, id, dx, dy]) => {
const el = document.querySelector('.ed-card-wrap'), r = el.getBoundingClientRect();
el.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: id, pointerType: 'touch', clientX: r.left + r.width / 2 + dx, clientY: r.top + r.height / 2 + dy }));
}, [type, id, dx, dy]);
// Two fingers spread to three times as far apart: the zoom.
await finger('pointerdown', 1, -30);
await finger('pointerdown', 2, 30);
for (const d of [45, 60, 75, 90]) { await finger('pointermove', 1, -d); await finger('pointermove', 2, d); }
const w1 = await width();
assert.ok(w1 > w0 * 2, `zoomed in (${w0} → ${w1})`);
// One lifts first; the other moves a little before it lifts too.
await finger('pointerup', 2, 90);
await finger('pointermove', 1, -80, 6);
await finger('pointermove', 1, -70, 12);
await finger('pointerup', 1, -70, 12);
await p.waitForTimeout(400);
assert.ok(Math.abs((await width()) - w1) < 1, `the zoom stays as the fingers lift (${w1} → ${await width()})`);

// A selection, plain and precise: the card does not move by a pixel.
const rect = () => p.evaluate(() => JSON.stringify(document.querySelector('.ed-card-wrap').getBoundingClientRect()));
await p.click('.ed-tool-select');
for (const precise of [false, true]) {
if (precise) await p.click('.ed-tool-precise');
const r0 = await rect();
await p.keyboard.press('Control+a');
await p.waitForTimeout(100);
assert.equal(await rect(), r0, `${precise ? 'precise' : 'plain'}: the card stays as the selection's bar comes`);
await p.keyboard.press('Escape');
await p.waitForTimeout(100);
assert.equal(await rect(), r0, `${precise ? 'precise' : 'plain'}: and as it goes`);
}
await p.context().close();
});
10 changes: 5 additions & 5 deletions tests/e2e/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,19 @@ export async function setup() {
const browser = await engine.launch();
const dir = await mkdtemp(join(tmpdir(), 'pips-e2e-'));
const errors = [];
/** A fresh phone at its first screen; `desk`: a computer's screen instead. */
async function blank(name, desk = false) {
/** A fresh phone at its first screen; `desk`: a computer's screen instead; `scale`: device pixels to a CSS pixel. */
async function blank(name, desk = false, scale = 1) {
const viewport = desk ? { width: 1440, height: 900 } : { width: 390, height: 844 };
const ctx = await browser.newContext({ viewport, locale: 'ru-RU', acceptDownloads: true });
const ctx = await browser.newContext({ viewport, locale: 'ru-RU', acceptDownloads: true, deviceScaleFactor: scale });
const p = await ctx.newPage();
p.on('pageerror', (e) => errors.push(`${name}: ${e.message}`));
p.on('console', (m) => { if (m.type() === 'error') errors.push(`${name}: ${m.text()}`); });
await p.goto(site.url);
return p;
}
/** A fresh phone with a key; with `words`, restored instead of created. */
async function phone(nick, words, desk = false) {
const p = await blank(nick, desk);
async function phone(nick, words, desk = false, scale = 1) {
const p = await blank(nick, desk, scale);
if (words) {
await p.click('text=У меня уже есть ключ');
await p.fill('.words-entry input >> nth=0', words); // a phrase pasted fills every cell
Expand Down
96 changes: 96 additions & 0 deletions tests/unit/css-classes.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// The class names between the stylesheets and the code. All CSS here is
// global, so two parts of the app that pick the same name for different
// things style each other without a word (it happened: a strip of the
// palette and a place in the editor's layout were both .ed-strip). And a
// rule for a class nobody makes any more is dead weight that looks alive.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

const ROOT = new URL('../../web/', import.meta.url).pathname;
const walk = (d) => readdirSync(d).flatMap((f) => {
const p = join(d, f);
return statSync(p).isDirectory() ? (f === 'pkg' ? [] : walk(p)) : [p];
});
const files = walk(ROOT);
const js = files.filter((f) => f.endsWith('.js'));
const css = files.filter((f) => f.endsWith('.css'));
const html = files.filter((f) => f.endsWith('.html'));

/**
* Classes meant to be used by any part of the app: the shared vocabulary
* (buttons, fields, sheets, bars) and the card's own parts, which the
* editor lays out as the card does. Any other class belongs to the one
* module that makes it. A new shared one is added here on purpose.
*/
const SHARED = new Set([
// layout of a screen and its bar
'view', 'bar', 'title', 'sub', 'scroll', 'empty', 'lead', 'group', 'wide', 'pill',
// buttons and their rows
'btn', 'key', 'text-btn', 'icon-btn', 'row-btns', 'add',
// fields and texts
'field', 'check', 'hint', 'small',
// sheets
'grab', 'scrim',
// the card and the key's pictures, wherever they are shown
'face', 'front', 'cap', 'idcard', 'word-grid',
// the editor's own pieces, shared by its sheets and its layout
'ed-note', 'ed-swatch', 'ed-set-row', 'ed-place',
]);

/** The words inside the string and template literals of a source (the
* static parts of a template), and the prefixes a template completes
* (`is-${kind}` makes every is-…). */
function words(src) {
const out = new Set(), prefixes = new Set();
// Quoted strings anywhere, those inside a template's ${…} too; then
// the templates' own parts.
const quoted = [...src.matchAll(/'((?:[^'\\\n]|\\.)*)'|"((?:[^"\\\n]|\\.)*)"/g)].map((m) => m[1] ?? m[2]);
const templates = [...src.matchAll(/`((?:[^`\\]|\\.)*)`/g)].map((m) => m[1]);
for (const text of [...quoted, ...templates]) {
for (const part of text.split(/\$\{[^}]*\}/)) for (const w of part.match(/[a-z][\w-]*/g) ?? []) out.add(w);
for (const p of text.matchAll(/([a-z][\w-]*-)\$\{/g)) prefixes.add(p[1]);
}
return { out, prefixes };
}

/** The classes a CSS file's selectors name. */
function selectorClasses(src) {
const sel = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/@import[^;]*;/g, '').replace(/\{[^{}]*\}/g, '{}');
return new Set([...sel.matchAll(/\.([a-z][\w-]*)/g)].map((m) => m[1]));
}

test('every class the stylesheets style is made somewhere', () => {
const made = new Set(), prefixes = new Set();
for (const f of js) {
const w = words(readFileSync(f, 'utf8'));
w.out.forEach((x) => made.add(x));
w.prefixes.forEach((x) => prefixes.add(x));
}
for (const f of html) for (const m of readFileSync(f, 'utf8').matchAll(/class="([^"]*)"/g)) m[1].split(/\s+/).forEach((x) => made.add(x));
const dead = [];
for (const f of css) {
for (const c of selectorClasses(readFileSync(f, 'utf8'))) {
if (!made.has(c) && ![...prefixes].some((p) => c.startsWith(p))) dead.push(`${relative(ROOT, f)}: .${c}`);
}
}
assert.deepEqual(dead, [], 'rules for classes nothing makes');
});

test('a class is made by one module, unless it is shared on purpose', () => {
/** @type {Map<string, Set<string>>} */
const by = new Map();
for (const f of js) {
const src = readFileSync(f, 'utf8');
for (const m of src.matchAll(/class:\s*(['`])([^'`]*)\1/g)) {
for (const c of m[2].replace(/\$\{[^}]*\}/g, ' ').split(/\s+/)) {
if (!/^[a-z][\w-]*$/.test(c) || SHARED.has(c)) continue;
if (!by.has(c)) by.set(c, new Set());
by.get(c)?.add(relative(ROOT, f));
}
}
}
const twice = [...by].filter(([, f]) => f.size > 1).map(([c, f]) => `.${c}: ${[...f].join(', ')}`);
assert.deepEqual(twice, [], 'one name for two things: rename one, or add it to SHARED if it is one thing');
});
19 changes: 18 additions & 1 deletion tests/unit/editor-input.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,24 @@ test('a second finger takes the stroke back; a quick two-finger tap undoes', ()
s.down(2, 100, 10);
s.up(2, 100, 10);
s.up(1, 10, 10);
assert.deepEqual(s.log0(), [['draw', 10], ['cancel'], ['view'], ['view-end'], ['taps', 2]]);
// The view starts again from the finger that stays (see the next test).
assert.deepEqual(s.log0(), [['draw', 10], ['cancel'], ['view'], ['view'], ['view-end'], ['taps', 2]]);
});

test('a finger left after a pinch carries on from where the view is, not from the pinch’s start', () => {
const starts = [];
const on = {};
drawInput(/** @type {any} */ ({ addEventListener: (type, f) => { on[type] = f; }, setPointerCapture() {} }), {
draw: () => ({ move() {}, end() {}, cancel() {} }), pick() {}, taps() {},
view: { start: (m, d) => starts.push([m.x, d]), move() {}, end() {} },
});
const ev = (type, id, x) => on[type]({ type, pointerId: id, pointerType: 'touch', clientX: x, clientY: 0, button: 0, target: { closest: () => null } });
ev('pointerdown', 1, 0);
ev('pointerdown', 2, 100);
ev('pointermove', 2, 300);
ev('pointerup', 2, 300);
// Measured from its start, one finger has no spread, and the zoom would spring back.
assert.deepEqual(starts, [[50, 100], [0, 0]]);
});

test('three fingers tapped together redo', () => {
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/i18n.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// The two languages kept in step with each other and with the code: the
// same keys, the same arguments, no string the code never asks for, and
// no key the code asks for that is not there (it would show as the key).
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

const { dictionaries } = await import('../../web/src/i18n.js');
const { ru, en } = dictionaries;

const SRC = new URL('../../web/src/', import.meta.url).pathname;
const walk = (d) => readdirSync(d).flatMap((f) => {
const p = join(d, f);
return statSync(p).isDirectory() ? walk(p) : [p];
});
const code = walk(SRC).filter((f) => f.endsWith('.js') && !f.endsWith('i18n.js')).map((f) => ({ f: relative(SRC, f), s: readFileSync(f, 'utf8') }));
const all = code.map((c) => c.s).join('\n');

test('both languages have the same strings, taking the same arguments', () => {
const ruKeys = Object.keys(ru).sort(), enKeys = Object.keys(en).sort();
assert.deepEqual(ruKeys.filter((k) => !(k in en)), [], 'in Russian only');
assert.deepEqual(enKeys.filter((k) => !(k in ru)), [], 'in English only');
const shape = (v) => (typeof v === 'function' ? `fn/${v.length}` : typeof v);
assert.deepEqual(ruKeys.filter((k) => shape(ru[k]) !== shape(en[k])).map((k) => `${k}: ${shape(ru[k])} vs ${shape(en[k])}`), []);
});

test('every string is asked for somewhere, and every string asked for is there', () => {
// Keys made in code: `ed.h.${tool}`, reason('backup.err', e).
const prefixes = new Set([
...[...all.matchAll(/`([a-z][\w.-]*\.)\$\{/g)].map((m) => m[1]),
...[...all.matchAll(/reason\('([a-z][\w.-]*)'/g)].map((m) => `${m[1]}.`),
]);
const quoted = new Set([...all.matchAll(/'([a-z][\w-]*(?:\.[\w-]+)+)'/g)].map((m) => m[1]));
// And `${key}.title`: a key with a suffix, the key itself quoted.
const suffixes = new Set([...all.matchAll(/\$\{[^}]*\}(\.[\w-]+)`/g)].map((m) => m[1]));
const used = (k) => quoted.has(k) || [...prefixes].some((p) => k.startsWith(p)) || [...suffixes].some((x) => k.endsWith(x) && quoted.has(k.slice(0, -x.length)));
const unused = Object.keys(ru).filter((k) => !used(k));
assert.deepEqual(unused, [], 'strings nothing asks for');
// Literal keys handed to t(): each must exist.
const missing = [];
for (const { f, s } of code) for (const m of s.matchAll(/\bt\('([^']+)'/g)) if (!(m[1] in ru)) missing.push(`${f}: ${m[1]}`);
assert.deepEqual(missing, [], 'asked for, but not there');
});

test('no Russian quotes or untranslated words in English', () => {
const bad = Object.entries(en).filter(([, v]) => /[«»а-яё]/i.test(typeof v === 'function' ? v.toString() : v)).map(([k]) => k);
assert.deepEqual(bad, []);
});
9 changes: 9 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ thing, with one way of fitting together:
screen until another screen needs it.
- **Every number has a reason** written next to it: a constant is named, and
says where it comes from (the format, a measurement, DECISIONS.md).
- **A size the CSS sets is read, not copied.** When code must lay out
something the same way the stylesheet does, the value lives in a CSS
custom property (`cssNumber()` in `dom.js`) or is measured.
- **A class has one owner**: the module that makes it. CSS is global, so a
second module picking the same name styles the first. The shared
vocabulary (buttons, fields, sheets, the card's parts) is listed in
`tests/unit/css-classes.test.mjs`, which also refuses rules for classes
nothing makes. `tests/unit/i18n.test.mjs` keeps both languages and the
code in step.
- **Fix causes, not symptoms.** A bug is understood before it is fixed, and
the fix comes with a test that fails without it.

Expand Down
Loading
Loading