mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 00:03:43 +00:00
## Summary **Independent of every other open PR.** Choosing a pick saves the selection, redraws the control and notifies the page's listeners itself, exactly as a click in the control does, so it re-queries straight away whether or not #2168 (`setSelected` notifies) is merged; with both in, nothing fires twice. Region quick picks are named groups of region (IATA) codes, offered as one-tap choices inside the region filter, e.g. "Northern California" for a dozen airport codes instead of ticking each box. One deployment has been injecting these into the page through an nginx `sub_filter` and a script that wraps `window.RegionFilter`; this makes them a configured feature. ```jsonc "regionQuickPicks": [ { "name": "Northern California", "description": "Bay Area and Sacramento observers.", "regions": ["SJC", "SFO", "OAK", "SMF"] } ] ``` - **Server:** `GET /api/config/region-quick-picks` returns the groups cleaned up: names and codes trimmed, codes upper-cased and de-duplicated in their configured order, groups without a name or codes dropped, at most 20 groups of 200 codes, always a list. `/api/config/regions` is unchanged. - **Region filter:** loads the groups with the regions and offers every group with at least one observer today, inside the region control: - dropdown layout: rows of the same kind as **All** (checkbox and bold name), directly under All with a divider before the single regions; the row is ticked while that pick is the selection, ticking it selects the pick and closes the menu, unticking goes back to all; - pill layout: the picks lead the same bar, set off from the single regions by a thin divider. - A tap selects the group's codes that have an observer and re-queries straight away. - The selector then reads the group's name ("South Bay ▾") instead of "2 Regions", and the button shows as pressed. Selecting the same codes by hand does the same. - Tapping the active group again goes back to all regions. - A failed fetch, or no configured groups, leaves the filter exactly as before. - **Mobile:** the rows take the menu's 44 px row height under `(pointer: coarse)`; the pill-bar picks reuse `.region-pill` and its touch target. Inside `.filter-bar` (Packets) the existing touch rule for inputs made every region checkbox a 44 px square; the checkbox is now 20 px and the 44 px row is the tap target. - **Semantics:** like every region filter, a group means "heard by observers in these regions", not "located there"; `config.example.json` says so. No existing issue covers this. ## Screenshot Nodes page with two picks configured (example config below), after tapping **South Bay**: the pick and its codes (SJC, MRY) are selected, and the list re-queried for them. Taken against `test-fixtures/e2e-fixture.db`.  ## Config example ```json "regionQuickPicks": [ { "name": "South Bay", "description": "San Jose and Monterey observers", "regions": ["SJC", "MRY"] }, { "name": "East and North Bay", "description": "Oakland and San Francisco observers", "regions": ["OAK", "SFO"] } ] ``` Each pick needs a `name` and at least one code; `description` is optional (shown as the tooltip). Codes are trimmed, upper-cased and de-duplicated; a pick with no code that has an observer today is not shown. At most 20 picks of 200 codes. ## Browser validation The branch's server was run locally against a migrated copy of `test-fixtures/e2e-fixture.db` (observers in MRY, OAK, SFO, SJC) with three groups configured: "South Bay" (SJC, MRY, SCZ), "East and North Bay" (OAK, SFO, sts) and "Nowhere yet" (XXX). Checked in headless Chromium: | check | desktop 1280 px | phone 390 px (touch) | |---|---|---| | groups shown | South Bay, East and North Bay ("Nowhere yet" hidden) | same | | tap South Bay selects | SJC, MRY (SCZ has no observer) | same | | next `/api/packets` request | `region=SJC,MRY` | `region=SJC,MRY` | | selector label | "South Bay ▾" | "South Bay ▾" | | second tap | back to all regions | same | | button height | 24 px | 44 px | | page wider than viewport | no | no | | page errors | none | none | Checked on Packets (dropdown layout) and Nodes (pill layout). Three layout problems found this way on the phone were fixed before opening: a sideways-scrolling row clipped at the screen edge, the selector squeezed into a narrow column (the filter group is a non-wrapping flex row), and the two wrapped rows spread apart on Nodes (`align-content`). **Revision 1:** the first version put the picks in a separate row beside the selector; they moved inside the dropdown. Re-checked with a 14-region list and three picks on Packets: desktop 1280 px dark and phone 390 px light both keep the menu on screen (menu 340 px wide, page not wider than the viewport), the pick closes the menu and the trigger names the pick. **Revision 2:** picks are checkbox rows like **All** instead of chips. Re-checked on Packets at 1280 px dark and 390 px light: rows render bold under All, the divider separates the single regions, ticking a pick ticks its codes and names the trigger, no sideways scroll. Unrelated and not changed: on Nodes at 390 px the region row (existing pills included) starts flush with the screen edge. ## Tests - Red commit `81ea67d6`: the config type, a stub normaliser and endpoint returning nothing, and tests. - Go: cleaning (trim, upper-case, de-duplicate, drop incomplete groups), caps, nil config, and the endpoint's JSON. - Frontend, `tests/e2e/test-region-quick-picks.js` (moved from `tests/unit/` in `fced69a7`; it needs jsdom, so it runs in the E2E job like `test-table-sort.js`): the real `region-filter.js` in **jsdom** against faked config endpoints. Nine cases: rendering and hiding groups with no observer, selecting and re-querying, the selector label, toggling back, a hand-made selection marked active, the pill layout, no groups, a failed fetch, HTML escaping. Seven fail on the stub, all on assertions. - Green commit `0e33210e`: the implementation, styles, `config.example.json` (`regionQuickPicks` + `_comment_regionQuickPicks`), `docs/api-spec.md`, and the OpenAPI route list (`TestOpenAPICompleteness` requires it). The jsdom test also had a cross-realm comparison bug fixed in this commit (`Array.from` on arrays coming out of the jsdom window). - `make test`: 16 of 16 modules pass. Frontend suites: 195 of 196; the one failure, `test-issue-1956-release-routing.js`, needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally. - Red commit `f991060d` (revision): picks live inside the dropdown menu before the checkboxes, choosing one closes the menu, and in the pill layout they share the region bar; two fail on assertions against the old layout. Green commit `1f720e35`. Frontend 195 of 196 (same environment-only failure). - Red `134f5256` / green `da0adb50` (second revision): picks are checkbox rows between All and the single regions with a divider, no chips in the menu; ticking and unticking a pick row. The green commit also repairs `style.css`: `1f720e35` had prepended its block to the top of the file (its edit matched an earlier `.region-pill {`) and left the previous rules in place. The file now differs from `master` by 13 lines. ## Review notes - Groups are offered only when at least one of their codes appears in `/api/config/regions`, and only those codes are selected. The stored selection is pruned to known codes on load anyway, so selecting absent codes would not survive a reload. - Customizer (AGENTS.md rule 8): groups are operator configuration; a customizer editor could come later. ## SonarLint SonarQube Community rules (the SonarLint engine) were run on the files this PR touches, and only issues on lines it adds are listed. - **Fixed** in `b84ace4e`: - `normalizeQuickPickCodes` split out of the config normaliser (S3776, 17 > 15); - a comment on why a failed quick-pick fetch is ignored (S2486); - `for…of` in `activePick` (S4138); - in the tests: `node:` specifiers, `includes()`, and an explicit string comparator for sorted selections (S7772, S7765, S2871). - **Kept on purpose:** - S2486 is still reported on that `catch`. Swallowing the error is the intended behaviour: the filter works as before, just without picks. - S1523 on the `vm` test loader, as above. ## Checked against the repo rules - Rebased onto `master` (`673442cc`). Each red commit was re-run and still fails on its assertions; each green commit passes (TDD.md). - `make test`: all 16 modules pass. Frontend suites: all pass except `test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME` and fails the same way on `master` locally (#2160 addresses it). - No private data: test fixtures and examples use the codes the existing tests use (SJC/SFO/LAX, `#be`-style scopes). - No new `map[string]interface{}`, no hard-coded colours. `git add` was explicit, and there is one logical change per commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
8.1 KiB
JavaScript
174 lines
8.1 KiB
JavaScript
/**
|
|
* Region quick picks: config.json names groups of region (IATA) codes
|
|
* (regionQuickPicks) and the shared region filter offers each as a one-tap
|
|
* choice. Runs the real public/region-filter.js in jsdom against faked
|
|
* /api/config/regions and /api/config/region-quick-picks responses.
|
|
*/
|
|
'use strict';
|
|
const REPO_ROOT = require('node:path').resolve(__dirname, '..', '..');
|
|
const fs = require('node:fs');
|
|
const assert = require('node:assert');
|
|
const { JSDOM } = require('jsdom');
|
|
// Region codes compare as plain strings.
|
|
const byCode = (a, b) => a.localeCompare(b);
|
|
|
|
const SRC = fs.readFileSync(REPO_ROOT + '/public/region-filter.js', 'utf8');
|
|
let passed = 0, failed = 0;
|
|
async function test(name, fn) {
|
|
try { await fn(); passed++; console.log(' ✓ ' + name); }
|
|
catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); }
|
|
}
|
|
|
|
const SIX = { SFO: 'San Francisco', SJC: 'San Jose', LAX: 'Los Angeles', BRU: 'Brussels', ANR: 'Antwerp', MRY: 'Monterey' };
|
|
const PICKS = [
|
|
{ name: 'California', description: 'California observers', regions: ['SFO', 'SJC', 'LAX', 'OAK'] }, // OAK has no observer
|
|
{ name: 'Belgium', regions: ['BRU', 'ANR'] },
|
|
{ name: 'Nowhere', regions: ['XXX'] }, // no observer at all
|
|
];
|
|
|
|
async function setup(regions, picks, opts) {
|
|
opts = opts || {};
|
|
const dom = new JSDOM('<!doctype html><body><div id="rf"></div></body>', { url: 'http://localhost/', runScripts: 'outside-only' });
|
|
const w = dom.window;
|
|
if (opts.stored) w.localStorage.setItem('meshcore-region-filter', JSON.stringify(opts.stored));
|
|
w.fetch = async (url) => {
|
|
if (String(url).includes('region-quick-picks')) {
|
|
if (opts.picksFail) throw new Error('offline');
|
|
return { json: async () => ({ quickPicks: picks }) };
|
|
}
|
|
return { json: async () => regions };
|
|
};
|
|
w.eval(SRC);
|
|
const rf = w.RegionFilter;
|
|
const calls = [];
|
|
rf.onChange((sel) => calls.push(sel === null ? null : Array.from(sel).sort(byCode)));
|
|
const el = w.document.getElementById('rf');
|
|
await rf.init(el, opts.dropdown ? { dropdown: true } : undefined);
|
|
return { w, rf, el, calls };
|
|
}
|
|
const pickButtons = (el) => Array.from(el.querySelectorAll('.region-quick-pick'));
|
|
function pick(el, name) {
|
|
const b = pickButtons(el).find((x) => x.textContent.trim() === name);
|
|
assert.ok(b, 'expected a quick pick button named ' + JSON.stringify(name));
|
|
return b;
|
|
}
|
|
const trigger = (el) => el.querySelector('.region-dropdown-trigger');
|
|
// A pick is "on" when its checkbox is ticked (dropdown) or it is pressed (pill bar).
|
|
function isOn(b) {
|
|
const box = b.querySelector('input[type="checkbox"]');
|
|
return box ? box.checked : b.getAttribute('aria-pressed') === 'true';
|
|
}
|
|
|
|
(async () => {
|
|
console.log('Region quick picks');
|
|
|
|
await test('configured picks render, and a pick with no observer today is left out', async () => {
|
|
const { el } = await setup(SIX, PICKS);
|
|
assert.deepStrictEqual(pickButtons(el).map((b) => b.textContent.trim()), ['California', 'Belgium']);
|
|
});
|
|
|
|
await test('a pick selects exactly its codes that have an observer, and the page re-queries once', async () => {
|
|
const { el, rf, calls } = await setup(SIX, PICKS);
|
|
pick(el, 'California').click();
|
|
assert.deepStrictEqual(Array.from(rf.getSelected()).sort(byCode), ['LAX', 'SFO', 'SJC']);
|
|
assert.deepStrictEqual(calls, [['LAX', 'SFO', 'SJC']]);
|
|
});
|
|
|
|
await test('the selector names the active pick instead of counting regions', async () => {
|
|
const { el } = await setup(SIX, PICKS);
|
|
pick(el, 'California').click();
|
|
assert.ok(trigger(el).textContent.includes('California'), 'trigger: ' + trigger(el).textContent);
|
|
assert.strictEqual(isOn(pick(el, 'California')), true);
|
|
assert.strictEqual(isOn(pick(el, 'Belgium')), false);
|
|
});
|
|
|
|
await test('tapping the active pick again goes back to all regions', async () => {
|
|
const { el, rf, calls } = await setup(SIX, PICKS);
|
|
pick(el, 'California').click();
|
|
pick(el, 'California').click();
|
|
assert.strictEqual(rf.getSelected(), null);
|
|
assert.deepStrictEqual(calls, [['LAX', 'SFO', 'SJC'], null]);
|
|
});
|
|
|
|
await test('selecting the same codes by hand marks the pick as active', async () => {
|
|
const { el } = await setup(SIX, PICKS, { stored: ['BRU', 'ANR'] });
|
|
assert.strictEqual(isOn(pick(el, 'Belgium')), true);
|
|
assert.ok(trigger(el).textContent.includes('Belgium'));
|
|
});
|
|
|
|
await test('picks are offered in the pill layout too (four regions or fewer)', async () => {
|
|
const { el, rf } = await setup({ SFO: 'San Francisco', SJC: 'San Jose', BRU: 'Brussels' }, PICKS);
|
|
assert.ok(el.querySelector('.region-filter-bar'), 'pill layout expected');
|
|
pick(el, 'California').click();
|
|
assert.deepStrictEqual(Array.from(rf.getSelected()).sort(byCode), ['SFO', 'SJC']);
|
|
});
|
|
|
|
await test('without configured picks the filter renders exactly as before', async () => {
|
|
const { el } = await setup(SIX, []);
|
|
assert.strictEqual(pickButtons(el).length, 0);
|
|
assert.strictEqual(el.querySelectorAll('.region-quick-picks').length, 0);
|
|
assert.ok(trigger(el), 'dropdown still rendered');
|
|
});
|
|
|
|
await test('if the picks cannot be fetched the filter still works', async () => {
|
|
const { el } = await setup(SIX, PICKS, { picksFail: true });
|
|
assert.strictEqual(pickButtons(el).length, 0);
|
|
assert.ok(trigger(el));
|
|
});
|
|
|
|
await test('pick names and descriptions are escaped', async () => {
|
|
const { el } = await setup(SIX, [{ name: '<img src=x onerror=alert(1)>', description: '"><b>x</b>', regions: ['SFO'] }]);
|
|
assert.strictEqual(el.querySelectorAll('img').length, 0);
|
|
assert.strictEqual(el.querySelectorAll('b').length, 0);
|
|
assert.strictEqual(pickButtons(el).length, 1, 'expected the pick to render');
|
|
assert.strictEqual(pickButtons(el)[0].textContent.trim(), '<img src=x onerror=alert(1)>');
|
|
});
|
|
|
|
await test('in the dropdown the picks are checkbox rows like All, between All and the single regions', async () => {
|
|
const { el } = await setup(SIX, PICKS);
|
|
const menu = el.querySelector('.region-dropdown-menu');
|
|
const rows = Array.from(menu.querySelectorAll('.region-dropdown-item'));
|
|
const names = rows.map((r) => r.textContent.trim());
|
|
assert.deepStrictEqual(names.slice(0, 3), ['All', 'California', 'Belgium'], 'rows: ' + names.join(' / '));
|
|
rows.slice(1, 3).forEach((r) => {
|
|
assert.ok(r.querySelector('input[type="checkbox"]'), 'pick row has a checkbox: ' + r.textContent);
|
|
assert.ok(r.querySelector('strong'), 'pick name is bold like All: ' + r.textContent);
|
|
});
|
|
assert.ok(menu.querySelector('.region-dropdown-sep'), 'a divider separates picks from single regions');
|
|
assert.strictEqual(menu.querySelectorAll('.region-pill').length, 0, 'no pill chips inside the menu');
|
|
});
|
|
|
|
await test('ticking a pick row selects it, and unticking goes back to all', async () => {
|
|
const { el, rf, calls } = await setup(SIX, PICKS);
|
|
const box = () => {
|
|
const b = pick(el, 'Belgium').querySelector('input[type="checkbox"]');
|
|
assert.ok(b, 'expected the pick row to hold a checkbox');
|
|
return b;
|
|
};
|
|
box().click();
|
|
assert.deepStrictEqual(Array.from(rf.getSelected()).sort(byCode), ['ANR', 'BRU']);
|
|
assert.strictEqual(box().checked, true);
|
|
box().click();
|
|
assert.strictEqual(rf.getSelected(), null);
|
|
assert.deepStrictEqual(calls, [['ANR', 'BRU'], null]);
|
|
});
|
|
|
|
await test('choosing a pick from the menu closes it', async () => {
|
|
const { el } = await setup(SIX, PICKS);
|
|
trigger(el).click();
|
|
assert.strictEqual(el.querySelector('.region-dropdown-menu').hidden, false);
|
|
pick(el, 'California').click();
|
|
assert.strictEqual(el.querySelector('.region-dropdown-menu').hidden, true);
|
|
assert.strictEqual(trigger(el).getAttribute('aria-expanded'), 'false');
|
|
});
|
|
|
|
await test('in the pill layout the picks share the region bar', async () => {
|
|
const { el } = await setup({ SFO: 'San Francisco', SJC: 'San Jose', BRU: 'Brussels' }, PICKS);
|
|
const bar = el.querySelector('.region-filter-bar');
|
|
pickButtons(el).forEach((b) => assert.ok(bar.contains(b), 'pick outside the bar: ' + b.textContent));
|
|
});
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
if (failed) process.exit(1);
|
|
})();
|