'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM } = require('jsdom');
const SOURCE = fs.readFileSync(path.join(__dirname, '../../static/read-more.js'), 'utf8');
// jsdom has no layout: heights come from data-h on each .text element.
const measure = (el) => Number(el.dataset.h || 0);
function entry(id, height, notRead = false) {
return `
Article ${id}

`;
}
function setup(entries) {
const dom = new JSDOM(`${entries.join('')}`,
{ runScripts: 'outside-only' });
dom.window.eval(SOURCE);
return { window: dom.window, document: dom.window.document, ReadMore: dom.window.ReadMore };
}
const OPTS = { collapsedHeight: 250, label: 'Lire plus', measure };
function setContext(window, view) {
window.context = {
current_view: view,
extensions: { read_more: { collapsed_height: 250, label: 'Lire plus' } },
};
}
test('shouldCollapse thresholds', () => {
const { ReadMore } = setup([]);
assert.equal(ReadMore.THRESHOLD_RATIO, 1.5);
assert.equal(ReadMore.shouldCollapse(374, 250), false);
assert.equal(ReadMore.shouldCollapse(375, 250), true);
assert.equal(ReadMore.shouldCollapse(1000, 250), true);
});
test('collapses tall entry', () => {
const { document, ReadMore } = setup([entry(1, 1000)]);
ReadMore.processEntries(document, OPTS);
const flux = document.getElementById('flux_1');
const text = flux.querySelector('.text');
assert.ok(text.classList.contains('read-more-collapsed'));
assert.equal(text.style.getPropertyValue('--read-more-height'), '250px');
const buttons = flux.querySelectorAll('.read-more-btn');
assert.equal(buttons.length, 1);
assert.equal(buttons[0].textContent, 'Lire plus');
assert.equal(buttons[0].type, 'button');
assert.equal(text.nextElementSibling, buttons[0]);
assert.equal(flux.dataset.readMore, 'collapsed');
});
test('skips short entry', () => {
const { document, ReadMore } = setup([entry(1, 300)]);
ReadMore.processEntries(document, OPTS);
const flux = document.getElementById('flux_1');
assert.ok(!flux.querySelector('.text').classList.contains('read-more-collapsed'));
assert.equal(flux.querySelectorAll('.read-more-btn').length, 0);
assert.equal(flux.dataset.readMore, 'short');
});
test('is idempotent', () => {
const { document, ReadMore } = setup([entry(1, 1000), entry(2, 1000)]);
ReadMore.processEntries(document, OPTS);
ReadMore.processEntries(document, OPTS);
for (const flux of document.querySelectorAll('.flux')) {
assert.equal(flux.querySelectorAll('.read-more-btn').length, 1);
}
});
test('click expands', () => {
const { window, document, ReadMore } = setup([entry(1, 1000)]);
ReadMore.processEntries(document, OPTS);
const flux = document.getElementById('flux_1');
let parentClicks = 0;
flux.addEventListener('click', () => { parentClicks++; });
const event = new window.MouseEvent('click', { bubbles: true, cancelable: true });
flux.querySelector('.read-more-btn').dispatchEvent(event);
assert.ok(!flux.querySelector('.text').classList.contains('read-more-collapsed'));
assert.equal(flux.querySelectorAll('.read-more-btn').length, 0);
assert.equal(flux.dataset.readMore, 'expanded');
assert.equal(event.defaultPrevented, true);
assert.equal(parentClicks, 0);
});
test('does not re-collapse expanded entry', () => {
const { document, ReadMore } = setup([entry(1, 1000)]);
ReadMore.processEntries(document, OPTS);
document.querySelector('.read-more-btn').click();
ReadMore.processEntries(document, OPTS);
const flux = document.getElementById('flux_1');
assert.ok(!flux.querySelector('.text').classList.contains('read-more-collapsed'));
assert.equal(flux.querySelectorAll('.read-more-btn').length, 0);
assert.equal(flux.dataset.readMore, 'expanded');
});
test('does not touch read state', () => {
const { document, ReadMore } = setup([entry(1, 1000, true)]);
ReadMore.processEntries(document, OPTS);
const flux = document.getElementById('flux_1');
assert.ok(flux.classList.contains('not_read'));
flux.querySelector('.read-more-btn').click();
assert.ok(flux.classList.contains('not_read'));
});
test('reevaluates short entry after image load', () => {
const { window, document, ReadMore } = setup([entry(1, 300)]);
setContext(window, 'reader');
ReadMore.init(window, measure);
const flux = document.getElementById('flux_1');
assert.equal(flux.dataset.readMore, 'short');
const text = flux.querySelector('.text');
text.dataset.h = '1000';
text.querySelector('img').dispatchEvent(new window.Event('load'));
assert.ok(text.classList.contains('read-more-collapsed'));
assert.equal(flux.dataset.readMore, 'collapsed');
assert.equal(flux.querySelectorAll('.read-more-btn').length, 1);
});
test('init ignores non-reader view', () => {
const { window, document, ReadMore } = setup([entry(1, 1000)]);
setContext(window, 'normal');
ReadMore.init(window, measure);
assert.equal(document.querySelectorAll('.read-more-btn').length, 0);
assert.equal(document.getElementById('flux_1').dataset.readMore, undefined);
});
test('init handles load-more', () => {
const { window, document, ReadMore } = setup([entry(1, 1000)]);
setContext(window, 'reader');
ReadMore.init(window, measure);
assert.equal(document.querySelectorAll('.read-more-btn').length, 1);
document.getElementById('stream').insertAdjacentHTML('beforeend', entry(2, 1000));
document.body.dispatchEvent(new window.Event('freshrss:load-more', { bubbles: true }));
assert.equal(document.querySelectorAll('.read-more-btn').length, 2);
assert.equal(document.getElementById('flux_2').dataset.readMore, 'collapsed');
});
test('init works before body is parsed', () => {
const { window, document, ReadMore } = setup([]);
document.documentElement.removeChild(document.body);
setContext(window, 'reader');
assert.doesNotThrow(() => ReadMore.init(window, measure));
const body = document.createElement('body');
body.innerHTML = entry(1, 1000);
document.documentElement.appendChild(body);
body.dispatchEvent(new window.Event('freshrss:load-more', { bubbles: true }));
assert.equal(document.getElementById('flux_1').dataset.readMore, 'collapsed');
});
test('late image load does not collapse entry already above viewport top', () => {
const { window, document, ReadMore } = setup([entry(1, 300)]);
setContext(window, 'reader');
ReadMore.init(window, measure);
const flux = document.getElementById('flux_1');
flux.getBoundingClientRect = () => ({ top: -100, bottom: 200 });
const text = flux.querySelector('.text');
text.dataset.h = '1000';
text.querySelector('img').dispatchEvent(new window.Event('load'));
assert.ok(!text.classList.contains('read-more-collapsed'));
assert.equal(flux.querySelectorAll('.read-more-btn').length, 0);
});
test('load-more does not collapse short entry already above viewport top', () => {
const { window, document, ReadMore } = setup([entry(1, 300)]);
setContext(window, 'reader');
ReadMore.init(window, measure);
const flux = document.getElementById('flux_1');
flux.getBoundingClientRect = () => ({ top: -100, bottom: 200 });
flux.querySelector('.text').dataset.h = '1000';
document.body.dispatchEvent(new window.Event('freshrss:load-more', { bubbles: true }));
assert.equal(flux.dataset.readMore, 'short');
assert.equal(flux.querySelectorAll('.read-more-btn').length, 0);
});