mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
Keep the news layer inside its day and its memory bounds
From review of the news layer: - An event's other sides older than 24 hours (or undated) are no longer listed, and a report dated more than 15 minutes ahead is outside the window, as in the regional brief. - Picture cards are one canvas per event in a bounded cache (24), repainted in place when the age changes, with each card's picture dropped with it; the age in the key had made a new canvas every minute. - The feed list drops reports that leave the window and is capped at 600. - "In view" is filtered from the reports the layer already holds; it reads the feed only with minor news on, once per refresh, never per pan. - Infinite scroll restarts after a filter change or a failed read. - The two filter selects stack, so "Significant and above" is not cut off. - The timeout tests hold the event loop until aborted, so they pass on Node 22 as well.
This commit is contained in:
1 parent
138cc77a11
commit
e95ae7b80c
11 files changed
+545
-75
No files matched your search
@@ -124,10 +124,16 @@ test('upstream failures, bad JSON and timeouts become bounded errors', async ()
|
|||||||
const slow = mount({
|
const slow = mount({
|
||||||
origin: () => 'https://news.example',
|
origin: () => 'https://news.example',
|
||||||
timeoutMs: 10,
|
timeoutMs: 10,
|
||||||
|
// AbortSignal.timeout does not hold the event loop open: the hanging
|
||||||
|
// fetch does, until its signal aborts, so the run cannot end before it.
|
||||||
fetchImpl: (_url, { signal }) =>
|
fetchImpl: (_url, { signal }) =>
|
||||||
new Promise((_, reject) =>
|
new Promise((_, reject) => {
|
||||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
const alive = setTimeout(() => {}, 60_000);
|
||||||
),
|
signal.addEventListener('abort', () => {
|
||||||
|
clearTimeout(alive);
|
||||||
|
reject(signal.reason);
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const timedOut = await slow('/hotspots');
|
const timedOut = await slow('/hotspots');
|
||||||
assert.equal(timedOut.status, 504);
|
assert.equal(timedOut.status, 504);
|
||||||
|
|||||||
+46
-13
@@ -22,10 +22,23 @@ const SCALE = 2; // canvas pixels per CSS pixel
|
|||||||
const TITLE_FONT = '600 11px Inter, "Noto Sans Arabic", system-ui, sans-serif';
|
const TITLE_FONT = '600 11px Inter, "Noto Sans Arabic", system-ui, sans-serif';
|
||||||
const META_FONT = '9px "JetBrains Mono", "Noto Sans Arabic", monospace';
|
const META_FONT = '9px "JetBrains Mono", "Noto Sans Arabic", monospace';
|
||||||
const CREDIT_FONT = '8px Inter, "Noto Sans Arabic", system-ui, sans-serif';
|
const CREDIT_FONT = '8px Inter, "Noto Sans Arabic", system-ui, sans-serif';
|
||||||
const MAX_CARDS = 200;
|
// The map shows about ten cards plus the clicked one; a few more stay ready
|
||||||
|
// for a pan back. The least recently drawn card goes, and its picture with it.
|
||||||
|
export const MAX_CARDS = 24;
|
||||||
|
|
||||||
const pictures = new Map(); // url → {image, state: 'loading'|'ok'|'failed', waiters}
|
const pictures = new Map(); // url → {image, state: 'loading'|'ok'|'failed', waiters}
|
||||||
const cards = new Map(); // key → canvas
|
const cards = new Map(); // event id → {canvas, key, view, look, pic}, oldest first
|
||||||
|
|
||||||
|
/** Forget a picture no kept card shows any more. */
|
||||||
|
function releasePicture(url) {
|
||||||
|
if (!url || [...cards.values()].some((card) => card.view.image === url))
|
||||||
|
return;
|
||||||
|
const entry = pictures.get(url);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.waiters.clear();
|
||||||
|
entry.image.onload = entry.image.onerror = null;
|
||||||
|
pictures.delete(url);
|
||||||
|
}
|
||||||
|
|
||||||
function picture(url, onLoad) {
|
function picture(url, onLoad) {
|
||||||
let entry = pictures.get(url);
|
let entry = pictures.get(url);
|
||||||
@@ -176,13 +189,21 @@ function paint(canvas, view, look, pic) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The canvas of an event's card. `onChange` runs when its picture arrives and
|
* The canvas of an event's card, one per event: a change in what it shows
|
||||||
* the canvas has been repainted (ask the scene for a frame).
|
* (the age in its meta line ticks by the minute) repaints the same canvas.
|
||||||
|
* `onChange` runs when its picture arrives and the canvas has been repainted
|
||||||
|
* (ask the scene for a frame).
|
||||||
* @returns {HTMLCanvasElement|null} null where there is no canvas
|
* @returns {HTMLCanvasElement|null} null where there is no canvas
|
||||||
*/
|
*/
|
||||||
export function eventCard(id, view, look, onChange) {
|
export function eventCard(id, view, look, onChange) {
|
||||||
const key = `${id}|${look.severity}|${look.category}|${look.rim}|${view.image || ''}|${view.title}|${view.meta}`;
|
const key = `${look.severity}|${look.category}|${look.rim}|${view.image || ''}|${view.credit || ''}|${view.title}|${view.meta}`;
|
||||||
if (cards.has(key)) return cards.get(key);
|
let card = cards.get(id);
|
||||||
|
if (card) {
|
||||||
|
// Most recently drawn last.
|
||||||
|
cards.delete(id);
|
||||||
|
cards.set(id, card);
|
||||||
|
if (card.key === key) return card.canvas;
|
||||||
|
} else {
|
||||||
const canvas = globalThis.document?.createElement?.('canvas');
|
const canvas = globalThis.document?.createElement?.('canvas');
|
||||||
if (
|
if (
|
||||||
!canvas?.getContext?.('2d') ||
|
!canvas?.getContext?.('2d') ||
|
||||||
@@ -192,15 +213,27 @@ export function eventCard(id, view, look, onChange) {
|
|||||||
return null;
|
return null;
|
||||||
canvas.width = CARD_W * SCALE;
|
canvas.width = CARD_W * SCALE;
|
||||||
canvas.height = CARD_H * SCALE;
|
canvas.height = CARD_H * SCALE;
|
||||||
const pic = view.image
|
card = { canvas, key: null, view: {}, look, pic: null };
|
||||||
|
cards.set(id, card);
|
||||||
|
while (cards.size > MAX_CARDS) {
|
||||||
|
const [oldest, gone] = cards.entries().next().value;
|
||||||
|
cards.delete(oldest);
|
||||||
|
releasePicture(gone.view.image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const previous = card.view.image;
|
||||||
|
card.key = key;
|
||||||
|
card.view = view;
|
||||||
|
card.look = look;
|
||||||
|
const shown = card;
|
||||||
|
card.pic = view.image
|
||||||
? picture(view.image, () => {
|
? picture(view.image, () => {
|
||||||
paint(canvas, view, look, pic);
|
if (cards.get(id) !== shown || shown.view.image !== view.image) return;
|
||||||
|
paint(shown.canvas, shown.view, shown.look, shown.pic);
|
||||||
onChange?.();
|
onChange?.();
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
paint(canvas, view, look, pic);
|
if (previous && previous !== view.image) releasePicture(previous);
|
||||||
// Old cards go once there are many; the newest keys win.
|
paint(card.canvas, view, look, card.pic);
|
||||||
if (cards.size >= MAX_CARDS) cards.delete(cards.keys().next().value);
|
return card.canvas;
|
||||||
cards.set(key, canvas);
|
|
||||||
return canvas;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { MAX_CARDS, eventCard } from './cards.js';
|
||||||
|
|
||||||
|
const look = { severity: 3, category: 'strike', rim: 'confirmed', size: 20 };
|
||||||
|
const view = (extra = {}) => ({
|
||||||
|
title: 'Strike on Kharkiv',
|
||||||
|
meta: '10 min · 6 sources · UA, RU',
|
||||||
|
image: 'https://a.test/p.jpg',
|
||||||
|
credit: 'A',
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Node has no canvas: a 2D context that accepts every call, and an Image that
|
||||||
|
// records what was asked for.
|
||||||
|
function fakeBrowser(t) {
|
||||||
|
const saved = {
|
||||||
|
document: globalThis.document,
|
||||||
|
Path2D: globalThis.Path2D,
|
||||||
|
Image: globalThis.Image,
|
||||||
|
};
|
||||||
|
const images = [];
|
||||||
|
const ctx = new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_target, name) =>
|
||||||
|
name === 'measureText'
|
||||||
|
? (text) => ({ width: String(text).length * 5 })
|
||||||
|
: name === 'createLinearGradient'
|
||||||
|
? () => ({ addColorStop() {} })
|
||||||
|
: () => {},
|
||||||
|
set: () => true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let canvases = 0;
|
||||||
|
globalThis.document = {
|
||||||
|
createElement: () => {
|
||||||
|
canvases++;
|
||||||
|
return { getContext: () => ctx };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.Path2D = class {};
|
||||||
|
globalThis.Image = class {
|
||||||
|
constructor() {
|
||||||
|
images.push(this);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
for (const [name, value] of Object.entries(saved))
|
||||||
|
if (value === undefined) delete globalThis[name];
|
||||||
|
else globalThis[name] = value;
|
||||||
|
});
|
||||||
|
return { images, canvases: () => canvases };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a card keeps one canvas per event while its age ticks', (t) => {
|
||||||
|
const browser = fakeBrowser(t);
|
||||||
|
const first = eventCard('ev-a', view(), look);
|
||||||
|
assert.ok(first);
|
||||||
|
// The meta line's minute-level age changes: the same canvas, repainted.
|
||||||
|
const later = eventCard('ev-a', view({ meta: '11 min · 6 sources' }), look);
|
||||||
|
assert.equal(later, first);
|
||||||
|
assert.equal(browser.canvases(), 1);
|
||||||
|
// One picture request for the event, however often it is drawn.
|
||||||
|
assert.equal(browser.images.length, 1);
|
||||||
|
// Its picture arriving repaints and asks for a frame once; a card with no
|
||||||
|
// picture never does.
|
||||||
|
let frames = 0;
|
||||||
|
const pictured = eventCard(
|
||||||
|
'ev-b',
|
||||||
|
view({ image: 'https://b.test/p.jpg' }),
|
||||||
|
look,
|
||||||
|
() => frames++,
|
||||||
|
);
|
||||||
|
assert.ok(pictured);
|
||||||
|
browser.images.at(-1).onload();
|
||||||
|
assert.equal(frames, 1);
|
||||||
|
assert.ok(eventCard('ev-c', view({ image: null }), look));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the card cache is bounded and evicts pictures with their cards', (t) => {
|
||||||
|
const browser = fakeBrowser(t);
|
||||||
|
const kept = eventCard('keep', view({ image: 'https://k.test/0.jpg' }), look);
|
||||||
|
for (let i = 0; i < MAX_CARDS * 3; i++) {
|
||||||
|
eventCard(`ev-${i}`, view({ image: `https://p.test/${i}.jpg` }), look);
|
||||||
|
// A card drawn every frame stays: the least recently drawn goes first.
|
||||||
|
assert.equal(
|
||||||
|
eventCard('keep', view({ image: 'https://k.test/0.jpg' }), look),
|
||||||
|
kept,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const requested = browser.images.length;
|
||||||
|
// The first event was evicted with its picture: drawing it again paints a
|
||||||
|
// new canvas and asks for the picture anew.
|
||||||
|
const again = eventCard(
|
||||||
|
'ev-0',
|
||||||
|
view({ image: 'https://p.test/0.jpg' }),
|
||||||
|
look,
|
||||||
|
);
|
||||||
|
assert.ok(again);
|
||||||
|
assert.equal(browser.images.length, requested + 1);
|
||||||
|
// Evicted pictures are let go: their load handlers are cleared.
|
||||||
|
assert.equal(browser.images[1].onload, null);
|
||||||
|
// The recent ones are still cached.
|
||||||
|
const recent = MAX_CARDS * 3 - 1;
|
||||||
|
eventCard(
|
||||||
|
`ev-${recent}`,
|
||||||
|
view({ image: `https://p.test/${recent}.jpg` }),
|
||||||
|
look,
|
||||||
|
);
|
||||||
|
assert.equal(browser.images.length, requested + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without a canvas no card is made and nothing is cached', (t) => {
|
||||||
|
fakeBrowser(t);
|
||||||
|
globalThis.document = { createElement: () => ({ getContext: () => null }) };
|
||||||
|
assert.equal(eventCard('none', view(), look), null);
|
||||||
|
globalThis.document = undefined;
|
||||||
|
assert.equal(eventCard('none', view(), look), null);
|
||||||
|
});
|
||||||
+31
-13
@@ -80,11 +80,13 @@ const emptyFeed = () => ({
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
// `minor` holds the day's reports with minor news included, read once while
|
||||||
|
// that filter is on; otherwise In view uses the map's own reports.
|
||||||
const emptyInView = () => ({
|
const emptyInView = () => ({
|
||||||
active: false,
|
active: false,
|
||||||
cell: null,
|
cell: null,
|
||||||
centre: null,
|
centre: null,
|
||||||
reports: [],
|
minor: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
@@ -152,7 +154,7 @@ export function createNewsLayer({
|
|||||||
const findReport = (id) =>
|
const findReport = (id) =>
|
||||||
mapReports.find((r) => r.id === id) ||
|
mapReports.find((r) => r.id === id) ||
|
||||||
feed.items.find((r) => r.id === id) ||
|
feed.items.find((r) => r.id === id) ||
|
||||||
inView.reports.find((r) => r.id === id) ||
|
inView.minor?.find((r) => r.id === id) ||
|
||||||
null;
|
null;
|
||||||
const selected = () => (selectedId ? findReport(selectedId) : null);
|
const selected = () => (selectedId ? findReport(selectedId) : null);
|
||||||
const onMap = (report) => visibleOnMap(report, filters, now());
|
const onMap = (report) => visibleOnMap(report, filters, now());
|
||||||
@@ -527,7 +529,7 @@ export function createNewsLayer({
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · '),
|
.join(' · '),
|
||||||
sidesLine(report),
|
sidesLine(report, now()),
|
||||||
attestation(report)?.label || '',
|
attestation(report)?.label || '',
|
||||||
mediaLabels(report).join(' · '),
|
mediaLabels(report).join(' · '),
|
||||||
sourceLinkLabel(report.url),
|
sourceLinkLabel(report.url),
|
||||||
@@ -824,7 +826,7 @@ export function createNewsLayer({
|
|||||||
if (controller.signal.aborted || feedRequest !== controller || !enabled)
|
if (controller.signal.aborted || feedRequest !== controller || !enabled)
|
||||||
return;
|
return;
|
||||||
feed = {
|
feed = {
|
||||||
...mergeFeedPage(feed, page, mode),
|
...mergeFeedPage(feed, page, mode, now()),
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
@@ -852,9 +854,10 @@ export function createNewsLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// "In view": below 3,000 km, the reports within 500 km of the view centre,
|
// "In view": below 3,000 km, the reports within 500 km of the view centre,
|
||||||
// read again once the camera has settled for 0.8 s in another half-degree
|
// placed again once the camera has settled for 0.8 s in another half-degree
|
||||||
// cell. The feed has no area query, so the last day's geocoded reports are
|
// cell. The feed has no area query: the layer already holds the last day's
|
||||||
// read and kept by distance here.
|
// geocoded reports (the map's), kept here by distance. Only "Include minor
|
||||||
|
// news" needs its own read, once per refresh, never per pan.
|
||||||
function scheduleInView() {
|
function scheduleInView() {
|
||||||
clearTimeout(settleTimer);
|
clearTimeout(settleTimer);
|
||||||
settleTimer = setTimeout(() => {
|
settleTimer = setTimeout(() => {
|
||||||
@@ -878,10 +881,22 @@ export function createNewsLayer({
|
|||||||
if (!centre) return;
|
if (!centre) return;
|
||||||
const cell = viewCell(centre);
|
const cell = viewCell(centre);
|
||||||
if (!force && inView.active && cell === inView.cell) return;
|
if (!force && inView.active && cell === inView.cell) return;
|
||||||
|
inView = { ...inView, active: true, cell, centre };
|
||||||
|
if (!filters.includeMinor) {
|
||||||
|
inViewRequest?.abort();
|
||||||
|
inViewRequest = null;
|
||||||
|
inView = { ...inView, minor: null, loading: false, error: null };
|
||||||
|
notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!force && (inView.minor || inViewRequest)) {
|
||||||
|
notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
inViewRequest?.abort();
|
inViewRequest?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
inViewRequest = controller;
|
inViewRequest = controller;
|
||||||
inView = { ...inView, active: true, cell, centre, loading: true };
|
inView = { ...inView, loading: true };
|
||||||
notify();
|
notify();
|
||||||
try {
|
try {
|
||||||
const page = await source.getFeed(
|
const page = await source.getFeed(
|
||||||
@@ -890,7 +905,7 @@ export function createNewsLayer({
|
|||||||
geo: true,
|
geo: true,
|
||||||
today: true,
|
today: true,
|
||||||
events: true,
|
events: true,
|
||||||
relevance: filters.includeMinor ? '' : NEWS_RELEVANT,
|
relevance: '',
|
||||||
limit: NEWS_MAP_LIMIT,
|
limit: NEWS_MAP_LIMIT,
|
||||||
},
|
},
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
@@ -898,7 +913,7 @@ export function createNewsLayer({
|
|||||||
if (controller.signal.aborted || inViewRequest !== controller) return;
|
if (controller.signal.aborted || inViewRequest !== controller) return;
|
||||||
inView = {
|
inView = {
|
||||||
...inView,
|
...inView,
|
||||||
reports: page.reports,
|
minor: page.reports.filter((r) => withinNewsWindow(r, now())),
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
@@ -1004,9 +1019,12 @@ export function createNewsLayer({
|
|||||||
active: inView.active,
|
active: inView.active,
|
||||||
loading: inView.loading,
|
loading: inView.loading,
|
||||||
error: inView.error ? t(FEED_UNAVAILABLE) : null,
|
error: inView.error ? t(FEED_UNAVAILABLE) : null,
|
||||||
items: reportsInView(inView.reports, inView.centre, filters, at).map(
|
items: reportsInView(
|
||||||
(r) => feedCardView(r, at),
|
filters.includeMinor ? inView.minor || [] : mapReports,
|
||||||
),
|
inView.centre,
|
||||||
|
filters,
|
||||||
|
at,
|
||||||
|
).map((r) => feedCardView(r, at)),
|
||||||
},
|
},
|
||||||
selected:
|
selected:
|
||||||
report && withinNewsWindow(report, at)
|
report && withinNewsWindow(report, at)
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ const report = (id, extra = {}) => ({
|
|||||||
|
|
||||||
function fixture(
|
function fixture(
|
||||||
t,
|
t,
|
||||||
{ source: overrides = {}, height = 20_000_000, reduced = false } = {},
|
{
|
||||||
|
source: overrides = {},
|
||||||
|
height = 20_000_000,
|
||||||
|
reduced = false,
|
||||||
|
centre = () => ({ lat: 49, lon: 36 }),
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
const calls = { feed: [], hotspots: 0, outline: 0 };
|
const calls = { feed: [], hotspots: 0, outline: 0 };
|
||||||
const reports = [
|
const reports = [
|
||||||
@@ -98,7 +103,7 @@ function fixture(
|
|||||||
overlayHost,
|
overlayHost,
|
||||||
now: () => NOW,
|
now: () => NOW,
|
||||||
matchMedia: () => ({ matches: reduced }),
|
matchMedia: () => ({ matches: reduced }),
|
||||||
getViewCentre: () => ({ lat: 49, lon: 36 }),
|
getViewCentre: () => centre(),
|
||||||
});
|
});
|
||||||
// The context store announces selections on `window`, which Node lacks.
|
// The context store announces selections on `window`, which Node lacks.
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
@@ -178,31 +183,40 @@ test('the card toggle and the severity floor change what is shown, not what is r
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('In view reads again only when the camera settles low in a new cell', async (t) => {
|
test('In view places the held reports when the camera settles low, with no new read', async (t) => {
|
||||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
const { layer, camera, calls } = fixture(t, { height: 1_000_000 });
|
let centre = { lat: 49, lon: 36 };
|
||||||
|
const { layer, camera, calls } = fixture(t, {
|
||||||
|
height: 1_000_000,
|
||||||
|
centre: () => centre,
|
||||||
|
});
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
const reads = () => calls.feed.filter((q) => q.limit === 500).length;
|
const reads = calls.feed.length;
|
||||||
const before = reads();
|
|
||||||
camera.moveEnd.raiseEvent();
|
|
||||||
t.mock.timers.tick(799);
|
|
||||||
assert.equal(reads(), before);
|
|
||||||
t.mock.timers.tick(1);
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
const view = layer.getRowControls().feed.inView;
|
const view = layer.getRowControls().feed.inView;
|
||||||
assert.equal(view.active, true);
|
assert.equal(view.active, true);
|
||||||
|
assert.equal(view.loading, false);
|
||||||
// Gaza is beyond 500 km of the view centre; the old report is past 24 h.
|
// Gaza is beyond 500 km of the view centre; the old report is past 24 h.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
view.items.map((item) => item.id),
|
view.items.map((item) => item.id),
|
||||||
['major'],
|
['major'],
|
||||||
);
|
);
|
||||||
// Same cell: no new read.
|
// Panning to another cell (over Gaza) reads nothing: it reuses the map's
|
||||||
const settled = reads();
|
// reports, once the camera has settled.
|
||||||
|
centre = { lat: 31.5, lon: 34.4 };
|
||||||
camera.moveEnd.raiseEvent();
|
camera.moveEnd.raiseEvent();
|
||||||
t.mock.timers.tick(800);
|
t.mock.timers.tick(799);
|
||||||
assert.equal(reads(), settled);
|
assert.deepEqual(
|
||||||
|
layer.getRowControls().feed.inView.items.map((item) => item.id),
|
||||||
|
['major'],
|
||||||
|
);
|
||||||
|
t.mock.timers.tick(1);
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.deepEqual(
|
||||||
|
layer.getRowControls().feed.inView.items.map((item) => item.id),
|
||||||
|
['far'],
|
||||||
|
);
|
||||||
|
assert.equal(calls.feed.length, reads);
|
||||||
// Up above 3,000 km, In view goes away.
|
// Up above 3,000 km, In view goes away.
|
||||||
camera.positionCartographic.height = 5_000_000;
|
camera.positionCartographic.height = 5_000_000;
|
||||||
camera.moveEnd.raiseEvent();
|
camera.moveEnd.raiseEvent();
|
||||||
@@ -210,6 +224,57 @@ test('In view reads again only when the camera settles low in a new cell', async
|
|||||||
assert.equal(layer.getRowControls().feed.inView.active, false);
|
assert.equal(layer.getRowControls().feed.inView.active, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('In view with minor news reads once, not per pan; a failed read shows', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
let centre = { lat: 49, lon: 36 };
|
||||||
|
let fail = false;
|
||||||
|
const minorReads = [];
|
||||||
|
const { layer, camera } = fixture(t, {
|
||||||
|
height: 1_000_000,
|
||||||
|
centre: () => centre,
|
||||||
|
source: {
|
||||||
|
getFeed: async (query) => {
|
||||||
|
if (query.relevance === '' && query.limit === 500) {
|
||||||
|
minorReads.push(query);
|
||||||
|
if (fail) throw new Error('boom');
|
||||||
|
return {
|
||||||
|
reports: [report('broad', { severity: 2, relevance: 'minor' })],
|
||||||
|
nextBefore: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { reports: [], nextBefore: null };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
layer.enable();
|
||||||
|
await layer.update();
|
||||||
|
camera.moveEnd.raiseEvent();
|
||||||
|
t.mock.timers.tick(800);
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(minorReads.length, 0);
|
||||||
|
layer.setParams({ filters: { includeMinor: true } });
|
||||||
|
for (let i = 0; i < 4; i++) await Promise.resolve();
|
||||||
|
assert.equal(minorReads.length, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
layer.getRowControls().feed.inView.items.map((item) => item.id),
|
||||||
|
['broad'],
|
||||||
|
);
|
||||||
|
centre = { lat: 47, lon: 33 };
|
||||||
|
camera.moveEnd.raiseEvent();
|
||||||
|
t.mock.timers.tick(800);
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(minorReads.length, 1);
|
||||||
|
// Turned off and on again with the server failing: the error is shown.
|
||||||
|
layer.setParams({ filters: { includeMinor: false } });
|
||||||
|
fail = true;
|
||||||
|
layer.setParams({ filters: { includeMinor: true } });
|
||||||
|
for (let i = 0; i < 4; i++) await Promise.resolve();
|
||||||
|
assert.equal(minorReads.length, 2);
|
||||||
|
const view = layer.getRowControls().feed.inView;
|
||||||
|
assert.equal(view.error, 'News feed unavailable');
|
||||||
|
assert.deepEqual(view.items, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('a server without news is unavailable once and is not asked again', async (t) => {
|
test('a server without news is unavailable once and is not asked again', async (t) => {
|
||||||
const failure = Object.assign(
|
const failure = Object.assign(
|
||||||
new Error('News feed not configured on this server'),
|
new Error('News feed not configured on this server'),
|
||||||
|
|||||||
+54
-19
@@ -11,6 +11,11 @@ export const NEWS_FEED_PAGE = 60;
|
|||||||
export const NEWS_MAP_LIMIT = 500;
|
export const NEWS_MAP_LIMIT = 500;
|
||||||
/** News is real time only: nothing older than a day is shown anywhere. */
|
/** News is real time only: nothing older than a day is shown anywhere. */
|
||||||
export const NEWS_WINDOW_MS = 24 * 3_600_000;
|
export const NEWS_WINDOW_MS = 24 * 3_600_000;
|
||||||
|
// A publish time this far ahead of the clock is skew, not a future story
|
||||||
|
// (as data/regionalNewsWindow.js).
|
||||||
|
const NEWS_CLOCK_SKEW_MS = 15 * 60_000;
|
||||||
|
/** At most this many reports are kept in the feed list, newest first. */
|
||||||
|
export const NEWS_FEED_MAX = 600;
|
||||||
// Above this camera height the layer shows one beacon per hotspot; below it
|
// Above this camera height the layer shows one beacon per hotspot; below it
|
||||||
// (or once a hotspot is chosen) the outlines and individual reports.
|
// (or once a hotspot is chosen) the outlines and individual reports.
|
||||||
export const NEWS_DETAIL_MAX_HEIGHT_M = 2_500_000;
|
export const NEWS_DETAIL_MAX_HEIGHT_M = 2_500_000;
|
||||||
@@ -202,12 +207,28 @@ export const severityLabel = (report) => t(severityStyle(report).label);
|
|||||||
const lastHeard = (report) =>
|
const lastHeard = (report) =>
|
||||||
Date.parse(report?.event?.lastAt || report?.publishedAt || '');
|
Date.parse(report?.event?.lastAt || report?.publishedAt || '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a time is inside the last 24 hours (the only news shown); one more
|
||||||
|
* than 15 minutes ahead of the clock is not.
|
||||||
|
*/
|
||||||
|
function inWindow(value, now) {
|
||||||
|
const at = Date.parse(value || '');
|
||||||
|
return (
|
||||||
|
Number.isFinite(at) &&
|
||||||
|
now - at <= NEWS_WINDOW_MS &&
|
||||||
|
at - now <= NEWS_CLOCK_SKEW_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a report is from the last 24 hours (the only news shown). */
|
/** Whether a report is from the last 24 hours (the only news shown). */
|
||||||
export function withinNewsWindow(report, now = Date.now()) {
|
export function withinNewsWindow(report, now = Date.now()) {
|
||||||
const at = Date.parse(report?.publishedAt || '');
|
return inWindow(report?.publishedAt, now);
|
||||||
return Number.isFinite(at) && now - at <= NEWS_WINDOW_MS;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The event's one-report-per-side list, inside the last 24 hours only. */
|
||||||
|
const recentSides = (report, now) =>
|
||||||
|
(report?.event?.sides || []).filter((s) => inWindow(s.publishedAt, now));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How a report or its event is attested, as a chip: `claimed` (someone's
|
* How a report or its event is attested, as a chip: `claimed` (someone's
|
||||||
* claim, named where known), `confirmed` (both sides, or an independent
|
* claim, named where known), `confirmed` (both sides, or an independent
|
||||||
@@ -380,15 +401,18 @@ export function cardEvents(reports, now = Date.now(), limit = NEWS_CARD_LIMIT) {
|
|||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The distinct sides an event was reported by, lead first, translated. */
|
/** The distinct sides an event was reported by in the last day, lead first. */
|
||||||
function eventSides(report) {
|
function eventSides(report, now) {
|
||||||
const sides = (report?.event?.sides || []).map((s) => s.side);
|
const sides = recentSides(report, now).map((s) => s.side);
|
||||||
return [...new Set(sides.length ? sides : [report?.side].filter(Boolean))];
|
return [...new Set(sides.length ? sides : [report?.side].filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Reported by UA sources · RU sources", or '' when no side is known. */
|
/**
|
||||||
export function sidesLine(report) {
|
* "Reported by UA sources · RU sources", or '' when no side is known; an
|
||||||
const sides = eventSides(report).filter((side) => side !== 'unknown');
|
* unattributed side is left out.
|
||||||
|
*/
|
||||||
|
export function sidesLine(report, now = Date.now()) {
|
||||||
|
const sides = eventSides(report, now).filter((side) => side !== 'unknown');
|
||||||
if (!sides.length) return '';
|
if (!sides.length) return '';
|
||||||
if (sides.length === 1)
|
if (sides.length === 1)
|
||||||
return t(REPORTED_BY_SIDE, { side: sideLabel(sides[0]) });
|
return t(REPORTED_BY_SIDE, { side: sideLabel(sides[0]) });
|
||||||
@@ -409,7 +433,7 @@ export function eventPicture(report) {
|
|||||||
/** What an event's picture card on the globe shows. */
|
/** What an event's picture card on the globe shows. */
|
||||||
export function cardView(report, now = Date.now()) {
|
export function cardView(report, now = Date.now()) {
|
||||||
const event = report.event;
|
const event = report.event;
|
||||||
const sides = eventSides(report).map(sideLabel);
|
const sides = eventSides(report, now).map(sideLabel);
|
||||||
const picture = eventPicture(report);
|
const picture = eventPicture(report);
|
||||||
return {
|
return {
|
||||||
title: report.titleEn || report.title,
|
title: report.titleEn || report.title,
|
||||||
@@ -477,7 +501,7 @@ export function feedCardView(report, now = Date.now()) {
|
|||||||
sources: tn(size, '{count} source', '{count} sources'),
|
sources: tn(size, '{count} source', '{count} sources'),
|
||||||
age: ageWords(report.event?.lastAt || report.publishedAt, now),
|
age: ageWords(report.event?.lastAt || report.publishedAt, now),
|
||||||
publisher: report.publisher,
|
publisher: report.publisher,
|
||||||
sides: sidesLine(report),
|
sides: sidesLine(report, now),
|
||||||
sourceLink: sourceLinkLabel(report.url),
|
sourceLink: sourceLinkLabel(report.url),
|
||||||
picture: eventPicture(report),
|
picture: eventPicture(report),
|
||||||
chips: [
|
chips: [
|
||||||
@@ -490,7 +514,9 @@ export function feedCardView(report, now = Date.now()) {
|
|||||||
...mediaLabels(report).map((label) => ({ kind: 'media', label })),
|
...mediaLabels(report).map((label) => ({ kind: 'media', label })),
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
// The event's first report from each other side, under the lead.
|
// The event's first report from each other side, under the lead.
|
||||||
others: (report.event?.sides || []).slice(1).map((s) => ({
|
others: recentSides(report, now)
|
||||||
|
.slice(1)
|
||||||
|
.map((s) => ({
|
||||||
side: sideLabel(s.side),
|
side: sideLabel(s.side),
|
||||||
publisher: s.publisher,
|
publisher: s.publisher,
|
||||||
title: s.title,
|
title: s.title,
|
||||||
@@ -519,11 +545,18 @@ export function reportMarkerPosition(report, pin = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge a page into the feed, newest first, one row per report id.
|
* Merge a page into the feed, newest first, one row per report id, keeping
|
||||||
* `mode` 'head' refreshes the top and keeps the older cursor; 'append' adds an
|
* only the last 24 hours and at most `max` rows (a long session does not grow
|
||||||
* older page and takes its cursor; 'replace' starts over.
|
* the list). `mode` 'head' refreshes the top and keeps the older cursor;
|
||||||
|
* 'append' adds an older page and takes its cursor; 'replace' starts over.
|
||||||
*/
|
*/
|
||||||
export function mergeFeedPage(feed, page, mode) {
|
export function mergeFeedPage(
|
||||||
|
feed,
|
||||||
|
page,
|
||||||
|
mode,
|
||||||
|
now = Date.now(),
|
||||||
|
max = NEWS_FEED_MAX,
|
||||||
|
) {
|
||||||
const byId = new Map();
|
const byId = new Map();
|
||||||
const items =
|
const items =
|
||||||
mode === 'replace'
|
mode === 'replace'
|
||||||
@@ -531,11 +564,13 @@ export function mergeFeedPage(feed, page, mode) {
|
|||||||
: mode === 'append'
|
: mode === 'append'
|
||||||
? [...feed.items, ...page.reports]
|
? [...feed.items, ...page.reports]
|
||||||
: [...page.reports, ...feed.items];
|
: [...page.reports, ...feed.items];
|
||||||
for (const item of items) if (!byId.has(item.id)) byId.set(item.id, item);
|
for (const item of items)
|
||||||
|
if (!byId.has(item.id) && withinNewsWindow(item, now))
|
||||||
|
byId.set(item.id, item);
|
||||||
return {
|
return {
|
||||||
items: [...byId.values()].sort((a, b) =>
|
items: [...byId.values()]
|
||||||
b.publishedAt.localeCompare(a.publishedAt),
|
.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
|
||||||
),
|
.slice(0, max),
|
||||||
nextBefore:
|
nextBefore:
|
||||||
mode === 'head' && feed.items.length ? feed.nextBefore : page.nextBefore,
|
mode === 'head' && feed.items.length ? feed.nextBefore : page.nextBefore,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,8 +61,20 @@ const event = (extra = {}) => ({
|
|||||||
severity: 3,
|
severity: 3,
|
||||||
lastAt: '2026-10-28T09:30:00Z',
|
lastAt: '2026-10-28T09:30:00Z',
|
||||||
sides: [
|
sides: [
|
||||||
{ side: 'UA', title: 'a', url: 'https://a.test/', publisher: 'A' },
|
{
|
||||||
{ side: 'RU', title: 'b', url: 'https://b.test/', publisher: 'B' },
|
side: 'UA',
|
||||||
|
title: 'a',
|
||||||
|
url: 'https://a.test/',
|
||||||
|
publisher: 'A',
|
||||||
|
publishedAt: '2026-10-28T09:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
side: 'RU',
|
||||||
|
title: 'b',
|
||||||
|
url: 'https://b.test/',
|
||||||
|
publisher: 'B',
|
||||||
|
publishedAt: '2026-10-28T09:20:00Z',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
image: { url: 'https://a.test/p.jpg', publisher: 'A' },
|
image: { url: 'https://a.test/p.jpg', publisher: 'A' },
|
||||||
...extra,
|
...extra,
|
||||||
@@ -189,12 +201,18 @@ test('feed pages merge newest first without duplicates', () => {
|
|||||||
{ items: [] },
|
{ items: [] },
|
||||||
{ reports: [a, b], nextBefore: 'B' },
|
{ reports: [a, b], nextBefore: 'B' },
|
||||||
'replace',
|
'replace',
|
||||||
|
NOW,
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[feed.items.map((r) => r.id), feed.nextBefore],
|
[feed.items.map((r) => r.id), feed.nextBefore],
|
||||||
[['a', 'b'], 'B'],
|
[['a', 'b'], 'B'],
|
||||||
);
|
);
|
||||||
feed = mergeFeedPage(feed, { reports: [b, c], nextBefore: null }, 'append');
|
feed = mergeFeedPage(
|
||||||
|
feed,
|
||||||
|
{ reports: [b, c], nextBefore: null },
|
||||||
|
'append',
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[feed.items.map((r) => r.id), feed.nextBefore],
|
[feed.items.map((r) => r.id), feed.nextBefore],
|
||||||
[['a', 'b', 'c'], null],
|
[['a', 'b', 'c'], null],
|
||||||
@@ -204,6 +222,7 @@ test('feed pages merge newest first without duplicates', () => {
|
|||||||
{ ...feed, nextBefore: 'C' },
|
{ ...feed, nextBefore: 'C' },
|
||||||
{ reports: [newer, a], nextBefore: 'X' },
|
{ reports: [newer, a], nextBefore: 'X' },
|
||||||
'head',
|
'head',
|
||||||
|
NOW + 3_600_000,
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[feed.items.map((r) => r.id), feed.nextBefore],
|
[feed.items.map((r) => r.id), feed.nextBefore],
|
||||||
@@ -215,6 +234,46 @@ test('feed pages merge newest first without duplicates', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the feed list keeps only the last 24 hours and at most its cap', () => {
|
||||||
|
const fresh = report('f', '2026-10-28T09:00:00Z');
|
||||||
|
const stale = report('s', '2026-10-27T09:00:00Z');
|
||||||
|
const ahead = report('x', '2026-10-28T11:00:00Z');
|
||||||
|
// A long session: yesterday's rows already held fall out on the next merge.
|
||||||
|
const feed = mergeFeedPage(
|
||||||
|
{ items: [stale], nextBefore: 'S' },
|
||||||
|
{ reports: [fresh, ahead], nextBefore: 'F' },
|
||||||
|
'head',
|
||||||
|
NOW,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
feed.items.map((r) => r.id),
|
||||||
|
['f'],
|
||||||
|
);
|
||||||
|
const many = Array.from({ length: 30 }, (_, i) =>
|
||||||
|
report(`r${i}`, new Date(NOW - (i + 1) * 60_000).toISOString()),
|
||||||
|
);
|
||||||
|
const capped = mergeFeedPage(
|
||||||
|
{ items: [] },
|
||||||
|
{ reports: many, nextBefore: null },
|
||||||
|
'replace',
|
||||||
|
NOW,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
assert.equal(capped.items.length, 10);
|
||||||
|
assert.equal(capped.items[0].id, 'r0');
|
||||||
|
assert.equal(capped.items.at(-1).id, 'r9');
|
||||||
|
// Everything outside the window: an empty list, not an error.
|
||||||
|
assert.deepEqual(
|
||||||
|
mergeFeedPage(
|
||||||
|
{ items: [] },
|
||||||
|
{ reports: [stale], nextBefore: null },
|
||||||
|
'replace',
|
||||||
|
NOW,
|
||||||
|
).items,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('filters default to significant news and map to a last-24-hours query', () => {
|
test('filters default to significant news and map to a last-24-hours query', () => {
|
||||||
const hotspots = [{ id: 'ua' }, { id: 'me' }];
|
const hotspots = [{ id: 'ua' }, { id: 'me' }];
|
||||||
const filters = normalizeFilters(
|
const filters = normalizeFilters(
|
||||||
@@ -252,6 +311,11 @@ test('only the last 24 hours pass, and the severity floor applies', () => {
|
|||||||
assert.equal(withinNewsWindow(fresh, NOW), true);
|
assert.equal(withinNewsWindow(fresh, NOW), true);
|
||||||
assert.equal(withinNewsWindow(old, NOW), false);
|
assert.equal(withinNewsWindow(old, NOW), false);
|
||||||
assert.equal(withinNewsWindow({ publishedAt: 'x' }, NOW), false);
|
assert.equal(withinNewsWindow({ publishedAt: 'x' }, NOW), false);
|
||||||
|
// Clock skew up to 15 min ahead passes; further ahead is not news yet.
|
||||||
|
const skew = report('k', '2026-10-28T10:14:00Z');
|
||||||
|
const future = report('z', '2026-10-28T10:16:00Z');
|
||||||
|
assert.equal(withinNewsWindow(skew, NOW), true);
|
||||||
|
assert.equal(withinNewsWindow(future, NOW), false);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[fresh, minor, old]
|
[fresh, minor, old]
|
||||||
.filter((r) => passesFilters(r, filters, NOW))
|
.filter((r) => passesFilters(r, filters, NOW))
|
||||||
@@ -417,6 +481,62 @@ test('feed cards read like Breaking: headline, meta parts, sides and chips', ()
|
|||||||
assert.equal(sidesLine({ side: 'unknown' }), '');
|
assert.equal(sidesLine({ side: 'unknown' }), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('event sides older than a day are left out of the card and its sides line', () => {
|
||||||
|
const r = report('w', '2026-10-28T09:00:00Z', {
|
||||||
|
event: event({
|
||||||
|
sides: [
|
||||||
|
{
|
||||||
|
side: 'UA',
|
||||||
|
title: 'a',
|
||||||
|
url: 'https://a.test/',
|
||||||
|
publisher: 'A',
|
||||||
|
publishedAt: '2026-10-28T08:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
side: 'RU',
|
||||||
|
title: 'old',
|
||||||
|
url: 'https://b.test/',
|
||||||
|
publisher: 'B',
|
||||||
|
publishedAt: '2026-10-26T08:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
side: 'unknown',
|
||||||
|
title: 'c',
|
||||||
|
url: 'https://c.test/',
|
||||||
|
publisher: 'C',
|
||||||
|
publishedAt: '2026-10-28T09:10:00Z',
|
||||||
|
},
|
||||||
|
// No date: nothing shows it is recent.
|
||||||
|
{ side: 'IL', title: 'd', url: 'https://d.test/', publisher: 'D' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const view = feedCardView(r, NOW);
|
||||||
|
assert.deepEqual(
|
||||||
|
view.others.map((o) => [o.side, o.title]),
|
||||||
|
[['Unattributed', 'c']],
|
||||||
|
);
|
||||||
|
// The unattributed side is not named in the sides line.
|
||||||
|
assert.equal(view.sides, 'Reported by UA sources');
|
||||||
|
assert.equal(cardView(r, NOW).meta, '30 min · 6 sources · UA, Unattributed');
|
||||||
|
// All sides outside the window: no others, the report's own side remains.
|
||||||
|
const stale = report('v', '2026-10-28T09:00:00Z', {
|
||||||
|
event: event({
|
||||||
|
sides: [
|
||||||
|
{
|
||||||
|
side: 'RU',
|
||||||
|
title: 'old',
|
||||||
|
url: 'https://b.test/',
|
||||||
|
publisher: 'B',
|
||||||
|
publishedAt: '2026-10-26T08:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.deepEqual(feedCardView(stale, NOW).others, []);
|
||||||
|
assert.equal(feedCardView(stale, NOW).sides, 'Reported by UA sources');
|
||||||
|
});
|
||||||
|
|
||||||
test('in view: half-degree cells and reports within 500 km of the centre', () => {
|
test('in view: half-degree cells and reports within 500 km of the centre', () => {
|
||||||
assert.equal(viewCell({ lat: 50.2, lon: 36.7 }), '100:73');
|
assert.equal(viewCell({ lat: 50.2, lon: 36.7 }), '100:73');
|
||||||
assert.equal(viewCell({ lat: 50.4, lon: 36.9 }), '100:73');
|
assert.equal(viewCell({ lat: 50.4, lon: 36.9 }), '100:73');
|
||||||
|
|||||||
@@ -111,12 +111,20 @@ test('malformed, failed and unconfigured reads reject; a server without news is
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('reads time out and follow the caller signal', async () => {
|
test('reads time out and follow the caller signal', async () => {
|
||||||
|
// AbortSignal.timeout does not hold the event loop open: the hanging fetch
|
||||||
|
// does, until its signal aborts, so the run cannot end before it.
|
||||||
const hang = (_url, { signal }) =>
|
const hang = (_url, { signal }) =>
|
||||||
new Promise((_, reject) =>
|
new Promise((_, reject) => {
|
||||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
const alive = setTimeout(() => {}, 60_000);
|
||||||
once: true,
|
signal.addEventListener(
|
||||||
}),
|
'abort',
|
||||||
|
() => {
|
||||||
|
clearTimeout(alive);
|
||||||
|
reject(signal.reason);
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
);
|
);
|
||||||
|
});
|
||||||
const slow = createNewsSource({ fetchImpl: hang, timeoutMs: 20 });
|
const slow = createNewsSource({ fetchImpl: hang, timeoutMs: 20 });
|
||||||
await assert.rejects(slow.getHotspots(), { name: 'TimeoutError' });
|
await assert.rejects(slow.getHotspots(), { name: 'TimeoutError' });
|
||||||
const caller = new AbortController();
|
const caller = new AbortController();
|
||||||
|
|||||||
+9
-1
@@ -13,7 +13,8 @@ const set = (node, key, value) => {
|
|||||||
* The News rail panel (`#news-panel`): it renders the `feed` view the news
|
* The News rail panel (`#news-panel`): it renders the `feed` view the news
|
||||||
* layer publishes through its row controls, shows itself only while the
|
* layer publishes through its row controls, shows itself only while the
|
||||||
* layer is on, and acts only through setLayerParams. Near the bottom of its
|
* layer is on, and acts only through setLayerParams. Near the bottom of its
|
||||||
* scroller it asks for the next older page, once per page.
|
* scroller it asks for the next older page, once per page; other filters or a
|
||||||
|
* failed read let it ask again.
|
||||||
*/
|
*/
|
||||||
export function createNewsPanel({ container, setLayerParams = () => {} } = {}) {
|
export function createNewsPanel({ container, setLayerParams = () => {} } = {}) {
|
||||||
const document = container?.ownerDocument;
|
const document = container?.ownerDocument;
|
||||||
@@ -25,6 +26,7 @@ export function createNewsPanel({ container, setLayerParams = () => {} } = {}) {
|
|||||||
let view = null;
|
let view = null;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let requestedAt = -1;
|
let requestedAt = -1;
|
||||||
|
let shownFilters = '';
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
@@ -59,6 +61,12 @@ export function createNewsPanel({ container, setLayerParams = () => {} } = {}) {
|
|||||||
return {
|
return {
|
||||||
/** Show the layer's feed view, or hide the panel with null. */
|
/** Show the layer's feed view, or hide the panel with null. */
|
||||||
update(next) {
|
update(next) {
|
||||||
|
const filters = JSON.stringify(next?.filters ?? null);
|
||||||
|
// A new list (other filters or hotspot), or a read that failed, may stop
|
||||||
|
// at the same length as the last request: let the next scroll ask.
|
||||||
|
if (filters !== shownFilters || (next?.error && !view?.error))
|
||||||
|
requestedAt = -1;
|
||||||
|
shownFilters = filters;
|
||||||
view = next || null;
|
view = next || null;
|
||||||
render();
|
render();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,8 +37,20 @@ const big = report('big', {
|
|||||||
status: 'both-sides',
|
status: 'both-sides',
|
||||||
lastAt: '2026-10-28T09:50:00.000Z',
|
lastAt: '2026-10-28T09:50:00.000Z',
|
||||||
sides: [
|
sides: [
|
||||||
{ side: 'UA', title: 'a', url: 'https://a.test/', publisher: 'A' },
|
{
|
||||||
{ side: 'RU', title: 'b', url: 'https://b.test/', publisher: 'B' },
|
side: 'UA',
|
||||||
|
title: 'a',
|
||||||
|
url: 'https://a.test/',
|
||||||
|
publisher: 'A',
|
||||||
|
publishedAt: '2026-10-28T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
side: 'RU',
|
||||||
|
title: 'b',
|
||||||
|
url: 'https://b.test/',
|
||||||
|
publisher: 'B',
|
||||||
|
publishedAt: '2026-10-28T09:40:00.000Z',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
image: { url: 'https://a.test/p.jpg', publisher: 'A' },
|
image: { url: 'https://a.test/p.jpg', publisher: 'A' },
|
||||||
},
|
},
|
||||||
@@ -221,3 +233,46 @@ test('filters, In view and empty or failed reads', () => {
|
|||||||
);
|
);
|
||||||
news.destroy();
|
news.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('scrolling to the end asks once per page, again after a filter change or a failed read', () => {
|
||||||
|
const { news, calls } = fixture();
|
||||||
|
const body = document.getElementById('news-panel-body');
|
||||||
|
// A scroller at its bottom.
|
||||||
|
Object.defineProperty(body, 'scrollHeight', {
|
||||||
|
value: 1000,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(body, 'clientHeight', {
|
||||||
|
value: 400,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
body.scrollTop = 600;
|
||||||
|
const loadMores = () => calls.filter(([, p]) => p.loadMore === true).length;
|
||||||
|
const scroll = () => body.dispatchEvent(new Event('scroll'));
|
||||||
|
news.update(view());
|
||||||
|
scroll();
|
||||||
|
scroll();
|
||||||
|
assert.equal(loadMores(), 1);
|
||||||
|
// The page came back with nothing new (as many items): no second request.
|
||||||
|
news.update(view());
|
||||||
|
scroll();
|
||||||
|
assert.equal(loadMores(), 1);
|
||||||
|
// Another hotspot, the same number of rows: the new list can be read on.
|
||||||
|
const hotspot = normalizeFilters({ hotspot: 'ua-ru-war' }, [
|
||||||
|
{ id: 'ua-ru-war' },
|
||||||
|
]);
|
||||||
|
news.update(view({ filters: hotspot }));
|
||||||
|
scroll();
|
||||||
|
assert.equal(loadMores(), 2);
|
||||||
|
// The read fails: the next scroll tries again.
|
||||||
|
news.update(view({ filters: hotspot, error: 'News feed unavailable' }));
|
||||||
|
scroll();
|
||||||
|
assert.equal(loadMores(), 3);
|
||||||
|
// While loading, or with no older page, nothing is asked.
|
||||||
|
news.update(view({ filters: hotspot, loading: true }));
|
||||||
|
scroll();
|
||||||
|
news.update(view({ filters: hotspot, hasMore: false }));
|
||||||
|
scroll();
|
||||||
|
assert.equal(loadMores(), 3);
|
||||||
|
news.destroy();
|
||||||
|
});
|
||||||
@@ -121,9 +121,11 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
/* One select per row: at the rail's width two side by side cut off
|
||||||
|
"Significant and above" (and longer translations). */
|
||||||
.news-readout__filters {
|
.news-readout__filters {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.news-filter {
|
.news-filter {
|
||||||
|
|||||||
Reference in new issue
Block a user