@bongos/core 1.19.532 → 1.19.533

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.
@@ -1028,6 +1028,14 @@ summary{min-height:24px;padding:3px 0;}
1028
1028
  <p class="lede">Optional — skip it and you get the suggested set below, shaped by what you just told us. These are extras on top of everything every project already has, and you can switch any of them on or off later.</p>
1029
1029
  <form data-next="5">
1030
1030
  <div class="wizField"><span id="modLbl">Extras for this project</span>
1031
+ <!-- the catalog search (task 1002340, BV1.R21). The wizard's own
1032
+ search idiom (#ghSearch): a plain field on one hairline, no
1033
+ new class and no icon. It filters the band below and NOTHING
1034
+ else — what is switched on is unaffected by what is on
1035
+ screen, and the line under it counts the ones it is hiding
1036
+ (ADR 0244). -->
1037
+ <input type="text" id="modQ" placeholder="Search extras…" autocomplete="off" aria-label="Search the extras by name" aria-describedby="modFilterNote">
1038
+ <div class="hint" id="modFilterNote" aria-live="polite"></div>
1031
1039
  <div class="typeCards" id="modCards" role="group" aria-labelledby="modLbl"></div>
1032
1040
  </div>
1033
1041
  <div class="modWhy" id="modWhy" aria-live="polite"></div>
@@ -3262,10 +3270,52 @@ summary{min-height:24px;padding:3px 0;}
3262
3270
  for (var i = 0; i < rows.length; i++) if (rows[i].key === key) return rows[i].label;
3263
3271
  return key;
3264
3272
  }
