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(); });