configs/server-dockers/jellyfin-news/index_test.js
server-dockers/jellyfin-news/index_test.jsRaw
1// Run with node --test index_test.js; no browser dependencies are required.
2const { test } = require('node:test');
3const assert = require('node:assert/strict');
4const fs = require('node:fs');
5const vm = require('node:vm');
6
7// Exercise the page's actual handlers against a minimal DOM and fetch stub.
8function page(options = {}) {
9 const ids = ['period', 'week-controls', 'month-controls', 'week-offset', 'month-offset', 'added', 'watched', 'posters', 'collage', 'collage-preview', 'collage-download', 'copy', 'output', 'status', 'extra-titles'];
10 const elements = Object.fromEntries(ids.map(id => [id, {
11 value: id === 'period' ? 'current-week' : id === 'extra-titles' ? '' : '-1',
12 events: {}, valid: true,
13 addEventListener(event, handler) { this.events[event] = handler; },
14 reportValidity() { return this.valid; },
15 removeAttribute(name) { delete this[name]; },
16 }]));
17 const requests = [];
18 const requestOptions = [];
19 const revoked = [];
20 const drawn = [];
21 const textPositions = [];
22 const canvas = {
23 getContext: () => ({
24 scale() {}, fillRect() {}, drawImage() { drawn.push('poster'); },
25 fillText(text, x, y) { drawn.push(text); textPositions.push({ text, x, y }); },
26 measureText: text => ({ width: text.length * 10 }),
27 }),
28 toBlob: callback => callback(options.exportFails ? null : new Blob(['png'], { type: 'image/png' })),
29 };
30 const script = fs.readFileSync(`${__dirname}/index.html`, 'utf8').split('<script>')[1].split('</script>')[0];
31 vm.runInNewContext(script, {
32 URLSearchParams,
33 URL: { createObjectURL: () => 'blob:collage', revokeObjectURL: url => revoked.push(url) },
34 setTimeout, clearTimeout,
35 Image: class {
36 naturalWidth = 480;
37 naturalHeight = 720;
38 set src(value) { if (value) queueMicrotask(() => value.includes('missing') ? this.onerror() : this.onload()); }
39 },
40 document: {
41 createElement: () => canvas,
42 querySelector: selector => elements[selector.slice(1)],
43 querySelectorAll: selector => selector === 'input'
44 ? [elements['week-offset'], elements['month-offset']] : Object.values(elements),
45 },
46 fetch: async (url, request) => {
47 requests.push(url);
48 requestOptions.push(request);
49 return options.response || { ok: true, text: async () => 'recap', json: async () => options.data || { titles: [] } };
50 },
51 });
52 return { elements, requests, requestOptions, revoked, drawn, textPositions, canvas };
53}
54
55test('period selection fetches nothing and watched can run first for every period', () => {
56 for (const period of ['current-week', 'week', 'month']) {
57 const { elements, requests } = page();
58 elements.output.value = 'old recap';
59 elements.period.value = period;
60 elements.period.events.change();
61 assert.deepEqual(requests, []);
62 assert.equal(elements.output.value, '');
63 assert.equal(elements['week-controls'].hidden, period !== 'week');
64 assert.equal(elements['month-controls'].hidden, period !== 'month');
65 elements.watched.events.click();
66 const url = new URL(requests[0], 'http://localhost');
67 assert.equal(url.pathname, '/api/watched');
68 assert.equal(url.searchParams.get('period'), period);
69 assert.equal(url.searchParams.get('offset'), period === 'current-week' ? null : '-1');
70 }
71});
72
73test('both report actions use the explicit period and edited offset', () => {
74 for (const period of ['current-week', 'week', 'month']) {
75 for (const action of ['added', 'watched']) {
76 const { elements, requests } = page();
77 elements.period.value = period;
78 elements.period.events.change();
79 if (period !== 'current-week') {
80 elements[`${period}-offset`].value = '-3';
81 elements[`${period}-offset`].events.input();
82 }
83 assert.equal(requests.length, 0);
84 elements[action].events.click();
85 const url = new URL(requests[0], 'http://localhost');
86 assert.equal(url.pathname, action === 'added' ? `/api/${period}` : '/api/watched');
87 assert.equal(url.searchParams.get('offset'), period === 'current-week' ? null : '-3');
88 }
89 }
90});
91
92test('invalid offsets prevent either report, but do not affect current week', () => {
93 for (const action of ['added', 'watched']) {
94 const { elements, requests } = page();
95 elements.period.value = 'week';
96 elements['week-offset'].valid = false;
97 elements[action].events.click();
98 assert.equal(requests.length, 0);
99 elements.period.value = 'current-week';
100 elements[action].events.click();
101 assert.equal(requests.length, 1);
102 }
103});
104
105test('poster generation uses every selected period, including months', async () => {
106 for (const period of ['current-week', 'week', 'month']) {
107 const { elements, requests } = page();
108 elements.period.value = period;
109 if (period !== 'current-week') elements[`${period}-offset`].value = '-2';
110 await elements.posters.events.click();
111 const url = new URL(requests[0], 'http://localhost');
112 assert.equal(url.pathname, '/api/posters');
113 assert.equal(url.searchParams.get('period'), period);
114 assert.equal(url.searchParams.get('offset'), period === 'current-week' ? null : '-2');
115 assert.equal(elements.collage.hidden, true);
116 assert.match(elements.status.textContent, /No new movies/);
117 assert.equal(elements.posters.disabled, false);
118 }
119});
120
121test('invalid poster offset prevents fetching', async () => {
122 const { elements, requests } = page();
123 elements.period.value = 'month';
124 elements['month-offset'].valid = false;
125 await elements.posters.events.click();
126 assert.deepEqual(requests, []);
127});
128
129test('extra recap entries are sent only with poster generation', async () => {
130 const { elements, requests, requestOptions, revoked } = page({ data: collageData });
131 const extras = 'Movies:\n• Film (2024)\n\nShows:\n• Series — Season 2, episode 1';
132 elements['extra-titles'].value = extras;
133 elements.added.events.click();
134 assert.equal(requests[0], '/api/current-week');
135 await elements.posters.events.click();
136 assert.equal(new URL(requests[1], 'http://localhost').pathname, '/api/posters');
137 assert.equal(requestOptions[1].method, 'POST');
138 assert.equal(requestOptions[1].body, extras);
139 elements['extra-titles'].events.input();
140 assert.deepEqual(revoked, ['blob:collage']);
141 assert.equal(elements.collage.hidden, true);
142});
143
144const collageData = {
145 start: '2026-08-01', end: '2026-09-01', title: 'Monthly update',
146 titles: [
147 { name: 'Movie', kind: 'Movies', url: '/api/poster/movie' },
148 { name: 'Show', kind: 'Shows', url: '/api/poster/missing' },
149 ],
150};
151
152test('collage groups posters beneath one heading per category, including partial rows', async () => {
153 for (const kinds of [['Movies', 'Shows'], ['Películas', 'Series'], ['Shows']]) {
154 const titles = kinds.flatMap(kind => Array.from({ length: 3 }, (_, i) => ({
155 name: `${kind} title ${i}`, kind, url: '',
156 })));
157 const { elements, textPositions, canvas } = page({ data: { ...collageData, titles } });
158 await elements.posters.events.click();
159 assert.equal(elements.collage.hidden, false);
160 let previousBottom = 0;
161 for (const kind of kinds) {
162 const headings = textPositions.filter(item => item.text === kind);
163 assert.equal(headings.length, 1);
164 assert.ok(headings[0].y > previousBottom);
165 const captions = textPositions.filter(item => item.text.startsWith(`${kind} title`));
166 assert.equal(captions.length, 3);
167 assert.ok(captions.every(item => item.y > headings[0].y));
168 assert.equal(captions[0].x, headings[0].x);
169 previousBottom = Math.max(...captions.map(item => item.y));
170 }
171 assert.ok(canvas.height > previousBottom);
172 if (kinds.length === 1) assert.ok(!textPositions.some(item => item.text === 'Movies'));
173 }
174});
175
176test('collage includes missing artwork and can be downloaded and cleared', async () => {
177 const { elements, drawn, revoked } = page({ data: collageData });
178 await elements.posters.events.click();
179 assert.equal(elements.collage.hidden, false);
180 assert.equal(elements['collage-download'].href, 'blob:collage');
181 assert.equal(elements['collage-download'].download, 'jellyfin-posters-2026-08-01-2026-09-01.png');
182 assert.equal(elements['collage-preview'].src, 'blob:collage');
183 assert.ok(drawn.includes('poster'));
184 assert.ok(drawn.includes('Show'));
185 assert.ok(drawn.includes('Poster unavailable'));
186 assert.match(elements.status.textContent, /1 poster\(s\) unavailable/);
187 elements.period.events.change();
188 assert.deepEqual(revoked, ['blob:collage']);
189 assert.equal(elements.collage.hidden, true);
190 assert.equal(elements['collage-download'].href, undefined);
191});
192
193test('poster request and export failures restore controls without a stale download', async () => {
194 for (const options of [
195 { response: { ok: false, text: async () => 'Could not query Jellyfin.' } },
196 { data: collageData, exportFails: true },
197 ]) {
198 const { elements } = page(options);
199 await elements.posters.events.click();
200 assert.match(elements.status.textContent, /Could not/);
201 assert.equal(elements.posters.disabled, false);
202 assert.equal(elements.period.disabled, false);
203 assert.equal(elements.collage.hidden, true);
204 }
205});