3273
+ /* ── the catalog search (task 1002340, BV1.R21) ──
3274
+ Lexical and deliberately dumb: it matches the words the owner can already
3275
+ see. Search by MEANING ("somewhere to chat") is a different feature and is
3276
+ deferred (idea 1000552), so nothing here pretends to understand a query.
3277
+
3278
+ It is a VIEW over the rows and never an edit to the answer (ADR 0244):
3279
+ filtering paints fewer rows, and state.modules does not move. That is what
3280
+ lets a switched-on module be off screen without being switched off — the
3281
+ count under the field says so rather than leaving it to be discovered. */
3282
+ var moduleQuery = ''; /* what is typed in the search box; never saved to the draft */
3283
+ /* A query as the terms to match: lower-cased, split on whitespace, empties
3284
+ dropped — so a box holding only spaces is the same as an empty one. */
3285
+ function searchTerms(q) {
3286
+ return String(q == null ? '' : q).toLowerCase().split(/\s+/).filter(function (t) { return t.length > 0; });
3287
+ }
3288
+ /* Does one catalog row answer this search? Every term must appear somewhere in
3289
+ the row's own words — its name, its one-line summary, or its key — as a
3290
+ substring, case-insensitively. AND across terms, so adding a word narrows;
3291
+ substring, so a half-typed word still finds its module. */
3292
+ function moduleMatches(mod, terms) {
3293
+ if (!terms.length) return true;
3294
+ var hay = (((mod && mod.label) || '') + ' ' + ((mod && mod.summary) || '') + ' ' + ((mod && mod.key) || '')).toLowerCase();
3295
+ for (var i = 0; i < terms.length; i++) if (hay.indexOf(terms[i]) === -1) return false;
3296
+ return true;
3297
+ }
3298
+ /* The catalog rows the current search leaves on screen, in catalog order. */
3299
+ function visibleModules() {
3300
+ var terms = searchTerms(moduleQuery);
3301
+ return ((starterBundles && starterBundles.optional) || []).filter(function (m) { return moduleMatches(m, terms); });
3302
+ }
3303
+ /* The line under the search box. Silent with nothing typed — there is nothing
3304
+ to explain. Once a search is hiding rows it says how many are left AND how
3305
+ many extras are on, counting the hidden ones: a switched-on module a filter
3306
+ took off screen would otherwise read as switched off. */
3307
+ function filterNote(shown, total, onCount) {
3308
+ if (!searchTerms(moduleQuery).length) return '';
3309
+ var line = shown ? 'Showing ' + shown + ' of ' + total + '.' : 'No extras match what you typed.';
3310
+ return onCount
3311
+ ? line + ' ' + onCount + (onCount === 1 ? ' extra is' : ' extras are') + ' switched on, including any this search is hiding.'
3312
+ : line;
3313
+ }
3265
3314
  function renderModules() {
3266
3315
  ensureBundles();
3267
3316
  var band = $('modCards');
3268
3317
  var note = $('modCoreNote');
3318
+ $('modQ').hidden = !starterBundles; /* a search box over a list that is not there can only disappoint */
3269
3319
  if (!starterBundles) {
3270
3320
  /* nothing yet. While the read is still in flight that is a blank band for
3271
3321
  a moment; only a read that ANSWERED badly gets the clause, so a slow
@@ -3273,6 +3323,7 @@ summary{min-height:24px;padding:3px 0;}
3273
3323
  band.innerHTML = '';
3274
3324
  $('modWhy').innerHTML = '';
3275
3325
  $('modWhy').classList.remove('on');
3326
+ $('modFilterNote').textContent = '';
3276
3327
  note.textContent = bundlesFailed
3277
3328
  ? 'We couldn’t load the list of extras just now. Your project is created with everything it needs either way, and you can add extras from its settings.'
3278
3329
  : '';
@@ -3280,11 +3331,13 @@ summary{min-height:24px;padding:3px 0;}
3280
3331
  return;
3281
3332
  }
3282
3333
  var on = pickedModules();
3283
- band.innerHTML = (starterBundles.optional || []).map(function (m) {
3334
+ var offered = visibleModules();
3335
+ band.innerHTML = offered.map(function (m) {
3284
3336
  return '<button type="button" class="tpill" data-mod="' + esc(m.key) + '" aria-pressed="'
3285
3337
  + (on.indexOf(m.key) === -1 ? 'false' : 'true') + '"><span><b>' + esc(m.label)
3286
3338
  + '</b><small>' + esc(m.summary) + '</small></span></button>';
3287
3339
  }).join('');
3340
+ $('modFilterNote').textContent = filterNote(offered.length, (starterBundles.optional || []).length, on.length);
3288
3341
  /* The tailoring, said out loud (task 1002341). A preselect an owner cannot
3289
3342
  see the reason for is one they have to audit, which is the thing ADR 0237
3290
3343
  says is worse than no preselect at all. Rendered as its own block above the
@@ -3317,6 +3370,13 @@ summary{min-height:24px;padding:3px 0;}
3317
3370
  renderModules();
3318
3371
  saveDraft();
3319
3372
  });
3373
+ /* Typing repaints the band and nothing else (task 1002340): no read (the
3374
+ catalog is already here) and no saveDraft — a search is not an answer, so
3375
+ it must not be able to turn an untouched picker into an answered one. */
3376
+ $('modQ').addEventListener('input', function (ev) {
3377
+ moduleQuery = (ev && ev.target && ev.target.value) || '';
3378
+ renderModules();
3379
+ });
3320
3380
  /* Next off this panel CONFIRMS what is on screen — the owner looked at the
3321
3381
  set and accepted it, which is a different fact from never being asked. */
3322
3382
  root.querySelector('#panel-modules form').addEventListener('submit', function () {
@@ -30,7 +30,8 @@
30
30
  "out-new": { "auth": false, "url": "/projects?view=new", "expect": { "hidden": ["#qWrap"] } },
31
31
  "in-new-type": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-type"],"hidden":["#panel-name","#qWrap"]} },
32
32
  "in-new-about": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-detail"],"hidden":["#panel-type","#qWrap"]} },
33
- "in-new-modules": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900]], "expect": {"visible":["#panel-modules","#modCards .tpill[aria-pressed=\"true\"]","#modCoreNote"],"hidden":["#panel-detail","#qWrap"]} },
33
+ "in-new-modules": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900]], "expect": {"visible":["#panel-modules","#modQ","#modCards .tpill[aria-pressed=\"true\"]","#modCoreNote"],"hidden":["#panel-detail","#qWrap"]} },
34
+ "in-new-modules-search": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["fill","#modQ","discord"],["wait",200]], "expect": {"visible":["#panel-modules","#modQ"],"value":[["#modQ","discord"]],"count":[["#modCards .tpill",1],["#modCards .tpill[data-mod=\"discord\"]",1]],"text":[["#modFilterNote","Showing 1 of 3. 2 extras are switched on, including any this search is hiding."]]} },
34
35
  "in-new-home": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-home"],"hidden":["#panel-detail","#qWrap"]} },
35
36
  "in-new-address": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-address"],"hidden":["#panel-home","#qWrap"]} },
36
37
  "in-new-review": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-review","#reviewList li"],"hidden":["#panel-address","#qWrap"]} },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.532",
3
+ "version": "1.19.533",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.532",
9
+ "version": "1.19.533",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.532",
3
+ "version": "1.19.533",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.532'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.533'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -2141,6 +2141,28 @@ test('the Modules step ships as real markup and is skippable like About (task 10
2141
2141
  'the optional step has no validation clause — Next past an untouched picker is the point');
