Files
Wegwichtel/test/groups-ui.test.js
T
Florian Zumpe a66a795407
Sonarqube Scanner / Prepare environment (push) Successful in 6s
Sonarqube Scanner / Build and test (push) Successful in 22s
Sonarqube Scanner / SonarQube analysis (push) Successful in 56s
Sonarqube Scanner / Deploy to Server (push) Successful in 6s
group editor code display changed
2026-10-18 23:03:12 +02:00

238 lines
12 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import vm from 'node:vm';
import { loadI18n } from '../test-support/i18n.js';
class Element extends EventTarget {
constructor() { super(); this.children = []; this.attributes = new Map(); this.dataset = {}; this.scrollTop = 0; this.open = false; }
appendChild(node) { this.children.push(node); return node; }
append(...nodes) { nodes.forEach(node => this.appendChild(node)); }
replaceChildren(...nodes) { this.children = nodes; }
setAttribute(name, value) { this.attributes.set(name, value); }
showModal() { this.open = true; }
close() { this.open = false; this.dispatchEvent(new Event('close')); }
scrollTo({ top }) { this.scrollTop = top; this.dispatchEvent(new Event('scroll')); }
}
const tick = async () => { for (let i = 0; i < 4; i += 1) await new Promise(resolve => setTimeout(resolve, 0)); };
function pointer(node, properties = {}) {
const event = new Event('pointerup', { cancelable: true });
for (const [key, value] of Object.entries(properties)) Object.defineProperty(event, key, { value });
node.dispatchEvent(event);
}
function key(node, value) {
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value }); node.dispatchEvent(event);
}
async function harness(loading = false) {
const ids = ['group-join-open', 'editor-group-join', 'editor-group-list', 'group-dialog', 'group-close',
'group-entry-dialog', 'group-entry-close', 'group-entry-status', 'group-route',
'group-status', 'group-wheels', 'group-entry', 'group-invitation', 'group-code', 'group-join', 'group-edit', 'group-expiry'];
const nodes = new Map(ids.map(id => [id, new Element()]));
const document = new EventTarget();
document.readyState = loading ? 'loading' : 'complete';
document.getElementById = id => nodes.get(id);
document.createElement = () => new Element();
const calls = [], edits = [];
const controls = { error: 0, offline: false, confirm: true, active: true, role: 'owner' };
const group = () => ({ id: 'group-id', route: { id: 22, name: 'Waldweg' }, revision: 0, role: controls.role, active: controls.active });
const context = { Event, document, Date, Map, Set, Headers, console, globalThis: null,
confirm: () => controls.confirm, matchMedia: () => ({ matches: false }),
Wegwichtel: { Config: { apiBase: '/api/' }, LocalRoutes: { creatorId: () => 'a'.repeat(64) },
EditorUi: { async openGroup(route) { edits.push(route.id); } } },
fetch: async (url, options) => {
calls.push({ url, ...options });
if (controls.offline) throw new TypeError('Failed to fetch');
if (controls.error) return { ok: false, status: controls.error };
let value;
if (options.method === 'GET') value = { groups: [group()] };
else if (options.method === 'DELETE') { controls.active = false; value = { closed: true }; }
else if (url.endsWith('/join')) value = { group: group() };
else value = { group: group(), code: Array(5).fill('🐦'), expiresAt: 1800000000 };
return { ok: true, json: async () => value };
}
};
context.globalThis = context;
await loadI18n(context);
for (const module of ['group-symbols', 'groups']) {
const filename = path.resolve('public/js', module + '.js');
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
}
return { nodes, context, calls, controls, edits, groups: context.Wegwichtel.Groups };
}
test('group code wheels support arrows, keyboard wrapping, scrolling and joining with ordered Unicode', async () => {
const h = await harness(true);
h.context.document.dispatchEvent(new Event('DOMContentLoaded'));
pointer(h.nodes.get('group-join-open'), { isPrimary: false });
assert.equal(h.nodes.get('group-entry-dialog').open, false);
pointer(h.nodes.get('group-join-open'));
assert.equal(h.nodes.get('group-entry-dialog').open, true);
const columns = h.nodes.get('group-wheels').children;
assert.equal(columns.length, 5);
const [up, viewport, down] = columns[0].children;
assert.equal(viewport.attributes.get('role'), 'spinbutton');
key(up, 'Enter');
assert.equal(viewport.attributes.get('aria-valuenow'), '9');
pointer(down);
assert.equal(viewport.attributes.get('aria-valuenow'), '1');
key(viewport, 'End'); key(viewport, 'ArrowUp');
assert.equal(viewport.attributes.get('aria-valuenow'), '8');
key(viewport, 'Home'); key(viewport, 'ArrowDown');
pointer(viewport.children[5]);
assert.equal(viewport.attributes.get('aria-valuetext'), 'Vogel');
viewport.scrollTop = 3 * 96; viewport.dispatchEvent(new Event('scroll'));
key(h.nodes.get('group-join'), ' ');
await tick();
const joined = h.calls.find(call => call.url.endsWith('/join'));
const body = JSON.parse(joined.body);
assert.equal(body.code.length, 5);
assert.equal(body.code[0], '🌼');
assert.equal(body.creator_id, 'a'.repeat(64));
assert.deepEqual(h.edits, [22]);
assert.equal(h.nodes.get('group-entry-dialog').open, false);
pointer(h.nodes.get('editor-group-join'));
pointer(h.nodes.get('group-entry-close'));
assert.equal(h.nodes.get('group-entry-dialog').open, false);
});
test('sharing displays five motifs and the owner can renew or close access with confirmation', async () => {
const h = await harness();
await h.groups.share(7);
assert.equal(JSON.parse(h.calls[0].body).routeId, 7);
assert.equal(h.nodes.get('group-code').children.length, 5);
assert.equal(h.nodes.get('group-entry-dialog').open, false);
assert.equal(h.nodes.get('group-dialog').open, true);
assert.match(h.nodes.get('group-expiry').textContent, /Code gilt/);
const shareButton = h.groups.shareButton('share-route');
assert.equal(shareButton.dataset.editorAction, 'share-route');
assert.equal(shareButton.children.length, 1);
assert.equal(shareButton.children[0].src, '/images/icons/group-edit.svg');
assert.equal(shareButton.children[0].alt, '');
assert.equal(shareButton.attributes.get('aria-haspopup'), 'dialog');
pointer(h.nodes.get('group-edit')); await tick();
assert.deepEqual(h.edits, [22]);
let actions = h.nodes.get('editor-group-list').children[0].children[1].children;
h.controls.confirm = false;
pointer(actions[2]); await tick();
assert.equal(h.calls.some(call => call.url.endsWith('/code')), false);
h.controls.confirm = true;
pointer(actions[2]); await tick();
assert.ok(h.calls.some(call => call.url.endsWith('/code')));
actions = h.nodes.get('editor-group-list').children[0].children[1].children;
pointer(actions[1]); await tick();
assert.equal(h.edits.length, 2);
pointer(actions[3]); await tick();
assert.ok(h.calls.some(call => call.method === 'DELETE'));
h.controls.role = 'member'; await h.groups.refresh();
assert.equal(h.nodes.get('editor-group-list').children[0].children[1].children.length, 2);
});
test('code failures are localized and group list refresh never advances an editing revision', async () => {
const h = await harness();
assert.equal(h.groups.headersFor('/routes/22')['If-Match'], undefined);
h.groups.observe({ headers: new Headers({ 'X-Group-Route': '22', 'X-Group-Revision': '3' }) });
assert.equal(h.groups.headersFor('/routes/22/pois/1')['If-Match'], '3');
await h.groups.refresh();
assert.equal(h.groups.headersFor('/routes/22')['If-Match'], '3');
assert.equal(h.groups.headersFor('/routes/23')['If-Match'], undefined);
h.groups.observe({ headers: new Headers({ 'X-Group-Route': '22', 'X-Group-Revision': 'invalid' }) });
assert.equal(h.groups.headersFor('/routes/22')['If-Match'], '3');
pointer(h.nodes.get('group-join-open'));
for (const [status, text] of [[404, /Code stimmt/], [401, /nicht geöffnet/], [403, /nicht geöffnet/], [409, /inzwischen geändert/], [429, /eine Minute/], [500, /Internetverbindung/]]) {
h.controls.error = status;
pointer(h.nodes.get('group-join')); await tick();
assert.match(h.nodes.get('group-entry-status').textContent, text);
assert.equal(h.nodes.get('group-join').disabled, false);
}
h.controls.error = 0; h.controls.offline = true;
h.context.Wegwichtel.I18n.setLanguage('en', null);
pointer(h.nodes.get('group-join')); await tick();
assert.match(h.nodes.get('group-entry-status').textContent, /internet connection/);
await h.groups.refresh();
});
test('a late join response does not reopen a cancelled dialog or editor', async () => {
const h = await harness();
const original = h.context.fetch;
let release;
h.context.fetch = async (url, options) => {
if (url.endsWith('/join')) await new Promise(resolve => { release = resolve; });
return original(url, options);
};
pointer(h.nodes.get('group-join-open'));
pointer(h.nodes.get('group-join'));
await tick();
pointer(h.nodes.get('group-entry-close'));
release();
await tick();
assert.equal(h.nodes.get('group-entry-dialog').open, false);
assert.deepEqual(h.edits, []);
});
test('unsent station changes are listed before network access and discarded only after confirmation', async () => {
const h = await harness();
const list = new Element(); h.nodes.set('editor-point-drafts', list);
const drafts = [{ route: { id: 7, name: 'Waldweg' }, poiId: 8, values: { title: 'Blumen' } }];
const opened = [];
h.context.Wegwichtel.EditorUi.openPendingPoint = async draft => opened.push(draft.poiId);
h.context.Wegwichtel.GroupPoints = { async list() { return drafts; }, async remove() { drafts.splice(0); } };
h.controls.offline = true;
await h.groups.refresh();
assert.equal(list.children.length, 1);
pointer(list.children[0].children[1].children[0]); await tick();
assert.deepEqual(opened, [8]);
h.controls.confirm = false;
pointer(list.children[0].children[1].children[1]); await tick();
assert.equal(drafts.length, 1);
h.controls.confirm = true;
pointer(list.children[0].children[1].children[1]); await tick();
h.context.document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')); await tick();
assert.equal(list.children.length, 0);
});
test('sharing opens the actual five-symbol invitation directly without a two-symbol preview', async () => {
const h = await harness();
await h.groups.share(7);
assert.equal(h.nodes.get('group-dialog').open, true);
assert.equal(h.nodes.get('group-entry-dialog').open, false);
assert.equal(h.nodes.get('group-code').children.length, 5);
assert.equal(h.nodes.get('group-route').textContent, 'Waldweg');
pointer(h.nodes.get('group-close'));
pointer(h.nodes.get('editor-group-join'));
assert.equal(h.nodes.get('group-entry-dialog').open, true);
assert.equal(h.nodes.get('group-dialog').open, false);
assert.equal(h.nodes.get('group-wheels').children.length, 5);
});
test('an incomplete invitation is never displayed as a two-symbol group code', async () => {
const h = await harness();
h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { route: { id: 7 } }, code: ['🐦', '🪨'] }) });
await assert.rejects(h.groups.share(7));
assert.equal(h.nodes.get('group-dialog').open, false);
assert.equal(h.nodes.get('group-code').children.length, 0);
});
test('the open invitation refreshes its code automatically and shows a deferred renewal during station editing', async () => {
const h = await harness();
await h.groups.share(7);
const original = h.context.fetch;
h.context.fetch = async (url, options) => {
if (!url.endsWith('/invitation')) return original(url, options);
return { ok: true, json: async () => ({ group: { id: 'group-id', route: { id: 22 }, role: 'owner' },
busy: true, expired: true, expiresAt: 1800000000 }) };
};
await h.groups.refreshInvitation();
assert.match(h.nodes.get('group-status').textContent, /Station bearbeitet/);
h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { id: 'group-id', route: { id: 22 }, role: 'owner' },
busy: false, expired: false, expiresAt: 1800001800, code: Array(5).fill('🪨') }) });
await h.groups.refreshInvitation();
assert.equal(h.nodes.get('group-code').children.length, 5);
assert.match(h.nodes.get('group-code').children[0].src, /🪨/);
assert.equal(h.nodes.get('group-status').textContent, '');
h.nodes.get('group-dialog').close();
h.context.fetch = async () => { throw new Error('must not fetch a closed invitation'); };
await h.groups.refreshInvitation();
});