diff --git a/AGENTS.md b/AGENTS.md index e0c77960..d6f5e03e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -603,9 +603,13 @@ reception came from are different acts. The second is one rule on both surfaces, without an advertised position is no candidate, since reach cannot place it; one pubkey listed twice is one node. - **Surfaces:** the reach stars and the node-position layer in the app and on the map, and in the - app also the arrow (#660) and the sender names. The node-position layer draws registry nodes - only, so there the rule pairs (rule 1) or refuses (rule 3); a rule-2 estimate gets no marker of - its own. On the map, the ticker and the point popup keep their resolver names, a separate issue. + app also the arrow (#660). The sender names follow it on both: in the app everywhere a sender + is named, on the map in the ticker and the point popup (#663). How a name is printed is + `namerules.js` (copied whole). The map's names take their registry slice around the rows they + name, not around the view (`web/rowattribution.js`), since the ticker's lines are the latest + wherever they were heard; the target picker keeps its own rule (above). The node-position layer + draws registry nodes only, so there the rule pairs (rule 1) or refuses (rule 3); a rule-2 + estimate gets no marker of its own. A ● hub has no name on either surface since #632 (`docs/2026-09-21-node-glyphs-in-gl.md`): it is a GL feature keyed by its star's id, and the tooltip that carried `starLabel` went with the marker. Should a hub get a name surface again, a 1-byte id reads `#` plus the id, never a bare diff --git a/app/changelog.json b/app/changelog.json index f839c43e..e6a98383 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -13,6 +13,13 @@ "title": "Quieter send sounds", "body": "The pops for Discover and trace-pings are a third as loud and shorter, so a weak reception is heard over them." }, + { + "id": "2026-09-29-map-names-relays-like-app", + "date": "2026-09-29", + "where": "map", + "title": "The map names a relay like the app", + "body": "A relay's name in the ticker and the popup is marked ~ as a guess. Members get the name of the one known node in reach." + }, { "id": "2026-09-27-hud-bottom-space", "date": "2026-09-27", diff --git a/app/src/namerules.js b/app/src/namerules.js new file mode 100644 index 00000000..0433f934 --- /dev/null +++ b/app/src/namerules.js @@ -0,0 +1,68 @@ +// How a sender's name is printed (#452, #661, #663). Pure: no resolver, no +// cache, no config. Where a name comes from differs per surface (the app asks +// the registries of the companion's SF, the map asks the server's resolve +// proxy), how it reads does not. +// Copied whole between app/src/ and web/ (parity.test.js), since neither deploy +// path can ship a file outside its own directory (#238). No imports. + +// A sender id of one byte (2 hex) is a 256-way collision space, so the id +// itself is never a name, and meshpacket.js carries it as its OWN sender_label +// for the two kinds below. A surface that prints that label unguarded shows +// "77" exactly as it would show a resolved short name. Marked with # instead, +// the house style hudsender.js set, and kept out of the resolver by the 4-hex +// floor. A name for it comes only from its attribution by reach (#661). +const HASH_ID_KINDS = ['direct_hash', 'path_hash'] +export function isHashIdKind(kind) { return HASH_ID_KINDS.includes(kind) } + +// A name resolved for a short prefix is a guess about who was heard: a 2- or +// 3-byte id is one in 65,536 or 16 million per registry, and a relay hash is +// the forwarder's, not a node id. The name stays (it is usually right, and +// the field reads by it) and wears GUESS_MARK on every surface, so nothing +// presents it as a resolved identity (#452). An advert's own name on its +// full key, a channel sender's name and an 8-byte discover prefix are not +// guesses; a 1-byte hash carries no resolved name (isHashIdKind), only the +// name of the node it is placed on by reach (displayName). +export const GUESS_MARK = '~' +const GUESS_MAX_HEX = 6 +export function isGuessedName(rec) { + if (!rec || !rec.sender_label) return false + if (rec.sender_kind === 'channel_name' || isHashIdKind(rec.sender_kind)) return false + const id = typeof rec.sender_id === 'string' ? rec.sender_id : '' + return /^[0-9a-f]+$/i.test(id) && id.length <= GUESS_MAX_HEX +} + +// nameParts: the name as a surface should print it, split into the guess mark +// and the name itself ({ mark: '', name: '' } when there is none), so the HUD +// can mute the mark and not the name (#618). displayName joins the two. +// +// A relay, path or direct hash of 1 to 3 bytes is named by its attribution by +// reach first (#661, attribution.js), which the surface puts on the row as +// _attr: +// node the one registry node in reach: that node's name, marked, since +// the node is still a guess about who relayed; none if it has none +// collision two or more in reach: no name, whatever the resolver said +// estimate none in reach: the resolver's name as before, unless the +// registry holds a positioned node with that prefix out of reach +// (prefixKnown), which is evidence the name is that node's +// A 1-byte hash (isHashIdKind) is named only by a placement: meshpacket.js +// carries the hash as its own label, which is no name. Any other row without +// _attr (a kind the rule does not cover, or not worked out yet) reads by its +// label, as before. +const NO_NAME = Object.freeze({ mark: '', name: '' }) +export function nameParts(rec) { + if (!rec) return NO_NAME + const attr = rec._attr + if (attr && attr.rule === 'node') return attr.node && attr.node.name ? { mark: GUESS_MARK, name: String(attr.node.name) } : NO_NAME + if (attr && attr.rule === 'collision') return NO_NAME + if (isHashIdKind(rec.sender_kind)) return NO_NAME + if (attr && attr.rule === 'estimate' && attr.prefixKnown) return NO_NAME + if (!rec.sender_label) return NO_NAME + return { mark: isGuessedName(rec) ? GUESS_MARK : '', name: String(rec.sender_label) } +} + +// displayName: the name as one string, marked when guessed; '' when there is +// none, so callers fall back to the id as before. +export function displayName(rec) { + const { mark, name } = nameParts(rec) + return mark + name +} diff --git a/app/src/names.js b/app/src/names.js index e5b2dfa9..4a1908bb 100644 --- a/app/src/names.js +++ b/app/src/names.js @@ -37,7 +37,7 @@ export function isFullPubkey(id) { return typeof id === 'string' && FULL_PUBKEY. // cached as ''). 1-byte hashes (2 hex) stay excluded: the resolver answers per // id without knowing where it was heard, and one byte is shared by too many // nodes for that answer to mean anything. A 1-byte hash is named only by its -// attribution by reach (#661, displayName). Mirrors the analysis website's +// attribution by reach (#661, namerules.js). Mirrors the analysis website's // gate (web/names.js) so a relayed advert heard by the hunter shows the same // repeater name the map does. const RESOLVABLE = /^[0-9a-f]{4,64}$/i; @@ -53,14 +53,10 @@ export function resolvableKey(rec) { return isResolvableId(rec.sender_id) ? rec.sender_id.toLowerCase() : null; } -// A sender id of one byte (2 hex) is a 256-way collision space, so the id -// itself is never a name, and meshpacket.js carries it as its OWN sender_label -// for the two kinds below. A surface that prints that label unguarded shows -// "77" exactly as it would show a resolved short name. Marked with # instead, -// the house style hudsender.js set, and kept out of the resolver by the 4-hex -// floor. A name for it comes only from its attribution by reach (#661). -const HASH_ID_KINDS = ['direct_hash', 'path_hash'] -export function isHashIdKind(kind) { return HASH_ID_KINDS.includes(kind) } +// How a name is printed (the guess mark, a hash id, a name by reach) is +// namerules.js, one file on both surfaces (#663). Re-exported, so the app's +// modules keep reading it from here. +export { isHashIdKind, GUESS_MARK, isGuessedName, nameParts, displayName } from './namerules.js'; // cachedName returns a previously-resolved name ('' = resolved-but-unknown) for // a key, or undefined when it has not been resolved yet. Synchronous — safe to @@ -104,57 +100,6 @@ export function consensusName(names) { return { name: '', refused: distinct.length > 1 }; } -// A name resolved for a short prefix is a guess about who was heard: a 2- or -// 3-byte id is one in 65,536 or 16 million per registry, and a relay hash is -// the forwarder's, not a node id. The name stays (it is usually right, and -// the field reads by it) and wears GUESS_MARK on every surface, so nothing -// presents it as a resolved identity (#452). An advert's own name on its -// full key, a channel sender's name and an 8-byte discover prefix are not -// guesses; a 1-byte hash carries no resolved name (isHashIdKind), only the -// name of the node it is placed on by reach (displayName). -export const GUESS_MARK = '~'; -const GUESS_MAX_HEX = 6; -export function isGuessedName(rec) { - if (!rec || !rec.sender_label) return false; - if (rec.sender_kind === 'channel_name' || isHashIdKind(rec.sender_kind)) return false; - const id = typeof rec.sender_id === 'string' ? rec.sender_id : ''; - return /^[0-9a-f]+$/i.test(id) && id.length <= GUESS_MAX_HEX; -} -// nameParts: the name as a surface should print it, split into the guess mark -// and the name itself ({ mark: '', name: '' } when there is none), so the HUD -// can mute the mark and not the name (#618). displayName joins the two. -// -// A relay, path or direct hash of 1 to 3 bytes is named by its attribution by -// reach first (#661, attribution.js), which the app puts on the row as _attr: -// node the one registry node in reach: that node's name, marked, since -// the node is still a guess about who relayed; none if it has none -// collision two or more in reach: no name, whatever the resolver said -// estimate none in reach: the resolver's name as before, unless the -// registry holds a positioned node with that prefix out of reach -// (prefixKnown), which is evidence the name is that node's -// A 1-byte hash (isHashIdKind) is named only by a placement: meshpacket.js -// carries the hash as its own label, which is no name. Any other row without -// _attr (a kind the rule does not cover, or not worked out yet) reads by its -// label, as before. -const NO_NAME = Object.freeze({ mark: '', name: '' }); -export function nameParts(rec) { - if (!rec) return NO_NAME; - const attr = rec._attr; - if (attr && attr.rule === 'node') return attr.node && attr.node.name ? { mark: GUESS_MARK, name: String(attr.node.name) } : NO_NAME; - if (attr && attr.rule === 'collision') return NO_NAME; - if (isHashIdKind(rec.sender_kind)) return NO_NAME; - if (attr && attr.rule === 'estimate' && attr.prefixKnown) return NO_NAME; - if (!rec.sender_label) return NO_NAME; - return { mark: isGuessedName(rec) ? GUESS_MARK : '', name: String(rec.sender_label) }; -} - -// displayName: the name as one string, marked when guessed; '' when there is -// none, so callers fall back to the id as before. -export function displayName(rec) { - const { mark, name } = nameParts(rec); - return mark + name; -} - // resolveName resolves a heard key (2-3 byte prefix, 8-byte prefix or full // pubkey) to a name. Every resolver of the companion's SF is asked at once // (resolversFor) and the answers go through consensusName: a name only when diff --git a/app/src/receptionlog.js b/app/src/receptionlog.js index 3468015c..76b49ebf 100644 --- a/app/src/receptionlog.js +++ b/app/src/receptionlog.js @@ -344,8 +344,8 @@ export function senderText(r) { // printed twice. A hash id is its # mark and nothing else, until it is placed // on a named node by reach (#661): then the # id stands beside that name. A // label that is the id is no name either: meshpacket.js gives a channel_name -// sender its decrypted name as both. For a row without an attribution this is -// the rule of web/receptionticker.js; the map's ticker does not attribute. +// sender its decrypted name as both. web/receptionticker.js carries the same +// rule since #663, pinned by web/parity.test.js. export function senderCell(r) { const name = senderText(r) if (isHashIdKind(r.sender_kind) && r.sender_id) { diff --git a/changelog.d/2026-09-29-02-map-names-relays-like-app.json b/changelog.d/2026-09-29-02-map-names-relays-like-app.json new file mode 100644 index 00000000..96ae1271 --- /dev/null +++ b/changelog.d/2026-09-29-02-map-names-relays-like-app.json @@ -0,0 +1,7 @@ +{ + "id": "2026-09-29-map-names-relays-like-app", + "date": "2026-09-29", + "where": "map", + "title": "The map names a relay like the app", + "body": "A relay's name in the ticker and the popup is marked ~ as a guess. Members get the name of the one known node in reach." +} diff --git a/web/changelog.json b/web/changelog.json index f839c43e..e6a98383 100644 --- a/web/changelog.json +++ b/web/changelog.json @@ -13,6 +13,13 @@ "title": "Quieter send sounds", "body": "The pops for Discover and trace-pings are a third as loud and shorter, so a weak reception is heard over them." }, + { + "id": "2026-09-29-map-names-relays-like-app", + "date": "2026-09-29", + "where": "map", + "title": "The map names a relay like the app", + "body": "A relay's name in the ticker and the popup is marked ~ as a guess. Members get the name of the one known node in reach." + }, { "id": "2026-09-27-hud-bottom-space", "date": "2026-09-27", diff --git a/web/e2e/relaynames.spec.js b/web/e2e/relaynames.spec.js new file mode 100644 index 00000000..4fa534d9 --- /dev/null +++ b/web/e2e/relaynames.spec.js @@ -0,0 +1,142 @@ +import { test, expect } from './fixtures.js' + +// #663: the ticker and the point popup name a relay the way the app does. A +// name on a 1 to 3-byte id is a guess and wears ~ (#452), and the registry +// node within reach decides the name first (#661). + +const RELAY = { + lat: 51, lon: 4, rssi: -90, snr: -8, sender_id: 'a1b2', sender_kind: 'relay', sender_label: 'repeater-3', + hunter_pubkey: 'h1', hunter_name: 'Hunter 1', channel_name: '', packet_type: 'TextMessage', hops: 2, + rx_at: new Date(Date.now() - 5000).toISOString(), +} +const NOORD = { pubkey: 'a1b2' + '0'.repeat(60), name: 'Mast Noord', lat: 51.001, lon: 4.001 } +const ZUID = { pubkey: 'a1b2' + 'f'.repeat(60), name: 'Mast Zuid', lat: 50.999, lon: 3.999 } + +async function setup(page, { role, points, nodes = [] }) { + await page.route('**/api/auth/me', (r) => (role === 'guest' + ? r.fulfill({ status: 401, json: { error: 'unauthorised' } }) + : r.fulfill({ json: { role, username: 'm' } }))) + await page.route('**/api/heatmap*', (r) => r.fulfill({ json: { features: [] } })) + await page.route('**/api/hunters*', (r) => r.fulfill({ json: { hunters: [] } })) + await page.route('**/api/points*', (r) => r.fulfill({ json: { points } })) + const asked = [] + await page.route('**/api/nodes/positions*', (r) => { + asked.push(new URL(r.request().url()).searchParams.get('bbox')) + return role === 'guest' ? r.fulfill({ status: 403, json: { error: 'forbidden' } }) : r.fulfill({ json: { nodes } }) + }) + return asked +} + +async function openPointPopup(page) { + await expect(async () => { + const box = await page.locator('#map').boundingBox() + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) + await expect(page.locator('.maplibregl-popup')).toBeVisible({ timeout: 1000 }) + }).toPass() +} + +test('a guest reads a relay name with the guess mark, and the registry is not asked', async ({ page }) => { + const asked = await setup(page, { role: 'guest', points: [RELAY] }) + await page.goto('/?mode=points') + const line = page.locator('#rx-log .rx-ln') + await expect(line).toHaveCount(1, { timeout: 10000 }) + await expect(line.locator('.rx-sn')).toContainText('~repeater-3') + await expect(line.locator('.rx-id')).toHaveText('a1b2') + expect(asked).toEqual([]) +}) + +test('a member reads the name of the one registry node in reach, with the node layer off', async ({ page }) => { + await setup(page, { role: 'member', points: [RELAY], nodes: [NOORD] }) + await page.goto('/?mode=points') + await expect(page.locator('#nodepos-toggle')).toHaveAttribute('aria-pressed', 'false') + const line = page.locator('#rx-log .rx-ln') + await expect(line).toHaveCount(1, { timeout: 10000 }) + await expect(line.locator('.rx-sn')).toContainText('~Mast Noord') + await expect(line.locator('.rx-sn')).not.toContainText('repeater-3') + await openPointPopup(page) + await expect(page.locator('.maplibregl-popup-content')).toContainText('sender ~Mast Noord') +}) + +test('two registry nodes in reach leave the id and no name', async ({ page }) => { + await setup(page, { role: 'member', points: [RELAY], nodes: [NOORD, ZUID] }) + await page.goto('/?mode=points') + const line = page.locator('#rx-log .rx-ln') + await expect(line).toHaveCount(1, { timeout: 10000 }) + await expect(line.locator('.rx-sn')).toContainText('a1b2') + await expect(line.locator('.rx-sn')).not.toContainText('repeater-3') + await expect(line.locator('.rx-sn')).not.toContainText('Mast') +}) + +test('a 1-byte id placed on a node reads by that name, with its # id beside it', async ({ page }) => { + const hash = { ...RELAY, sender_id: 'a1', sender_kind: 'path_hash', sender_label: 'a1' } + await setup(page, { role: 'member', points: [hash], nodes: [NOORD] }) + await page.goto('/?mode=points') + const line = page.locator('#rx-log .rx-ln') + await expect(line).toHaveCount(1, { timeout: 10000 }) + await expect(line.locator('.rx-sn')).toContainText('~Mast Noord') + await expect(line.locator('.rx-id')).toHaveText('#a1') + // The popup's id line reads the id as the ticker does. + await openPointPopup(page) + await expect(page.locator('.maplibregl-popup-content .pp-id')).toHaveText('#a1') +}) + +test('the registry is asked around the ticker lines, not around the view', async ({ page }) => { + // The ticker's page holds a line heard 2 degrees north of the one point the + // map frames, so the view cannot hold both and the slice has to. + const far = { ...RELAY, lat: 53, lon: 4, rx_at: new Date(Date.now() - 1000).toISOString() } + const asked = await setup(page, { role: 'member', points: [RELAY], nodes: [] }) + await page.route('**/api/points*', (r) => { + const q = new URL(r.request().url()).searchParams + const ticker = q.get('limit') === '200' && q.get('offset') === '0' && !q.get('bbox') + return r.fulfill({ json: { points: ticker ? [RELAY, far] : [RELAY] } }) + }) + await page.goto('/?mode=points') + await expect(page.locator('#rx-log .rx-ln')).toHaveCount(2, { timeout: 10000 }) + const view = await page.evaluate(() => window.__mapBounds()) + expect(view.north, 'the view holds the far line, so this measures nothing').toBeLessThan(53) + await expect.poll(() => asked.some((bbox) => { + const [south, , north] = String(bbox).split(',').map(Number) + return south < 51 && north > 53 + })).toBe(true) +}) + +// The lines drawn before the registry answered are named again in place when +// it does (receptionticker.js rebuild): same rows, so no rebuild, and the +// reader keeps where they scrolled to. A deterministic path: the first slice +// holds no node, and a minute later the registry names one. +test('a line drawn before the registry named its relay is renamed in place, where the reader scrolled', async ({ page }) => { + await page.clock.install() + const rows = Array.from({ length: 40 }, (_, i) => ({ ...RELAY, rx_at: new Date(Date.now() - 5000 - i * 1000).toISOString() })) + let nodes = [] + await setup(page, { role: 'member', points: rows }) + await page.route('**/api/nodes/positions*', (r) => r.fulfill({ json: { nodes } })) + await page.goto('/?mode=points') + const lines = page.locator('#rx-log .rx-ln') + await expect(lines).toHaveCount(40, { timeout: 10000 }) + await expect(lines.first().locator('.rx-sn')).toContainText('~repeater-3') + const list = page.locator('#rx-log .rx-list') + await list.evaluate((el) => { el.scrollTop = Math.floor(el.scrollHeight / 3) }) + const scrolled = await list.evaluate((el) => el.scrollTop) + expect(scrolled, 'the list has to scroll for this to measure anything').toBeGreaterThan(0) + nodes = [NOORD] + // Past the slice's minute, and past the next poll. + await page.clock.fastForward(66_000) + await expect(lines.first().locator('.rx-sn')).toContainText('~Mast Noord') + await expect(lines.last().locator('.rx-sn')).toContainText('~Mast Noord') + expect(await list.evaluate((el) => el.scrollTop)).toBe(scrolled) +}) + +// A tap that lands before the registry answered opens the popup by the label; +// when the answer lands, the open popup names the node it places the relay on. +test('a popup opened before the registry answered is named again when it does', async ({ page }) => { + await setup(page, { role: 'member', points: [RELAY] }) + let release + const answered = new Promise((r) => { release = r }) + await page.route('**/api/nodes/positions*', async (r) => { await answered; return r.fulfill({ json: { nodes: [NOORD] } }) }) + await page.goto('/?mode=points') + await expect(page.locator('#rx-log .rx-ln')).toHaveCount(1, { timeout: 10000 }) + await openPointPopup(page) + await expect(page.locator('.maplibregl-popup-content')).toContainText('sender ~repeater-3') + release() + await expect(page.locator('.maplibregl-popup-content')).toContainText('sender ~Mast Noord') +}) diff --git a/web/map.js b/web/map.js index 9cb6a87b..908b5e86 100644 --- a/web/map.js +++ b/web/map.js @@ -4,9 +4,10 @@ import { leafletZoom, mapZoomFromLeaflet, zoomParam, pointFeatures, hexFeatures, import { coverageStars, coverageFeatures, assignHues, isRepeaterHearing, selectionDim, starKey, starSelected } from './coverage.js' import { advertFeature, dotFeature, fc as glyphFc, NODE_GLYPH_SOURCE, NODE_DOT_SOURCE } from './nodeglyphs.js' import { registryIndex, attributeReception, REACH_CAP_KM } from './attribution.js' +import { createRowAttributor } from './rowattribution.js' import { EXAGGERATION_STEPS, DEFAULT_EXAGGERATION } from './terrain.js' import { API_BASE } from './config.js' -import { resolveName, cachedName, isFullPubkey, isResolvableId, senderName, resolvableKey } from './names.js' +import { resolveName, cachedName, isFullPubkey, isResolvableId, senderName, resolvableKey, isHashIdKind } from './names.js' import { loadSeenRole, saveSeenRole, roleRose, roleNotice } from './rolechange.js' import { locate, toLocatePoints } from './locate.js' import { groupSenderPointsForNodes, nodesInView, padBounds, circleRing, nodeRows } from './nodelayer.js' @@ -325,10 +326,19 @@ const ignoreSuffix = () => (ignored.size ? ` · ${ignored.size} ignored` : '') // the Locate and Ignore buttons are handled by the delegated document // listeners further down, so nothing here needs a reference to the popup. let currentPoints = [] +// The rows a name is printed on carry their attribution by reach (#663), so +// the popup and the ticker name a relay the way the app does. One attributor +// each: the points are the view's, the ticker's lines are the latest wherever +// they were heard, and each keeps the registry slice that covers its own rows. +// Below member the registry is refused, so nothing is asked. +const mayReadRegistry = () => roleKnown && canSeeObserverPoints(currentRole) +const pointAttribution = createRowAttributor({ fetchRegistry: (b) => fetchNodeRegistry(b), allowed: mayReadRegistry }) +const tickerAttribution = createRowAttributor({ fetchRegistry: (b) => fetchNodeRegistry(b), allowed: mayReadRegistry }) function pointPopupHtml(pt) { const role = pt.sender_role ? ` · ${esc(pt.sender_role)}` : '' const sid = pt.sender_id || '' - const idLine = sid ? `
${esc(sid)}` : '' + // A relay or direct hash reads '#' and the id, as the ticker has it. + const idLine = sid ? `
${esc(isHashIdKind(pt.sender_kind) ? '#' + sid : sid)}` : '' const locBtn = (sid && canSeeLocate(currentRole)) ? `
` : '' // Ignoring is per person and needs no role: it only ever removes rows from // the asker's own view. Same wording as the app's popup (huntmap.js). @@ -338,10 +348,14 @@ function pointPopupHtml(pt) { // Reception ticker two-way sync (#224): clicking a point scrolls the ticker // to the matching line, keyed by receptionKey since /api/points rows carry no // stable id. +// A tap before the registry answered opens by the label; the open popup is +// named again when the answer lands. +let pointsAttributed = Promise.resolve() const onPointClick = (props) => { const pt = currentPoints[props.i] if (!pt) return - wm.openPopup([pt.lon, pt.lat], pointPopupHtml(pt)) + const popup = wm.openPopup([pt.lon, pt.lat], pointPopupHtml(pt)) + if (pt._attr === undefined) pointsAttributed.then(() => { if (popup.isOpen()) popup.setHTML(pointPopupHtml(pt)) }) if (rxTicker) rxTicker.focusRecord(receptionKey(pt)) } // The pillar carries the index of the reception it stands for (#595), so a @@ -373,6 +387,10 @@ async function drawPoints() { // until the new ones are here. The array is kept for the click handler, // which gets a feature index back rather than a marker object. currentPoints = points + // Not awaited: the popup reads _attr when a point is tapped, and the draw + // does not wait for a registry. A tap that lands first reads by the label + // until this settles (onPointClick). + pointsAttributed = pointAttribution.attribute(points).catch(() => {}) // A selection dims what is not part of it (#624), on a fresh draw as well as // on a tap, or a pan would bring the whole map back to full strength. const { dimFor } = selectionDimmer() @@ -543,6 +561,8 @@ function announceRoleRise(role) { function applyRole(me) { currentRole = me.role || 'guest' roleKnown = true + // The registry is the role's to read or not (#663). + pointAttribution.reset(); tickerAttribution.reset() announceRoleRise(currentRole) const notice = document.getElementById('guest-notice') const msg = guestNotice(currentRole) @@ -785,7 +805,12 @@ async function fetchTickerPage(mode) { // truncated travels with the rows: QueryPoints fetches one past the limit to // detect it precisely, and the ticker's header needs it to say whether more // receptions exist behind this page (#638). - return { points: d.points || [], truncated: !!d.truncated } + // Attributed before the lines render, so a line never prints a name first + // and drops it a moment later. A registry that does not answer leaves the + // rows as a guest's (rowattribution.js). + const points = d.points || [] + await tickerAttribution.attribute(points).catch(() => {}) + return { points, truncated: !!d.truncated } } // Snap the map to the selected hunter(s) (#195). diff --git a/web/namerules.js b/web/namerules.js new file mode 100644 index 00000000..0433f934 --- /dev/null +++ b/web/namerules.js @@ -0,0 +1,68 @@ +// How a sender's name is printed (#452, #661, #663). Pure: no resolver, no +// cache, no config. Where a name comes from differs per surface (the app asks +// the registries of the companion's SF, the map asks the server's resolve +// proxy), how it reads does not. +// Copied whole between app/src/ and web/ (parity.test.js), since neither deploy +// path can ship a file outside its own directory (#238). No imports. + +// A sender id of one byte (2 hex) is a 256-way collision space, so the id +// itself is never a name, and meshpacket.js carries it as its OWN sender_label +// for the two kinds below. A surface that prints that label unguarded shows +// "77" exactly as it would show a resolved short name. Marked with # instead, +// the house style hudsender.js set, and kept out of the resolver by the 4-hex +// floor. A name for it comes only from its attribution by reach (#661). +const HASH_ID_KINDS = ['direct_hash', 'path_hash'] +export function isHashIdKind(kind) { return HASH_ID_KINDS.includes(kind) } + +// A name resolved for a short prefix is a guess about who was heard: a 2- or +// 3-byte id is one in 65,536 or 16 million per registry, and a relay hash is +// the forwarder's, not a node id. The name stays (it is usually right, and +// the field reads by it) and wears GUESS_MARK on every surface, so nothing +// presents it as a resolved identity (#452). An advert's own name on its +// full key, a channel sender's name and an 8-byte discover prefix are not +// guesses; a 1-byte hash carries no resolved name (isHashIdKind), only the +// name of the node it is placed on by reach (displayName). +export const GUESS_MARK = '~' +const GUESS_MAX_HEX = 6 +export function isGuessedName(rec) { + if (!rec || !rec.sender_label) return false + if (rec.sender_kind === 'channel_name' || isHashIdKind(rec.sender_kind)) return false + const id = typeof rec.sender_id === 'string' ? rec.sender_id : '' + return /^[0-9a-f]+$/i.test(id) && id.length <= GUESS_MAX_HEX +} + +// nameParts: the name as a surface should print it, split into the guess mark +// and the name itself ({ mark: '', name: '' } when there is none), so the HUD +// can mute the mark and not the name (#618). displayName joins the two. +// +// A relay, path or direct hash of 1 to 3 bytes is named by its attribution by +// reach first (#661, attribution.js), which the surface puts on the row as +// _attr: +// node the one registry node in reach: that node's name, marked, since +// the node is still a guess about who relayed; none if it has none +// collision two or more in reach: no name, whatever the resolver said +// estimate none in reach: the resolver's name as before, unless the +// registry holds a positioned node with that prefix out of reach +// (prefixKnown), which is evidence the name is that node's +// A 1-byte hash (isHashIdKind) is named only by a placement: meshpacket.js +// carries the hash as its own label, which is no name. Any other row without +// _attr (a kind the rule does not cover, or not worked out yet) reads by its +// label, as before. +const NO_NAME = Object.freeze({ mark: '', name: '' }) +export function nameParts(rec) { + if (!rec) return NO_NAME + const attr = rec._attr + if (attr && attr.rule === 'node') return attr.node && attr.node.name ? { mark: GUESS_MARK, name: String(attr.node.name) } : NO_NAME + if (attr && attr.rule === 'collision') return NO_NAME + if (isHashIdKind(rec.sender_kind)) return NO_NAME + if (attr && attr.rule === 'estimate' && attr.prefixKnown) return NO_NAME + if (!rec.sender_label) return NO_NAME + return { mark: isGuessedName(rec) ? GUESS_MARK : '', name: String(rec.sender_label) } +} + +// displayName: the name as one string, marked when guessed; '' when there is +// none, so callers fall back to the id as before. +export function displayName(rec) { + const { mark, name } = nameParts(rec) + return mark + name +} diff --git a/web/names.js b/web/names.js index 7fdbf1ae..5008d568 100644 --- a/web/names.js +++ b/web/names.js @@ -34,13 +34,10 @@ export function resolvableKey(rec) { return isResolvableId(rec.sender_id) ? rec.sender_id.toLowerCase() : null } -// A sender id of one byte (2 hex) is a 256-way collision space, so it is never -// a name, and meshpacket.js carries it as its OWN sender_label for the two -// kinds below. A surface that prints that label unguarded shows "77" exactly -// as it would show a resolved short name. Marked with # instead, the house -// style hudsender.js set, and kept out of the resolver by the 4-hex floor. -const HASH_ID_KINDS = ['direct_hash', 'path_hash'] -export function isHashIdKind(kind) { return HASH_ID_KINDS.includes(kind) } +// How a name is printed (the guess mark, a hash id, a name by reach) is +// namerules.js, one file on both surfaces (#663). +import { isHashIdKind, displayName } from './namerules.js' +export { isHashIdKind, GUESS_MARK, isGuessedName, nameParts, displayName } from './namerules.js' // cachedName: resolved name ('' = resolved-but-unknown) or undefined if not yet // looked up. Synchronous — use it while rendering. @@ -82,14 +79,20 @@ export async function resolveName(key) { return name } -// senderName picks the best label for a point: an existing server label wins -// (fill-only — advert broadcast names), then a cached resolved name for a -// resolvable id (full pubkey or discover prefix), then the raw id. +// senderName is the name a point or a ticker line prints, the app's +// senderText (receptionlog.js) with the map's one difference: a row without a +// label takes the name the resolve proxy cached for its id. After that the +// rule is namerules.js: a name on a short prefix wears the guess mark (#452), +// the attribution by reach decides a relay's name first (#661, _attr), and a +// 1-byte hash is # and its id until it is placed on a named node. Without a +// name the id stands, as before. export function senderName(pt) { - if (pt.sender_label) return pt.sender_label - if (isResolvableId(pt.sender_id)) { + if (!pt) return '—' + let rec = pt + if (!pt.sender_label && isResolvableId(pt.sender_id)) { const hit = cachedName(pt.sender_id) - if (hit) return hit + if (hit) rec = { ...pt, sender_label: hit } } - return pt.sender_id || '—' + if (isHashIdKind(pt.sender_kind) && pt.sender_id) return displayName(rec) || '#' + String(pt.sender_id) + return displayName(rec) || pt.sender_id || '—' } diff --git a/web/names.test.js b/web/names.test.js index 416afac9..e94a5819 100644 --- a/web/names.test.js +++ b/web/names.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { resolveName, cachedName, cachedPosition, _resetNameCache } from './names.js' +import { resolveName, cachedName, cachedPosition, _resetNameCache, senderName } from './names.js' beforeEach(() => { if (_resetNameCache) _resetNameCache() }) @@ -44,3 +44,33 @@ describe('cachedPosition', () => { expect(cachedPosition('abcd')).toBeNull() }) }) + +// #663: a name the resolver cache fills in reads by the same rule as a label +// the server sent. +describe('senderName (#663)', () => { + beforeEach(() => { _resetNameCache() }) + const answer = (name) => vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ name }) }))) + it('marks a cached name on a 2-byte relay id as a guess', async () => { + answer('repeater-3') + await resolveName('a1b2') + expect(senderName({ sender_kind: 'relay', sender_id: 'a1b2', sender_label: '' })).toBe('~repeater-3') + }) + it('leaves a cached name on a full key unmarked', async () => { + answer('Node Zuid') + const key = 'ab'.repeat(32) + await resolveName(key) + expect(senderName({ sender_kind: 'advert_pubkey', sender_id: key, sender_label: '' })).toBe('Node Zuid') + }) + it('drops a cached name when the attribution refuses one', async () => { + answer('repeater-3') + await resolveName('a1b2') + expect(senderName({ sender_kind: 'relay', sender_id: 'a1b2', sender_label: '', _attr: { rule: 'collision', count: 2 } })).toBe('a1b2') + }) + it('prints a 1-byte id as # and its id, never as a name', () => { + expect(senderName({ sender_kind: 'path_hash', sender_id: '77', sender_label: '77' })).toBe('#77') + }) + it('falls back to the id, then to a dash', () => { + expect(senderName({ sender_kind: 'relay', sender_id: 'a1b2f3c4d5e6', sender_label: '' })).toBe('a1b2f3c4d5e6') + expect(senderName({ sender_kind: 'relay', sender_id: '', sender_label: '' })).toBe('—') + }) +}) diff --git a/web/parity.test.js b/web/parity.test.js index b74f6bb6..08b66908 100644 --- a/web/parity.test.js +++ b/web/parity.test.js @@ -785,6 +785,15 @@ describe('receptions ticker CSS parity (#322)', () => { { sender_kind: 'channel_name', sender_id: 'Spammer', sender_label: 'Spammer' }, { sender_kind: 'path_hash', sender_id: '77', sender_label: '77' }, { sender_kind: 'direct_hash', sender_id: '4a', sender_label: 'Repeater-Zuid' }, + // #663: a name on a short prefix wears the guess mark, and the + // attribution by reach decides a relay's name first, on both surfaces. + { sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3' }, + { sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: { rule: 'node', node: { pubkey: 'a1b2' + '0'.repeat(60), name: 'Mast Noord' } } }, + { sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: { rule: 'collision', count: 2 } }, + { sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: { rule: 'estimate', prefixKnown: true } }, + { sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: { rule: 'estimate', prefixKnown: false } }, + { sender_kind: 'path_hash', sender_id: '77', sender_label: '77', _attr: { rule: 'node', node: { pubkey: '77' + '0'.repeat(62), name: 'Mast Noord' } } }, + { sender_kind: 'path_hash', sender_id: '77', sender_label: '77', _attr: { rule: 'node', node: { pubkey: '77' + '0'.repeat(62), name: '' } } }, ] for (const s of senders) { expect(webTicker.senderCell(s), JSON.stringify(s)).toEqual(appTicker.senderCell(s)) @@ -1361,7 +1370,9 @@ describe('files copied whole from the app (#595)', () => { // enough for it and because it catches the case a behaviour test cannot: a // comment on one side explaining a rule the other side no longer follows. describe('the filter modules are one file on both surfaces (#564)', () => { - for (const name of ['chiprow.js', 'barfilters.js', 'basemapswap.js']) { + // namerules.js since #663: how a sender's name is printed, the guess mark + // included, is one rule on both surfaces. + for (const name of ['chiprow.js', 'barfilters.js', 'basemapswap.js', 'namerules.js']) { it(`${name} is identical in app/src`, () => { const web = readFileSync(new URL(`./${name}`, import.meta.url), 'utf8') const app = readFileSync(new URL(`../app/src/${name}`, import.meta.url), 'utf8') diff --git a/web/receptionticker.js b/web/receptionticker.js index 6549aa1e..5ce2d27d 100644 --- a/web/receptionticker.js +++ b/web/receptionticker.js @@ -347,19 +347,22 @@ export function rxLineHeight(raw) { } export const CAP = 200 // recent-window cap, mirrors app's; reused by map.js's fetch limit -// senderCell, the app's rule for a row without an attribution -// (app/src/receptionlog.js, #451): once a name has resolved the id stands -// beside it in its own column, cut to the same six characters the target -// picker uses; a line without a name keeps the id in the name cell and the -// column empty, and a hash id is its # mark only. The map's ticker does not -// attribute by reach, so it has no placed hash id to name. +// senderCell is the app's rule (app/src/receptionlog.js, #451, #663): once a +// name has resolved the id stands beside it in its own column, cut to the same +// six characters the target picker uses; a line without a name keeps the id in +// the name cell and the column empty. A hash id is its # mark and nothing +// else, until it is placed on a named node by reach (#661): then the # id +// stands beside that name. map.js puts the attribution on the row as _attr +// (rowattribution.js). const ID_PREFIX_HEX_CHARS = 6 export function senderCell(pt) { - const id = pt.sender_id ? String(pt.sender_id) : '' - if (isHashIdKind(pt.sender_kind) && id) return { id: '', name: '#' + id } const name = senderName(pt) - const resolved = !!id && name !== id - return { id: resolved ? id.slice(0, ID_PREFIX_HEX_CHARS) : '', name } + if (isHashIdKind(pt.sender_kind) && pt.sender_id) { + const hashId = '#' + String(pt.sender_id) + return { id: name === hashId ? '' : hashId, name } + } + const resolved = !!pt.sender_id && name !== String(pt.sender_id) + return { id: resolved ? String(pt.sender_id).slice(0, ID_PREFIX_HEX_CHARS) : '', name } } // createReceptionTicker builds the log inside `rootId` and owns its own @@ -479,10 +482,20 @@ export function createReceptionTicker(rootId, { fetchFiltered, fetchAll, shouldP // Same rows, but they aged: refresh only the relative-time cells. Without // this a quiet mesh freezes every row at the age it first rendered, which // reads as "just received". + // The sender cells too (#663): a fetch returns new row objects, and what + // names them can have changed since these rows were drawn: the role + // became known, so the registry answered, or a resolver name landed. + // Written in place, so the reader stays where they scrolled to. const els = list.children for (let i = 0; i < els.length && i < view.length; i++) { const tm = els[i].children[1] if (tm) tm.textContent = relTime(view[i].rx_at, nowMs) + const cell = senderCell(view[i]) + const idEl = els[i].children[3] + const snEl = els[i].children[4] + if (idEl && idEl.textContent !== cell.id) idEl.textContent = cell.id + const text = snEl && snEl.firstChild + if (text && text.nodeType === 3 && text.nodeValue !== cell.name + ' ') text.nodeValue = cell.name + ' ' } paint(); return } diff --git a/web/receptionticker.test.js b/web/receptionticker.test.js index 4fb16483..56860e5d 100644 --- a/web/receptionticker.test.js +++ b/web/receptionticker.test.js @@ -370,3 +370,25 @@ describe('senderCell — the id stays beside the name it resolved to', () => { expect(senderCell({ sender_kind: 'path_hash', sender_id: '77', sender_label: '77' })).toEqual({ id: '', name: '#77' }) }) }) + +// #663: the map's ticker names a relay the way the app's does. +describe('senderCell, a relay named by the guess mark and by reach (#663)', () => { + const node = (name) => ({ rule: 'node', node: { pubkey: 'a1b2' + '0'.repeat(60), name } }) + it('marks a name on a 2 or 3-byte id as a guess', () => { + expect(senderCell({ sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3' })).toEqual({ id: 'a1b2', name: '~repeater-3' }) + expect(senderCell({ sender_kind: 'relay', sender_id: 'a1b2f3', sender_label: 'repeater-3' })).toEqual({ id: 'a1b2f3', name: '~repeater-3' }) + }) + it('leaves a name on a longer id unmarked', () => { + expect(senderCell({ sender_kind: 'discover', sender_id: 'a1b2f3c4d5e6f7a8', sender_label: 'repeater-3' }).name).toBe('repeater-3') + }) + it('takes the name of the one registry node in reach, marked', () => { + expect(senderCell({ sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: node('Mast Noord') })).toEqual({ id: 'a1b2', name: '~Mast Noord' }) + }) + it('names a 1-byte id by its placement, with the # id beside it', () => { + expect(senderCell({ sender_kind: 'path_hash', sender_id: '77', sender_label: '77', _attr: node('Mast Noord') })).toEqual({ id: '#77', name: '~Mast Noord' }) + }) + it('prints no name on a collision, nor when the registry knows the prefix out of reach', () => { + expect(senderCell({ sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: { rule: 'collision', count: 2 } })).toEqual({ id: '', name: 'a1b2' }) + expect(senderCell({ sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', _attr: { rule: 'estimate', prefixKnown: true } })).toEqual({ id: '', name: 'a1b2' }) + }) +}) diff --git a/web/rowattribution.js b/web/rowattribution.js new file mode 100644 index 00000000..610c7e68 --- /dev/null +++ b/web/rowattribution.js @@ -0,0 +1,81 @@ +// Attribution by reach for the rows a name is printed on (#663): the ticker's +// lines and the points behind a popup. attribution.js holds the rule; this +// fetches the registry slice it needs and puts the outcome on each row as +// _attr, which is where namerules.js reads it. +// +// The slice is the box around the rows, not around the view. The ticker shows +// the latest receptions wherever they were heard, and a row outside the view +// with no candidates fetched reads as "none in reach": it would print a name +// where the app prints none. The node-position layer keeps its own slice of +// the view, since it draws the nodes in view. +import { registryIndex, attributeReception, attributableId } from './attribution.js' + +const isCoord = (v) => typeof v === 'number' && Number.isFinite(v) + +// rowsBounds: the box around the rows that carry a position, or null. The +// caller's fetch pads it by the reach (fetchNodeRegistry, padBounds). +export function rowsBounds(rows) { + let b = null + for (const r of rows || []) { + if (!r || !isCoord(r.lat) || !isCoord(r.lon)) continue + if (!b) b = { south: r.lat, west: r.lon, north: r.lat, east: r.lon } + else { + b.south = Math.min(b.south, r.lat); b.north = Math.max(b.north, r.lat) + b.west = Math.min(b.west, r.lon); b.east = Math.max(b.east, r.lon) + } + } + return b +} + +const covers = (outer, inner) => outer.south <= inner.south && outer.north >= inner.north + && outer.west <= inner.west && outer.east >= inner.east +const union = (a, b) => ({ south: Math.min(a.south, b.south), west: Math.min(a.west, b.west), north: Math.max(a.north, b.north), east: Math.max(a.east, b.east) }) + +// A slice is reused while it covers the rows and is under a minute old: the +// ticker asks every 5 s while live, and the server's own registry cache is +// older than that anyway. +export const SLICE_TTL_MS = 60000 +// After an answer that is not a whole registry, the rows read without one +// for this long rather than asking again on every poll of an outage. +export const RETRY_MS = 15000 + +// createRowAttributor({ fetchRegistry, allowed, now }). +// fetchRegistry(bounds) answers { status, nodes, truncated } (map.js's +// fetchNodeRegistry). allowed() says whether this account may read the +// registry: below member the server refuses, so nothing is asked and every +// row reads by rule 2, the resolver's name with its guess mark. +// +// An answer that is not a whole registry counts as none. Refused or failed has +// no nodes. Truncated has some, and a missing node is a missed candidate: a +// collision would read as a placement. Neither is kept, and the next ask is +// RETRY_MS later. +// +// A fresh slice that does not cover the rows grows to the box around both: +// the ticker's "all" stand attributes two pages a poll, and a slice of the +// last box alone would be fetched twice a poll where neither covers the other. +export function createRowAttributor({ fetchRegistry, allowed = () => true, now = () => Date.now() } = {}) { + let slice = null // { bounds, at, index } + let failedAt = null + async function indexFor(rows) { + if (!allowed()) return null + const bounds = rowsBounds(rows.filter((r) => attributableId(r) != null)) + if (!bounds) return null + const fresh = slice && now() - slice.at < SLICE_TTL_MS + if (fresh && covers(slice.bounds, bounds)) return slice.index + if (failedAt != null && now() - failedAt < RETRY_MS) return null + const want = fresh ? union(slice.bounds, bounds) : bounds + const answer = await fetchRegistry(want) + if (!answer || answer.status !== 'ok' || answer.truncated) { failedAt = now(); return null } + failedAt = null + slice = { bounds: want, at: now(), index: registryIndex(answer.nodes) } + return slice.index + } + return { + async attribute(rows) { + const index = await indexFor(rows || []) + for (const r of rows || []) r._attr = attributeReception(r, { index }) + return rows + }, + reset() { slice = null; failedAt = null }, + } +} diff --git a/web/rowattribution.test.js b/web/rowattribution.test.js new file mode 100644 index 00000000..86dea430 --- /dev/null +++ b/web/rowattribution.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from 'vitest' +import { rowsBounds, createRowAttributor, RETRY_MS } from './rowattribution.js' + +const NODE = { pubkey: 'a1b2' + '0'.repeat(60), name: 'Mast Noord', lat: 51.0, lon: 4.0 } +const ROW = { sender_kind: 'relay', sender_id: 'a1b2', sender_label: 'repeater-3', lat: 51.01, lon: 4.01, rssi: -90 } +const ok = (nodes) => ({ status: 'ok', nodes, truncated: false }) + +describe('rowsBounds', () => { + it('is the box around the rows that have a position', () => { + expect(rowsBounds([{ lat: 51, lon: 4 }, { lat: 52, lon: 3 }, { lat: null, lon: null }])) + .toEqual({ south: 51, west: 3, north: 52, east: 4 }) + }) + it('is null without a positioned row', () => { + expect(rowsBounds([])).toBe(null) + expect(rowsBounds([{ lat: null, lon: 4 }])).toBe(null) + }) +}) + +describe('createRowAttributor (#663)', () => { + it('places a relay id on the one registry node in reach', async () => { + const a = createRowAttributor({ fetchRegistry: async () => ok([NODE]), allowed: () => true }) + const rows = [{ ...ROW }] + await a.attribute(rows) + expect(rows[0]._attr).toEqual({ rule: 'node', node: NODE }) + }) + it('asks for the box around the rows, not around the view', async () => { + const fetchRegistry = vi.fn(async () => ok([])) + const a = createRowAttributor({ fetchRegistry, allowed: () => true }) + await a.attribute([{ ...ROW }, { ...ROW, lat: 52.5, lon: 5.5 }]) + expect(fetchRegistry).toHaveBeenCalledWith({ south: 51.01, west: 4.01, north: 52.5, east: 5.5 }) + }) + it('asks nothing for a guest, and leaves the resolver name its guess mark', async () => { + const fetchRegistry = vi.fn(async () => ok([NODE])) + const a = createRowAttributor({ fetchRegistry, allowed: () => false }) + const rows = [{ ...ROW }] + await a.attribute(rows) + expect(fetchRegistry).not.toHaveBeenCalled() + expect(rows[0]._attr).toEqual({ rule: 'estimate', prefixKnown: false }) + }) + it('asks nothing when no row carries an id the rule covers', async () => { + const fetchRegistry = vi.fn(async () => ok([NODE])) + const a = createRowAttributor({ fetchRegistry, allowed: () => true }) + const rows = [{ sender_kind: 'advert_pubkey', sender_id: 'ab'.repeat(32), lat: 51, lon: 4, rssi: -90 }] + await a.attribute(rows) + expect(fetchRegistry).not.toHaveBeenCalled() + expect(rows[0]._attr).toBe(null) + }) + it('reuses a slice that still covers the rows, until it is a minute old', async () => { + let now = 1000 + const fetchRegistry = vi.fn(async () => ok([NODE])) + const a = createRowAttributor({ fetchRegistry, allowed: () => true, now: () => now }) + await a.attribute([{ ...ROW }, { ...ROW, lat: 51.2, lon: 4.2 }]) + await a.attribute([{ ...ROW, lat: 51.1, lon: 4.1 }]) + expect(fetchRegistry).toHaveBeenCalledTimes(1) + await a.attribute([{ ...ROW, lat: 53, lon: 4.1 }]) // outside the slice + expect(fetchRegistry).toHaveBeenCalledTimes(2) + now += 61000 + await a.attribute([{ ...ROW, lat: 53, lon: 4.1 }]) // covered, but stale + expect(fetchRegistry).toHaveBeenCalledTimes(3) + }) + it('treats a refused, failed or truncated registry as no registry', async () => { + for (const answer of [{ status: 'forbidden', nodes: [] }, { status: 'server_unreachable', nodes: [] }, { status: 'ok', nodes: [NODE], truncated: true }]) { + const a = createRowAttributor({ fetchRegistry: async () => answer, allowed: () => true }) + const rows = [{ ...ROW }] + await a.attribute(rows) + expect(rows[0]._attr, JSON.stringify(answer)).toEqual({ rule: 'estimate', prefixKnown: false }) + } + }) + // A registry that fails is asked again, but not on every 5 s poll of an + // outage: after a failure the rows read without it for RETRY_MS. + it('asks again after a failed answer, once RETRY_MS has passed', async () => { + let now = 1000 + const fetchRegistry = vi.fn().mockResolvedValueOnce({ status: 'server_unreachable', nodes: [] }).mockResolvedValue(ok([NODE])) + const a = createRowAttributor({ fetchRegistry, allowed: () => true, now: () => now }) + await a.attribute([{ ...ROW }]) + now += 5000 + await a.attribute([{ ...ROW }]) + expect(fetchRegistry).toHaveBeenCalledTimes(1) + now += RETRY_MS + const rows = [{ ...ROW }] + await a.attribute(rows) + expect(fetchRegistry).toHaveBeenCalledTimes(2) + expect(rows[0]._attr.rule).toBe('node') + }) + // The ticker's "all" stand attributes the filtered page and then the all + // page on every poll. Where neither box covers the other, a slice of the + // last box only would be fetched twice a poll, for ever. + it('keeps what it fetched for two alternating boxes, so it asks once more and then not again', async () => { + const fetchRegistry = vi.fn(async () => ok([NODE])) + const a = createRowAttributor({ fetchRegistry, allowed: () => true, now: () => 1000 }) + const here = [{ ...ROW }], there = [{ ...ROW, lat: 52.5, lon: 5.5 }] + for (let poll = 0; poll < 4; poll++) { await a.attribute(here.map((r) => ({ ...r }))); await a.attribute(there.map((r) => ({ ...r }))) } + expect(fetchRegistry).toHaveBeenCalledTimes(2) + // The second ask is the box around both. + expect(fetchRegistry.mock.calls[1][0]).toEqual({ south: 51.01, west: 4.01, north: 52.5, east: 5.5 }) + }) + it('forgets its slice on reset, which is what a change of role calls', async () => { + const fetchRegistry = vi.fn(async () => ok([NODE])) + const a = createRowAttributor({ fetchRegistry, allowed: () => true }) + await a.attribute([{ ...ROW }]) + a.reset() + await a.attribute([{ ...ROW }]) + expect(fetchRegistry).toHaveBeenCalledTimes(2) + }) +})