2142
2142
  });
2143
2143
 
2144
+ test('the picker\'s search ships as real markup, in the wizard\'s own field grammar (task 1002340)', () => {
2145
+ const panel = (HUB.match(/<section class="panel" id="panel-modules"[\s\S]*?<\/section>/) || [''])[0];
2146
+ assert.match(panel, /<input type="text" id="modQ"[^>]*>/, 'the search is a real field, not one built by script');
2147
+ assert.match(panel, /id="modQ"[^>]*aria-label="Search the extras by name"/, 'and it is named for a screen reader');
2148
+ assert.match(panel, /id="modFilterNote"[^>]*aria-live="polite"/, 'the count under it is announced when it changes');
2149
+ // it wears the wizard's own control skin (#view-new input[type=text]) rather
2150
+ // than a class of its own — the C1 rule the type and team bands follow
2151
+ assert.doesNotMatch(panel, /id="modQ"[^>]*class=/, 'no bespoke class for one field');
2152
+ // the band, the field and the count are one wizField — a search that sat
2153
+ // outside the field it filters would read as filtering the whole panel
2154
+ const field = (panel.match(/<div class="wizField"><span id="modLbl"[\s\S]*?<\/div>\s*<\/div>/) || [''])[0];
2155
+ assert.match(field, /id="modQ"/, 'the field holds the search');
2156
+ assert.match(field, /id="modCards"/, 'and the band it filters');
2157
+ // display only: the filter reaches the render, never state.modules (ADR 0244)
2158
+ assert.match(SCRIPT, /function moduleMatches\(mod, terms\)/, 'the matcher is a named function the harness can run');
2159
+ assert.match(SCRIPT, /function visibleModules\(\)/, 'and the rows it leaves are derived, not stored');
2160
+ const onInput = SCRIPT.slice(SCRIPT.indexOf("$('modQ').addEventListener('input'"));
2161
+ const body = onInput.slice(0, onInput.indexOf('});'));
2162
+ assert.doesNotMatch(body, /state\.modules/, 'typing never writes an answer');
2163
+ assert.doesNotMatch(body, /saveDraft/, 'and a search is not part of the draft');
2164
+ });
2165
+
2144
2166
  test('the picker reads ONE public declaration, and it is the one the bundles come from (task 1002339)', () => {
2145
2167
  // A second source for "what may be chosen" is exactly what would let a
2146
2168
  // preselect name a module the panel has no row for (ADR 0237 §5).
@@ -205,6 +205,13 @@ function boot({ state: seed = {}, fetchOk = true, payload = PAYLOAD } = {}) {
205
205
  skip: () => $('skipModules')._handlers.click(),
206
206
  forCreate: () => sandbox.modulesForCreate(),
207
207
  picked: () => sandbox.pickedModules(),
208
+ // BV1.R21 (task 1002340) — the search box. type() is the real `input` event
209
+ // the browser fires, so a test drives the same path a keystroke does.
210
+ type: (q) => { $('modQ')._handlers.input({ target: { value: q } }); },
211
+ filterNote: () => $('modFilterNote').textContent,
212
+ searchHidden: () => $('modQ').hidden === true,
213
+ // the pure matcher on its own, one row against one query
214
+ matches: (mod, q) => sandbox.moduleMatches(mod, sandbox.searchTerms(q)),
208
215
  };
209
216
  }
210
217
 
@@ -580,3 +587,203 @@ test('the picker and the server resolve the same set for every answer an owner c
580
587
  }
581
588
  }
582
589
  });
