arnaucube
arnaucube/configsPublic Self-hosted repository
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});