238 lines
12 KiB
JavaScript
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();
|
|
});
|