590
+
591
+ // ─────────────────────────────────────────────────────────────────────────────
592
+ // BV1.R21 (task 1002340) — keyword search over the catalog. ADR 0244: the search
593
+ // is a VIEW over the rows and never an edit to the answer, so everything below
594
+ // asserts both halves — what the band shows, and that what is switched on did
595
+ // not move.
596
+ // ─────────────────────────────────────────────────────────────────────────────
597
+
598
+ const DEV_BOX = PAYLOAD.optional[1];
599
+
600
+ test('the matcher reads a row\'s own words — its name, its summary and its key', () => {
601
+ // Lexical, not semantic (idea 1000552 holds the by-meaning half): the only
602
+ // words it knows are the ones the owner can see, plus the key they cannot.
603
+ const h = boot({ state: { projType: 'game' } });
604
+
605
+ assert.equal(h.matches(DEV_BOX, 'boxes'), true, 'by a word in the name');
606
+ assert.equal(h.matches(DEV_BOX, 'workspace'), true, 'by a word in the summary');
607
+ assert.equal(h.matches(DEV_BOX, 'dev-box'), true, 'by the key');
608
+ assert.equal(h.matches(DEV_BOX, 'discord'), false, 'and never by a word that is in none of them');
609
+ });
610
+
611
+ test('a term matches as a SUBSTRING, so a half-typed word still finds its module', () => {
612
+ const h = boot({ state: { projType: 'game' } });
613
+
614
+ assert.equal(h.matches(DEV_BOX, 'work'), true, 'the owner is still typing');
615
+ assert.equal(h.matches(DEV_BOX, 'ace'), true, 'even mid-word — a dumb filter beats a clever one that finds nothing');
616
+ });
617
+
618
+ test('matching is case-insensitive in both directions', () => {
619
+ const h = boot({ state: { projType: 'game' } });
620
+
621
+ assert.equal(h.matches(DEV_BOX, 'DEV BOXES'), true, 'a shouted query finds a title-cased row');
622
+ assert.equal(h.matches(PAYLOAD.optional[0], 'ai'), true, 'and a quiet one finds a shouted row');
623
+ });
624
+
625
+ test('every term must match — typing more words narrows, it never widens', () => {
626
+ // AND, not OR. An owner adding a word is telling us the list is still too long.
627
+ const h = boot({ state: { projType: 'game' } });
628
+
629
+ assert.equal(h.matches(DEV_BOX, 'cloud change'), true, 'both words are in the row');
630
+ assert.equal(h.matches(DEV_BOX, 'cloud discord'), false, 'one of them is not, so the row is out');
631
+ });
632
+
633
+ test('an empty search is not a search — every row stays', () => {
634
+ const h = boot({ state: { projType: 'game' } });
635
+
636
+ assert.equal(h.matches(DEV_BOX, ''), true);
637
+ assert.equal(h.matches(DEV_BOX, ' '), true, 'and a box holding only spaces is the same as an empty one');
638
+ });
639
+
640
+ test('typing filters the band to the rows that answer, in catalog order', async () => {
641
+ // Arrange
642
+ const h = boot({ state: { projType: 'game' } });
643
+ await h.open();
644
+ assert.equal(h.rows().length, 3, 'the whole catalog before anything is typed');
645
+
646
+ // Act — a word from one row's NAME
647
+ h.type('boxes');
648
+
649
+ // Assert
650
+ assert.deepEqual(h.rows().map((r) => r.key), ['dev-box']);
651
+ });
652
+
653
+ test('a summary word and a key find their row just as a name word does', async () => {
654
+ const h = boot({ state: { projType: 'game' } });
655
+ await h.open();
656
+
657
+ h.type('workspace');
658
+ assert.deepEqual(h.rows().map((r) => r.key), ['dev-box'], 'by summary');
659
+
660
+ h.type('dev-box');
661
+ assert.deepEqual(h.rows().map((r) => r.key), ['dev-box'], 'by key');
662
+
663
+ h.type('DISCORD');
664
+ assert.deepEqual(h.rows().map((r) => r.key), ['discord'], 'and case is not part of the question');
665
+ });
666
+
667
+ test('two terms narrow to the rows that carry both', async () => {
668
+ const h = boot({ state: { projType: 'game' } });
669
+ await h.open();
670
+
671
+ h.type('cloud change');
672
+ assert.deepEqual(h.rows().map((r) => r.key), ['dev-box']);
673
+
674
+ h.type('cloud discord');
675
+ assert.deepEqual(h.rows(), [], 'and no row carries both of these');
676
+ });
677
+
678
+ test('a search that matches nothing says so, and invents no rows', async () => {
679
+ // The band would otherwise just be empty, which reads as "the catalog failed"
680
+ // — the one thing the couldn't-load clause exists to distinguish.
681
+ const h = boot({ state: { projType: 'game' } });
682
+ await h.open();
683
+
684
+ h.type('kubernetes');
685
+
686
+ assert.deepEqual(h.rows(), []);
687
+ assert.match(h.filterNote(), /No extras match/, 'the panel says why the band is empty');
688
+ });
689
+
690
+ test('a switched-on module the search is HIDING is still switched on', async () => {
691
+ // The property ADR 0244 is about. A filter is a view; the answer is the set of
692
+ // toggles, and a view must not be able to edit it.
693
+ const h = boot({ state: { projType: 'game' } });
694
+ await h.open();
695
+ assert.deepEqual(h.picked(), ['dev-box', 'discord'], 'the game bundle is on');
696
+
697
+ // Act — search for the one module that is OFF, hiding both that are on
698
+ h.type('agents');
699
+
700
+ // Assert
701
+ assert.deepEqual(h.rows().map((r) => r.key), ['agents'], 'only the match is on screen');
702
+ assert.equal(h.rows()[0].pressed, false, 'and it is off, as it was');
703
+ assert.deepEqual(h.picked(), ['dev-box', 'discord'], 'the two hidden ones are still switched on');
704
+ assert.equal(h.state.modules, null, 'and typing answered nothing on the owner\'s behalf');
705
+ assert.match(h.filterNote(), /2 extras are switched on/, 'the count under the field says so out loud');
706
+ assert.match(h.filterNote(), /including any this search is hiding/, 'and that some of them are hidden');
707
+ });
708
+
709
+ test('the count under the field states what is shown and what is on, singular included', async () => {
710
+ const h = boot({ state: { projType: 'research' } });
711
+ await h.open();
712
+ assert.equal(h.filterNote(), '', 'with nothing typed there is nothing to explain');
713
+
714
+ h.type('agents');
715
+
716
+ assert.equal(h.filterNote(),
717
+ 'Showing 1 of 3. 1 extra is switched on, including any this search is hiding.');
718
+ });
719
+
720
+ test('with nothing switched on the count says only what is shown', async () => {
721
+ const h = boot({ state: { projType: 'not-sure' } });
722
+ await h.open();
723
+
724
+ h.type('a');
725
+
726
+ assert.equal(h.filterNote(), 'Showing 3 of 3.', 'no "0 extras are switched on" noise');
727
+ });
728
+
729
+ test('the review names the hidden picks too — a filter never reaches the create request', async () => {
730
+ const h = boot({ state: { projType: 'game', step: 7 } });
731
+ await h.open();
732
+ h.click('agents');
733
+ assert.deepEqual(h.forCreate(), ['agents', 'dev-box', 'discord']);
734
+
735
+ h.type('discord');
736
+
737
+ assert.deepEqual(h.rows().map((r) => r.key), ['discord'], 'one row on screen');
738
+ assert.deepEqual(h.forCreate(), ['agents', 'dev-box', 'discord'], 'three modules in the answer');
739
+ assert.equal(h.extrasRow(), 'Extra agents, Dev boxes, Discord', 'and the review names all three');
740
+ });
741
+
742
+ test('clearing the search brings every row back, untouched', async () => {
743
+ const h = boot({ state: { projType: 'game' } });
744
+ await h.open();
745
+ h.type('agents');
746
+ assert.equal(h.rows().length, 1);
747
+
748
+ h.type('');
749
+
750
+ assert.deepEqual(h.rows().map((r) => r.key), ['agents', 'dev-box', 'discord']);
751
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord'],
752
+ 'with the preselect exactly as it was before the search');
753
+ assert.equal(h.filterNote(), '', 'and the count goes quiet again');
754
+ });
755
+
756
+ test('a toggle made while filtered lands in the answer in catalog order, and the filter stands', async () => {
757
+ // The path BV1.R24 walks: preselect -> search -> add -> enabled.
758
+ const h = boot({ state: { projType: 'non-profit' } });
759
+ await h.open();
760
+
761
+ h.type('workspace');
762
+ h.click('dev-box');
763
+
764
+ assert.deepEqual(h.state.modules, ['dev-box', 'discord'], 'catalog order, not click order');
765
+ assert.deepEqual(h.rows().map((r) => r.key), ['dev-box'], 'and the search is still the search');
766
+ assert.equal(h.rows()[0].pressed, true, 'the row they just switched on shows it');
767
+ });
768
+
769
+ test('a search is not an answer: it saves no draft and costs no round trip', async () => {
770
+ const h = boot({ state: { projType: 'game' } });
771
+ await h.open();
772
+ const savesBefore = h.saves();
773
+
774
+ h.type('dev');
775
+ h.type('');
776
+
777
+ assert.equal(h.saves(), savesBefore, 'nothing is written to the draft');
778
+ assert.equal(h.fetches(), 1, 'and the catalog is already here — typing re-reads nothing');
779
+ });
780
+
781
+ test('with no catalog there is nothing to search, so the box is not offered', async () => {
782
+ // A search field over a list that failed to load is an affordance that can
783
+ // only disappoint.
784
+ const h = boot({ state: { projType: 'game' }, fetchOk: false });
785
+ await h.open();
786
+
787
+ assert.equal(h.searchHidden(), true);
788
+ assert.equal(h.filterNote(), '', 'and no count for a band with no rows');
789
+ });