@bongos/core 1.21.153 → 1.21.155
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +26 -26
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +34 -19
- package/docs/copy-registry.json +156 -21
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-readings.json +22 -20
- package/modules/hall-ui/public/modules.html +4 -1
- package/modules/hall-ui/public/modules.js +32 -1
- package/modules/hall-ui/records/catalog.md +2 -0
- package/modules/lifecycle/criterion-uat.js +7 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/copy-inventory.js +7 -3
- package/src/bongos/module-listing.js +41 -5
- package/src/bongos/routes/modules.js +11 -3
- package/src/module-api.js +1 -1
- package/tests/copy_inventory.mjs +21 -5
- package/tests/criterion_uat.mjs +19 -0
- package/tests/hall_catalog_world.mjs +13 -1
- package/tests/module_store_listing.mjs +78 -2
package/release-notes.json
CHANGED
|
@@ -9940,5 +9940,21 @@
|
|
|
9940
9940
|
"id": "1004766",
|
|
9941
9941
|
"text": "When a project's server can't talk to GitHub, the land-status check now says which step failed and what GitHub answered, instead of a bare error."
|
|
9942
9942
|
}
|
|
9943
|
+
],
|
|
9944
|
+
"1.21.154": [
|
|
9945
|
+
{
|
|
9946
|
+
"id": "1003800",
|
|
9947
|
+
"text": "In the module store, a buyer can now sort by the score that matters to them, like reliability or tests, so the module strongest at that comes first."
|
|
9948
|
+
}
|
|
9949
|
+
],
|
|
9950
|
+
"1.21.155": [
|
|
9951
|
+
{
|
|
9952
|
+
"id": "1004769",
|
|
9953
|
+
"text": "The person who built something can now sign off its UAT themselves, not only the project owner. The old rule is paused, not deleted, so it can come back with one change."
|
|
9954
|
+
},
|
|
9955
|
+
{
|
|
9956
|
+
"id": "1004768",
|
|
9957
|
+
"text": "A page rewrite can now include a long paragraph. Before, any paragraph over 400 characters was dropped from the site's text list, so the privacy page's changes could not be applied."
|
|
9958
|
+
}
|
|
9943
9959
|
]
|
|
9944
9960
|
}
|
|
@@ -66,7 +66,11 @@ const SURFACES = [
|
|
|
66
66
|
// Attributes whose value is COPY a person reads (not wiring). The matchers are
|
|
67
67
|
// built ONCE — the first cut re-created all seven per markup literal.
|
|
68
68
|
const COPY_ATTRS = ['title', 'aria-label', 'placeholder', 'alt', 'data-page-title', 'aria-description'];
|
|
69
|
-
|
|
69
|
+
// The longest string the registry lists. A page tweak may rewrite a paragraph
|
|
70
|
+
// past the old 400, and the applier then could not find its own words in the
|
|
71
|
+
// registry it regenerates (task 1004768, the privacy page's round).
|
|
72
|
+
const MAX_COPY_LEN = 2000;
|
|
73
|
+
const COPY_ATTR_RES = COPY_ATTRS.map((attr) => ({ attr, re: new RegExp(`\\b${attr}\\s*=\\s*"([^"]{4,${MAX_COPY_LEN}})"`, 'gi') }));
|
|
70
74
|
|
|
71
75
|
// What this tool knowingly does not see. Printed in the report — an inventory that
|
|
72
76
|
// implies completeness it does not have is worse than a short one.
|
|
@@ -125,7 +129,7 @@ function lineAt(starts, index) {
|
|
|
125
129
|
// by platform (task 1004545 — a 396-char run was 403 under CRLF and vanished).
|
|
126
130
|
function looksLikeCopy(raw) {
|
|
127
131
|
const s = String(raw).replace(/\r\n/g, '\n').trim();
|
|
128
|
-
if (s.length < 4 || s.length >
|
|
132
|
+
if (s.length < 4 || s.length > MAX_COPY_LEN) return false;
|
|
129
133
|
if (!/[A-Za-z]{2}/.test(s)) return false; // needs actual letters
|
|
130
134
|
if (!/\s/.test(s) && !/[.!?]$/.test(s)) return false; // one bare word is a token
|
|
131
135
|
if (/^[/.#]/.test(s)) return false; // paths, selectors
|
|
@@ -561,7 +565,7 @@ if (require.main === module) {
|
|
|
561
565
|
}
|
|
562
566
|
|
|
563
567
|
module.exports = {
|
|
564
|
-
SURFACES, COPY_ATTRS, BLIND_SPOTS, REGISTRY_REL, REPORT_REL,
|
|
568
|
+
SURFACES, COPY_ATTRS, BLIND_SPOTS, MAX_COPY_LEN, REGISTRY_REL, REPORT_REL,
|
|
565
569
|
cmp, stableId, looksLikeCopy, lineIndex, lineAt, stripEmbedded, fromHtml, fromJs, filesFor,
|
|
566
570
|
buildRegistry, renderReport, write, matchesGenerated,
|
|
567
571
|
};
|
|
@@ -92,6 +92,40 @@ function readParts(signals = [], { floor = QUALITY_FLOOR } = {}) {
|
|
|
92
92
|
return { parts, strengths, weaknesses, unmeasured };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Sort by the part a buyer cares about (task 1003800, owner decision 2026-10-22)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
//
|
|
99
|
+
// A module strong at what a buyer needs should come first for THAT buyer. Nothing
|
|
100
|
+
// records what a category is "for", and a hidden per-category weighting would break
|
|
101
|
+
// the score an author can check (ADR 0343 D3), so the owner chose: the buyer picks
|
|
102
|
+
// the part. The rule, in a sentence an author can check:
|
|
103
|
+
//
|
|
104
|
+
// modules with a score for the part come first, highest first; a tie goes to the
|
|
105
|
+
// higher overall score, then the key. A module with no score for the part goes
|
|
106
|
+
// last, in key order: a missing part is never a zero, and never a first place.
|
|
107
|
+
//
|
|
108
|
+
// Only `listed` is sorted. A "New" version is never ranked (owner decision,
|
|
109
|
+
// 2026-10-01), so `new` keeps key order whatever the buyer picks. Today this runs
|
|
110
|
+
// over the whole store; once approved sub-categories exist (task 1004355) the same
|
|
111
|
+
// rule orders each one (ADR 0342 D2).
|
|
112
|
+
const SORT_PARTS = PARTS.map(({ part, title }) => ({ part, title }));
|
|
113
|
+
|
|
114
|
+
// `entries` carry `parts` as readParts builds them. Returns a new array. Pure.
|
|
115
|
+
function sortByPart(entries, part) {
|
|
116
|
+
const scoreOf = (e) => (e.parts && e.parts[part] ? e.parts[part].score : null);
|
|
117
|
+
return [...entries].sort((a, b) => {
|
|
118
|
+
const sa = scoreOf(a);
|
|
119
|
+
const sb = scoreOf(b);
|
|
120
|
+
if (sa == null || sb == null) {
|
|
121
|
+
if (sa != null) return -1;
|
|
122
|
+
if (sb != null) return 1;
|
|
123
|
+
return a.key.localeCompare(b.key);
|
|
124
|
+
}
|
|
125
|
+
return (sb - sa) || ((b.overall ?? -1) - (a.overall ?? -1)) || a.key.localeCompare(b.key);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
95
129
|
// The newest version row per module key, in semver order (a version that does not
|
|
96
130
|
// parse is skipped, as latestVersions does in module-store.js).
|
|
97
131
|
function newestPerModule(rows) {
|
|
@@ -106,12 +140,13 @@ function newestPerModule(rows) {
|
|
|
106
140
|
return [...out.values()];
|
|
107
141
|
}
|
|
108
142
|
|
|
109
|
-
// The catalog: { floor, listed: [...], new: [...] }, each entry
|
|
143
|
+
// The catalog: { floor, sort, sort_parts, listed: [...], new: [...] }, each entry
|
|
110
144
|
// { key, title, version, published_at, overall, is_new, parts, strengths, weaknesses,
|
|
111
145
|
// unmeasured } (the last four from readParts). Excluded versions are left
|
|
112
146
|
// out entirely: a module below the bar is not listed at all. Both lists are in key
|
|
113
|
-
// order
|
|
114
|
-
|
|
147
|
+
// order unless `sort` names a part, which orders `listed` by sortByPart; ranking
|
|
148
|
+
// is a separate task (1003808) and does not happen here.
|
|
149
|
+
async function listCatalog({ db, floor = QUALITY_FLOOR, sort = null } = {}) {
|
|
115
150
|
const pool = db || require('./pool').pool;
|
|
116
151
|
const { rows: versions } = await pool.query(
|
|
117
152
|
`SELECT m.module_key, m.title, v.id AS version_id, v.version, v.published_at
|
|
@@ -142,7 +177,7 @@ async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
|
|
|
142
177
|
signals.get(k).push(s);
|
|
143
178
|
}
|
|
144
179
|
}
|
|
145
|
-
const out = { floor, listed: [], new: [] };
|
|
180
|
+
const out = { floor, sort: sort || null, sort_parts: SORT_PARTS, listed: [], new: [] };
|
|
146
181
|
for (const r of newest.sort((a, b) => a.module_key.localeCompare(b.module_key))) {
|
|
147
182
|
const score = scores.get(String(r.version_id)) || null;
|
|
148
183
|
const { placement } = placeVersion(score, floor);
|
|
@@ -153,7 +188,8 @@ async function listCatalog({ db, floor = QUALITY_FLOOR } = {}) {
|
|
|
153
188
|
...readParts(signals.get(String(r.version_id)) || [], { floor }),
|
|
154
189
|
});
|
|
155
190
|
}
|
|
191
|
+
if (sort) out.listed = sortByPart(out.listed, sort);
|
|
156
192
|
return out;
|
|
157
193
|
}
|
|
158
194
|
|
|
159
|
-
module.exports = { QUALITY_FLOOR, STRONG_AT, placeVersion, readParts, listCatalog };
|
|
195
|
+
module.exports = { QUALITY_FLOOR, STRONG_AT, SORT_PARTS, placeVersion, readParts, sortByPart, listCatalog };
|
|
@@ -110,7 +110,7 @@ const log = require('../logger').logger.child({ src: 'modules' });
|
|
|
110
110
|
// property access on a CommonJS namespace, so reaching these through one made them read
|
|
111
111
|
// as unused exports and tripped the dead-code ratchet that held this branch out of main.
|
|
112
112
|
const { listOverrides, setOverride } = require('../module-overrides');
|
|
113
|
-
const { listCatalog } = require('../module-listing');
|
|
113
|
+
const { listCatalog, SORT_PARTS } = require('../module-listing');
|
|
114
114
|
|
|
115
115
|
// The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
|
|
116
116
|
// module's pages, 'pollers' its background jobs. PURE.
|
|
@@ -733,12 +733,20 @@ module.exports = function buildModulesRouter() {
|
|
|
733
733
|
// listed module's newest version, if it passed the Security gate and scores at or
|
|
734
734
|
// above the floor, or under "new" if it has no real-world data yet. Below the bar
|
|
735
735
|
// is not listed at all. The floor is fixed in module-listing.js and returned here.
|
|
736
|
+
// `?sort=<part>` orders the listed modules by the part a buyer cares about
|
|
737
|
+
// (task 1003800, sortByPart); the parts it accepts come back as sort_parts.
|
|
736
738
|
//
|
|
737
739
|
// rank: any-builder — a read of public store metadata, like /store/modules/latest:
|
|
738
740
|
// no entitlement taken, nothing changed.
|
|
739
741
|
router.get('/store/modules', auth.requireBuilder,
|
|
740
|
-
asyncHandler('GET /store/modules', async (
|
|
741
|
-
const
|
|
742
|
+
asyncHandler('GET /store/modules', async (req, res) => {
|
|
743
|
+
const raw = req.query && req.query.sort;
|
|
744
|
+
const sort = typeof raw === 'string' && raw ? raw : null;
|
|
745
|
+
if (sort && !SORT_PARTS.some((p) => p.part === sort)) {
|
|
746
|
+
const names = SORT_PARTS.map((p) => `${p.title} (${p.part})`).join(', ');
|
|
747
|
+
return res.fail('bad_request', { status: 400, message: `The store can be sorted by one part of the score: ${names}.` });
|
|
748
|
+
}
|
|
749
|
+
const listing = await listCatalog({ sort });
|
|
742
750
|
res.set('Cache-Control', 'no-store');
|
|
743
751
|
res.json(listing);
|
|
744
752
|
}));
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.155'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/tests/copy_inventory.mjs
CHANGED
|
@@ -103,15 +103,18 @@ test('stripEmbedded keeps line numbers truthful', () => {
|
|
|
103
103
|
// Windows regen dropped a row CI kept and redded "the committed artifacts match".
|
|
104
104
|
// The fixture rebuilds both halves: a tag inside a comment, and a run sized to
|
|
105
105
|
// straddle the cap.
|
|
106
|
-
//
|
|
107
|
-
|
|
106
|
+
// 45-char lines under a 28-char head, as many as fit under the cap on LF; each
|
|
107
|
+
// CRLF adds one, which pushes it over. Sized from MAX_COPY_LEN (task 1004768).
|
|
108
|
+
const NEAR_CAP_HEAD = 'A real sentence on the page,';
|
|
109
|
+
const NEAR_CAP_LINE = ` ${'word '.repeat(8)}s.`;
|
|
110
|
+
const NEAR_CAP_LINES = Array.from({ length: Math.floor((inv.MAX_COPY_LEN - NEAR_CAP_HEAD.length) / (NEAR_CAP_LINE.length + 1)) }, () => NEAR_CAP_LINE);
|
|
108
111
|
const EOL_FIXTURE = [
|
|
109
112
|
'<!doctype html>',
|
|
110
113
|
'<!-- Author note: a building is a door (an <a>) to its interior.',
|
|
111
114
|
' The template is street.css, the <style> on the tokens.',
|
|
112
115
|
'-->',
|
|
113
116
|
'<style>.x { color: red; }</style>',
|
|
114
|
-
|
|
117
|
+
`<p>${NEAR_CAP_HEAD}`,
|
|
115
118
|
...NEAR_CAP_LINES.slice(0, -1),
|
|
116
119
|
`${NEAR_CAP_LINES.at(-1)}</p>`,
|
|
117
120
|
'<p title="A tooltip a person reads">Second real line.</p>',
|
|
@@ -123,13 +126,26 @@ test('LF and CRLF checkouts extract identical rows (task 1004545)', () => {
|
|
|
123
126
|
const strip = (rows) => rows.map(({ text, line, origin }) => ({ text, line, origin }));
|
|
124
127
|
assert.deepEqual(strip(crlf), strip(lf), 'the registry must not depend on the checkout\'s line endings');
|
|
125
128
|
const run = EOL_FIXTURE.slice(EOL_FIXTURE.indexOf('A real'), EOL_FIXTURE.indexOf('</p>'));
|
|
126
|
-
assert.ok(run.length <=
|
|
129
|
+
assert.ok(run.length <= inv.MAX_COPY_LEN && run.replace(/\n/g, '\r\n').length > inv.MAX_COPY_LEN, 'the fixture must straddle the cap, or this test proves nothing');
|
|
127
130
|
// The multi-line sentence sits under the cap only when CRLF counts as one
|
|
128
131
|
// character — so this is the case that reds if the length rule regresses.
|
|
129
132
|
assert.ok(lf.some((r) => r.text.startsWith('A real sentence on the page')), 'a multi-line run under the cap is copy on LF');
|
|
130
133
|
assert.ok(crlf.some((r) => r.text.startsWith('A real sentence on the page')), 'and the same run is copy on CRLF');
|
|
131
134
|
});
|
|
132
135
|
|
|
136
|
+
// The privacy page's round rewrote one paragraph past 400 characters, and the
|
|
137
|
+
// registry dropped it, so the applier could not find its own words (task 1004768).
|
|
138
|
+
test('a long paragraph a page tweak writes is still listed', () => {
|
|
139
|
+
const para = `We keep only what we need to run your account. ${'Nothing else is stored or sold, and you can ask for it to be removed. '.repeat(14)}`.trim();
|
|
140
|
+
assert.ok(para.length > 1000 && para.length <= inv.MAX_COPY_LEN, 'the fixture must sit well past the old 400 cap');
|
|
141
|
+
const rows = inv.fromHtml({ source: `<p>${para}</p>\n<p title="${para}">x y</p>`, rel: 'p.html' });
|
|
142
|
+
assert.equal(rows.filter((r) => r.text === para).length, 2, 'listed as text and as an attribute');
|
|
143
|
+
const at = (n) => `${'ab '.repeat(n)}`.slice(0, n - 1) + '.';
|
|
144
|
+
assert.equal(at(inv.MAX_COPY_LEN).length, inv.MAX_COPY_LEN);
|
|
145
|
+
assert.equal(inv.looksLikeCopy(at(inv.MAX_COPY_LEN)), true, 'exactly at the cap is listed');
|
|
146
|
+
assert.equal(inv.looksLikeCopy(at(inv.MAX_COPY_LEN + 1)), false, 'one past the cap is refused');
|
|
147
|
+
});
|
|
148
|
+
|
|
133
149
|
test('HTML comments are never copy, and a tag named inside one swallows nothing', () => {
|
|
134
150
|
const rows = inv.fromHtml({ source: EOL_FIXTURE, rel: 'p.html' });
|
|
135
151
|
const texts = rows.map((r) => r.text);
|
|
@@ -140,7 +156,7 @@ test('HTML comments are never copy, and a tag named inside one swallows nothing'
|
|
|
140
156
|
assert.ok(texts.includes('Second real line.'));
|
|
141
157
|
assert.ok(texts.includes('A tooltip a person reads'));
|
|
142
158
|
const real = rows.find((r) => r.text === 'Second real line.');
|
|
143
|
-
assert.equal(real.line,
|
|
159
|
+
assert.equal(real.line, 7 + NEAR_CAP_LINES.length, 'a stripped comment must not shift the lines after it');
|
|
144
160
|
// A span still addresses the file's own characters (copy-apply writes through it).
|
|
145
161
|
assert.equal(EOL_FIXTURE.slice(real.start, real.end), 'Second real line.');
|
|
146
162
|
});
|
package/tests/criterion_uat.mjs
CHANGED
|
@@ -29,6 +29,8 @@ import { makeRunner } from './helpers.mjs';
|
|
|
29
29
|
|
|
30
30
|
process.env.NODE_ENV = 'test';
|
|
31
31
|
const require = createRequire(import.meta.url);
|
|
32
|
+
// The shipper rule is paused in production (owner, 2026-10-22); prove it still works.
|
|
33
|
+
process.env.UAT_SELF_SIGNOFF = 'off';
|
|
32
34
|
const uat = require('../modules/lifecycle/criterion-uat.js');
|
|
33
35
|
const uatDb = require('../modules/lifecycle/criterion-uat-db.js');
|
|
34
36
|
const doneWhen = require('../modules/lifecycle/done-when.js');
|
|
@@ -115,6 +117,23 @@ await test('LADDER: whoever shipped linked work is refused — ids compare as st
|
|
|
115
117
|
assert.equal(code({ signerId: '8' }), 'signer_shipped_this_work');
|
|
116
118
|
});
|
|
117
119
|
|
|
120
|
+
await test('PAUSE (task 1004769): at the production default a shipper is let through, and only the shipper rung moves', () => {
|
|
121
|
+
const modPath = require.resolve('../modules/lifecycle/criterion-uat.js');
|
|
122
|
+
delete process.env.UAT_SELF_SIGNOFF;
|
|
123
|
+
delete require.cache[modPath];
|
|
124
|
+
try {
|
|
125
|
+
const paused = require(modPath);
|
|
126
|
+
const pcode = (over) => { const r = paused.signoffRefusal({ ...base, ...over }); return r && r.code; };
|
|
127
|
+
assert.equal(pcode({ signerId: 7 }), null);
|
|
128
|
+
assert.equal(pcode({ signerId: '8' }), null);
|
|
129
|
+
assert.equal(pcode({ signerId: 7, recording: null }), 'uat_recording_required');
|
|
130
|
+
} finally {
|
|
131
|
+
process.env.UAT_SELF_SIGNOFF = 'off';
|
|
132
|
+
delete require.cache[modPath];
|
|
133
|
+
require(modPath);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
118
137
|
await test('LADDER: the project owner may sign off work they shipped (solo projects)', () => {
|
|
119
138
|
assert.equal(code({ signerId: 3, shipperIds: ['3'] }), null);
|
|
120
139
|
});
|
|
@@ -94,7 +94,7 @@ test('modules: the store group reads strengths and weaknesses off the server row
|
|
|
94
94
|
const html = read('modules.html');
|
|
95
95
|
assert.match(html, /<div class="ov-group" id="store-group" hidden>/, 'the store group is hidden until the store answers');
|
|
96
96
|
assert.match(html, /<ul class="ov-rows" id="store-grid"><\/ul>/);
|
|
97
|
-
assert.match(js, /api\.request\('GET', `\$\{API\}\/store\/modules`\)/, 'it reads GET /store/modules');
|
|
97
|
+
assert.match(js, /api\.request\('GET', `\$\{API\}\/store\/modules\$\{q\}`\)/, 'it reads GET /store/modules');
|
|
98
98
|
assert.match(js, /<span class="ov-fact">strong at \$\{partsList\(strong\)\}<\/span>/, 'a strength is a plain fact');
|
|
99
99
|
assert.match(js, /<span class="fact-pill fact-pill--warn">weak at \$\{partsList\(weak\)\}<\/span>/, 'a weakness is the warn pill');
|
|
100
100
|
assert.match(js, /not measured yet/, 'a part with no data is named, never counted as weak');
|
|
@@ -106,6 +106,18 @@ test('modules: the store group reads strengths and weaknesses off the server row
|
|
|
106
106
|
assert.notDeepEqual(two[0].strengths.map((s) => s.part), two[1].strengths.map((s) => s.part), '…that are strong at different things');
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
test('modules: the buyer sorts the store by the part that matters to them (task 1003800)', () => {
|
|
110
|
+
const js = read('modules.js');
|
|
111
|
+
const html = read('modules.html');
|
|
112
|
+
assert.match(html, /<select id="store-sort"><option value="">Name<\/option><\/select>/, 'the menu starts at name order');
|
|
113
|
+
assert.match(js, /\?sort=\$\{encodeURIComponent\(storeSort\)\}/, 'the server orders the list; the page asks for a part');
|
|
114
|
+
assert.match(js, /fillStoreSort\(body\.sort_parts\)/, 'the parts on the menu are the server\'s list');
|
|
115
|
+
assert.doesNotMatch(decomment(js), /\.sort\(\(a, b\)/, 'the page does not re-sort the store rows itself');
|
|
116
|
+
assert.match(js, /not measured yet<\/span>/, 'a row with no score for the sorted part says so, not a zero');
|
|
117
|
+
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'store__modules.json'), 'utf8'));
|
|
118
|
+
assert.deepEqual(fixture.sort_parts.map((p) => p.part), ['tests', 'install', 'reliability', 'tester_feedback', 'docs'], 'the harness carries the menu');
|
|
119
|
+
});
|
|
120
|
+
|
|
109
121
|
test('modules: the kit filter bar carries the search and a state lens, and the empty state is the kit\'s', () => {
|
|
110
122
|
const js = read('modules.js');
|
|
111
123
|
assert.match(js, /kit\.makeFilterBar\(\{/, 'the bar is the kit\'s');
|
|
@@ -53,7 +53,7 @@ const poolPath = require.resolve('../src/bongos/pool.js');
|
|
|
53
53
|
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ ...pool, release() {} }), query: pool.query } } };
|
|
54
54
|
|
|
55
55
|
const buildModulesRouter = require('../src/bongos/routes/modules.js');
|
|
56
|
-
const { QUALITY_FLOOR, placeVersion } = require('../src/bongos/module-listing.js');
|
|
56
|
+
const { QUALITY_FLOOR, SORT_PARTS, placeVersion, sortByPart } = require('../src/bongos/module-listing.js');
|
|
57
57
|
|
|
58
58
|
function handlerFor(router, method, p) {
|
|
59
59
|
const layer = router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]);
|
|
@@ -163,7 +163,83 @@ test('GET /store/modules: an empty store answers empty lists and the floor', asy
|
|
|
163
163
|
state.modules = {}; state.versions = []; state.scores = [];
|
|
164
164
|
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
165
165
|
const res = await invoke(handle, {});
|
|
166
|
-
assert.deepEqual(res.body, { floor: 50, listed: [], new: [] });
|
|
166
|
+
assert.deepEqual(res.body, { floor: 50, sort: null, sort_parts: SORT_PARTS, listed: [], new: [] });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// sort by the part a buyer cares about (task 1003800, owner decision 2026-10-22)
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
const entry = (key, overall, partScores) => ({
|
|
174
|
+
key, overall,
|
|
175
|
+
parts: Object.fromEntries(Object.entries(partScores).map(([p, s]) => [p, { outcome: s == null ? 'no_data' : 'scored', score: s, sample_size: null }])),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('sortByPart: highest first for the chosen part; a tie goes to the overall score, then the key', () => {
|
|
179
|
+
const out = sortByPart([
|
|
180
|
+
entry('b-mod', 70, { reliability: 90 }),
|
|
181
|
+
entry('a-mod', 70, { reliability: 90 }),
|
|
182
|
+
entry('steady', 60, { reliability: 95 }),
|
|
183
|
+
entry('higher', 80, { reliability: 90 }),
|
|
184
|
+
], 'reliability');
|
|
185
|
+
assert.deepEqual(out.map((e) => e.key), ['steady', 'higher', 'a-mod', 'b-mod']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('sortByPart: a module with no score for the part goes last, never first, and the input is untouched', () => {
|
|
189
|
+
const input = [entry('z-unmeasured', 99, { reliability: null }), entry('low', 55, { reliability: 10 }), entry('a-missing', 99, {})];
|
|
190
|
+
const out = sortByPart(input, 'reliability');
|
|
191
|
+
assert.deepEqual(out.map((e) => e.key), ['low', 'a-missing', 'z-unmeasured']);
|
|
192
|
+
assert.equal(input[0].key, 'z-unmeasured', 'a new array; the listing it came from keeps its order');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('SORT_PARTS: every part a buyer compares, Security not among them', () => {
|
|
196
|
+
assert.deepEqual(SORT_PARTS.map((p) => p.part), ['tests', 'install', 'reliability', 'tester_feedback', 'docs']);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('GET /store/modules?sort=: orders the listed modules by the part, leaves New in key order', async () => {
|
|
200
|
+
state.modules = {
|
|
201
|
+
tested: { status: 'listed', title: 'Tested' }, steady: { status: 'listed', title: 'Steady' },
|
|
202
|
+
'b-new': { status: 'listed', title: 'B new' }, 'a-new': { status: 'listed', title: 'A new' },
|
|
203
|
+
};
|
|
204
|
+
state.versions = [
|
|
205
|
+
{ id: 21, module_key: 'tested', version: '1.0.0', published_at: 't' },
|
|
206
|
+
{ id: 22, module_key: 'steady', version: '1.0.0', published_at: 't' },
|
|
207
|
+
{ id: 23, module_key: 'b-new', version: '1.0.0', published_at: 't' },
|
|
208
|
+
{ id: 24, module_key: 'a-new', version: '1.0.0', published_at: 't' },
|
|
209
|
+
];
|
|
210
|
+
state.scores = [
|
|
211
|
+
{ id: 21, version_id: 21, computed_at: 1, ...score({ overall: 74 }) },
|
|
212
|
+
{ id: 22, version_id: 22, computed_at: 1, ...score({ overall: 74 }) },
|
|
213
|
+
{ id: 23, version_id: 23, computed_at: 1, ...score({ is_new: true }) },
|
|
214
|
+
{ id: 24, version_id: 24, computed_at: 1, ...score({ is_new: true }) },
|
|
215
|
+
];
|
|
216
|
+
state.signals = [
|
|
217
|
+
{ id: 21, version_id: 21, measured_at: 1, ...sig('tests', 96) },
|
|
218
|
+
{ id: 22, version_id: 21, measured_at: 1, ...sig('reliability', 52) },
|
|
219
|
+
{ id: 23, version_id: 22, measured_at: 1, ...sig('tests', 58) },
|
|
220
|
+
{ id: 24, version_id: 22, measured_at: 1, ...sig('reliability', 91) },
|
|
221
|
+
{ id: 25, version_id: 23, measured_at: 1, ...sig('reliability', 99) },
|
|
222
|
+
];
|
|
223
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
224
|
+
const byRel = await invoke(handle, { query: { sort: 'reliability' } });
|
|
225
|
+
assert.equal(byRel.body.sort, 'reliability');
|
|
226
|
+
assert.deepEqual(byRel.body.listed.map((m) => m.key), ['steady', 'tested'], 'the buyer who cares about reliability sees Steady first');
|
|
227
|
+
assert.deepEqual(byRel.body.new.map((m) => m.key), ['a-new', 'b-new'], 'New is never ranked, whatever the buyer picks');
|
|
228
|
+
const byTests = await invoke(handle, { query: { sort: 'tests' } });
|
|
229
|
+
assert.deepEqual(byTests.body.listed.map((m) => m.key), ['tested', 'steady']);
|
|
230
|
+
const plain = await invoke(handle, { query: {} });
|
|
231
|
+
assert.equal(plain.body.sort, null);
|
|
232
|
+
assert.deepEqual(plain.body.listed.map((m) => m.key), ['steady', 'tested'], 'no sort is key order, as before');
|
|
233
|
+
state.signals = [];
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('GET /store/modules?sort=: a part it does not know is refused, Security included', async () => {
|
|
237
|
+
const { handle } = handlerFor(buildModulesRouter(), 'get', '/store/modules');
|
|
238
|
+
for (const sort of ['security', 'price', 'overall']) {
|
|
239
|
+
const res = await invoke(handle, { query: { sort } });
|
|
240
|
+
assert.equal(res.statusCode, 400, `${sort} is not a part to sort by`);
|
|
241
|
+
assert.match(res.body.message, /Tests \(tests\), Clean installs \(install\), Reliability \(reliability\), Tester feedback \(tester_feedback\), How-to \(docs\)/, 'the refusal names each part as a person says it, with the value to send');
|
|
242
|
+
}
|
|
167
243
|
});
|
|
168
244
|
|
|
169
245
|
// ---------------------------------------------------------------------------
|