1// Tests of the home page layout of static/js/ermite.js (window.ermiteLayout)
2// with store.js, on the small DOM of dom.mjs. Only the ermiteLayout block is
3// evaluated: the rest needs a page. Run with
4// node --test 'internal/adapters/web/jstest/*.test.mjs'
5import assert from 'node:assert/strict';
6import { readFileSync } from 'node:fs';
7import { beforeEach, test } from 'node:test';
8import { el, installDom } from './dom.mjs';
9
10const read = (f) => readFileSync(new URL('../static/js/' + f, import.meta.url), 'utf8');
11const ermite = read('ermite.js');
12const start = ermite.indexOf(' window.ermiteLayout = ');
13const block = ermite.slice(start, ermite.indexOf(' })();', start) + ' })();'.length);
14const storeSrc = read('store.js');
15
16const doc = installDom();
17let listeners;
18let storage;
19let fits;
20let home;
21let buttons;
22
23// A page: .home and the switch, with "cards" pressed as the server renders it.
24// Then store.js and the block are evaluated, as a page load does.
25function load({ stored, storageObj } = {}) {
26 listeners = {};
27 fits = 0;
28 storage = storageObj ?? new Map(stored ? Object.entries(stored) : []);
29 globalThis.localStorage = storageObj ?? {
30 getItem: (k) => (storage.has(k) ? storage.get(k) : null),
31 setItem: (k, v) => { storage.set(k, String(v)); },
32 removeItem: (k) => { storage.delete(k); },
33 };
34 globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
35 globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
36 home = el('div', { cls: 'home', attrs: { 'data-layout': 'cards' } });
37 buttons = ['cards', 'split', 'compact'].map((v) => el('button', {
38 cls: 'btn icon', attrs: { type: 'button', 'data-set-layout': v, 'aria-pressed': String(v === 'cards') },
39 }));
40 const sw = el('div', { cls: 'layout-switch', attrs: { role: 'group', 'data-js': '' } }, ...buttons);
41 doc.body.replaceChildren(sw, home);
42 window.ermiteFit = { all: () => { fits++; } };
43 new Function(storeSrc)();
44 new Function(block)();
45 return window.ermiteLayout;
46}
47
48const pressed = () => buttons.map((b) => b.getAttribute('aria-pressed'));
49const click = (target) => listeners.click.forEach((f) => f({ target }));
50
51beforeEach(() => { delete globalThis.localStorage; });
52
53test('with nothing stored the layout is cards, and the page is as rendered', () => {
54 const L = load();
55 assert.equal(L.get(), 'cards');
56 assert.deepEqual(L.values, ['cards', 'split', 'compact']);
57 assert.equal(home.getAttribute('data-layout'), 'cards');
58 assert.deepEqual(pressed(), ['true', 'false', 'false']);
59});
60
61test('the stored layout is applied when the page loads', () => {
62 const L = load({ stored: { 'home-layout': 'compact' } });
63 assert.equal(L.get(), 'compact');
64 assert.equal(home.getAttribute('data-layout'), 'compact');
65 assert.deepEqual(pressed(), ['false', 'false', 'true']);
66 assert.ok(fits > 0, 'the chips are fitted again');
67});
68
69test('a stored value that is not a layout falls back to cards', () => {
70 const L = load({ stored: { 'home-layout': 'grid' } });
71 assert.equal(L.get(), 'cards');
72 assert.equal(home.getAttribute('data-layout'), 'cards');
73 assert.deepEqual(pressed(), ['true', 'false', 'false']);
74});
75
76test('set stores the layout, shows it and fits the chips again', () => {
77 const L = load();
78 fits = 0;
79 L.set('split');
80 assert.equal(storage.get('home-layout'), 'split');
81 assert.equal(home.getAttribute('data-layout'), 'split');
82 assert.deepEqual(pressed(), ['false', 'true', 'false']);
83 assert.equal(fits, 1);
84 L.set('nope');
85 assert.equal(L.get(), 'split', 'an unknown layout is ignored');
86 assert.equal(storage.get('home-layout'), 'split');
87});
88
89test('pressing a layout button sets it; other clicks do nothing', () => {
90 const L = load();
91 click(buttons[2]);
92 assert.equal(L.get(), 'compact');
93 assert.equal(home.getAttribute('data-layout'), 'compact');
94 assert.deepEqual(pressed(), ['false', 'false', 'true']);
95 click(home);
96 click({ closest: undefined });
97 assert.equal(L.get(), 'compact');
98 // a child of the button (an icon) counts as the button
99 const icon = el('svg');
100 buttons[1].append(icon);
101 click(icon);
102 assert.equal(L.get(), 'split');
103});
104
105test("another tab's change applies live", () => {
106 const L = load();
107 storage.set('home-layout', 'split');
108 listeners.storage.forEach((f) => f({ key: 'home-layout', newValue: 'split', storageArea: localStorage }));
109 assert.equal(L.get(), 'split');
110 assert.equal(home.getAttribute('data-layout'), 'split');
111 assert.deepEqual(pressed(), ['false', 'true', 'false']);
112 storage.delete('home-layout');
113 listeners.storage.forEach((f) => f({ key: 'home-layout', newValue: null, storageArea: localStorage }));
114 assert.equal(home.getAttribute('data-layout'), 'cards', 'removed: back to the default');
115 listeners.storage.forEach((f) => f({ key: 'keys-off', newValue: '1', storageArea: localStorage }));
116 assert.equal(home.getAttribute('data-layout'), 'cards');
117});
118
119test('a page without .home or without ermiteFit still works', () => {
120 const L = load();
121 doc.body.replaceChildren();
122 delete window.ermiteFit;
123 L.set('split');
124 assert.equal(L.get(), 'split');
125 L.apply();
126});
127
128test('without usable storage the choice holds for the page', () => {
129 const refusing = {
130 getItem: () => { throw new Error('denied'); },
131 setItem: () => { throw new Error('quota'); },
132 removeItem: () => { throw new Error('denied'); },
133 };
134 const L = load({ storageObj: refusing });
135 assert.equal(L.get(), 'cards');
136 click(buttons[1]);
137 assert.equal(L.get(), 'split');
138 assert.equal(home.getAttribute('data-layout'), 'split');
139});