From 80188daea7c5efd777a69e4889309eb411b1bfca Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 04:30:19 +0200 Subject: [PATCH] [feat] templates search field + community provider search/sorts (76 FW3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - one search field on every Templates tab: every word must match title, description, author or tags (communitySearch.matchesQuery) — the OSS GitHub gallery is searchable with no server; the query survives a tab switch, Escape clears it first - provider `search: true` gets list({q}) debounced 300 ms; `sorts: [{id,label}]` render as chips beside the field (first = default, never sent); absent = absent, a 36-era provider is called list({force}) exactly as before - a re-list keeps the grid up ("Searching…"), an older query's late answer is dropped, an empty answer reads "No scenes match" - OPEN-CORE.md documents the additive members (no CLOUD_HOOKS_VERSION bump) - unit communitySearch (7) + contentIndex memo pin; e2e community-search (OSS, 36-era and searching providers, stale-answer counterfactual, N6 folded + unfolded) Co-Authored-By: Claude Opus 5.5 (1M context) --- OPEN-CORE.md | 16 ++ src/components/menu/TemplatesModal.svelte | 108 ++++++++++- src/lib/communitySearch.js | 36 ++++ src/lib/sceneTemplates.js | 56 +++++- tests/e2e/battery.json | 1 + tests/e2e/community-search.test.cjs | 220 ++++++++++++++++++++++ tests/unit/communitySearch.test.js | 48 +++++ tests/unit/contentIndex.test.js | 3 +- 8 files changed, 477 insertions(+), 11 deletions(-) create mode 100644 src/lib/communitySearch.js create mode 100644 tests/e2e/community-search.test.cjs create mode 100644 tests/unit/communitySearch.test.js diff --git a/OPEN-CORE.md b/OPEN-CORE.md index 30b4fc56c..e1b4ae1ef 100644 --- a/OPEN-CORE.md +++ b/OPEN-CORE.md @@ -272,6 +272,22 @@ Promise<{liked, likeCount} | null>` puts a heart on every card (`[data-card-hear A play link's frame may carry `&src=play|embed&b=v` (read once with `embed=1`; `playMode.embedSource` / `embedBuild`) — the badge inside counts them. +#### 76 FW3: search and sorts on the Community tab — additive, no bump + +The Templates modal has ONE search field (`#templates-search`) on every tab: core filters the +cards client-side — every word must appear in the title, description, author or tags +(`communitySearch.matchesQuery`) — so the OSS GitHub gallery is searchable with no server. Two +optional provider members widen it on Community: + +| member | contract | +|---|---| +| `search: true` | `list()` also receives `q` (lower-case, single-spaced, ≤ 60 chars, debounced 300 ms) when the field is not empty; search the same fields server-side so a returned row also passes core's filter. An older query's late answer is dropped | +| `sorts: [{id, label}]` | ≤ 5 chips beside the field (`#community-sorts [data-sort]`); the FIRST is the default and is never sent — another chip's `id` arrives as `list({sort})` | + +Absent = absent: a provider that declares neither is called `list({force})` exactly as before +and is never re-listed for a query. While a re-list runs the grid stays up and +`#community-searching` says "Searching…"; an empty answer to a query reads "No scenes match". + #### 37 R20: "Report a problem" — `setProblemReporter(reporter | null)` — additive, no bump Core draws the whole report — the screenshot taken at the press, boxes drawn round what is diff --git a/src/components/menu/TemplatesModal.svelte b/src/components/menu/TemplatesModal.svelte index 856ac31fc..f806bd5d3 100644 --- a/src/components/menu/TemplatesModal.svelte +++ b/src/components/menu/TemplatesModal.svelte @@ -10,6 +10,7 @@ import Tabs from '../ui/Tabs.svelte'; import EmptyState from '../ui/EmptyState.svelte'; import Icon from '../ui/Icon.svelte'; + import SearchField from '../ui/SearchField.svelte'; import { templatesModalOpen, templatesModalTab, hidePanels, restorePanels } from '../../stores/appStore.js'; import { templates, @@ -19,6 +20,9 @@ communityEntries, communityState, communityNotice, + communityQuery, + communitySearching, + setCommunityQuery, loadingSlug, loadTemplatesIndex, loadCommunityGallery, @@ -33,6 +37,7 @@ // 28-A6: a cloud plugin's Community provider — null in the OSS build. Read for two // things only: whether the GitHub copy applies, and the provider's own submit control. import { communityProvider } from '$lib/cloudHooks'; + import { matchesQuery, normQuery } from '$lib/communitySearch.js'; import { licenseLabel } from '$lib/packs'; import { classifyRequirements } from '$lib/moduleRequirements'; import HeartButton from '../ui/HeartButton.svelte'; @@ -59,8 +64,45 @@ // (`provider.mine`) and marks the viewer's own entries; a facet of its own, AND-ed with tags let mineOnly = $state(false); const mineChip = $derived(tab === 'community' && $communityProvider?.mine === true); + // 76 FW3: the search field — one query for every tab (it survives a tab switch, unlike the + // chips: a word means the same thing in each tab). Client-side over title, description, + // author and tags everywhere; a provider that declared `search: true` also gets it + // server-side (debounced), so Community finds scenes beyond the first page. + let query = $state(''); + const q = $derived(normQuery(query)); + // 76 FW3: the provider's sorts ({id, label}[], the first is its default) — Community only + const sorts = $derived( + tab === 'community' && Array.isArray($communityProvider?.sorts) + ? $communityProvider.sorts + .filter((/** @type {any} */ s) => s && typeof s.id === 'string' && typeof s.label === 'string') + .slice(0, 5) + : [] + ); + const sortId = $derived( + sorts.some((/** @type {any} */ s) => s.id === $communityQuery.sort) ? $communityQuery.sort : (sorts[0]?.id ?? '') + ); + /** @param {string} id */ + function pickSort(id) { + setCommunityQuery({ q: $communityQuery.q, sort: id === sorts[0]?.id ? '' : id }); + } + // the server half is debounced; the client filter above is immediate + $effect(() => { + const next = q; + const t = setTimeout(() => untrack(() => setCommunityQuery({ q: next, sort: $communityQuery.sort })), 300); + return () => clearTimeout(t); + }); + /** @param {KeyboardEvent} e Escape clears a non-empty field before it closes the modal */ + function searchKey(e) { + if (e.key === 'Escape' && query) { + e.preventDefault(); + e.stopPropagation(); + query = ''; + } + } const shown = $derived( - tabEntries.filter((/** @type {any} */ e) => matchesTags(e, activeTags) && (!mineOnly || !mineChip || e.mine === true)) + tabEntries.filter( + (/** @type {any} */ e) => matchesTags(e, activeTags) && (!mineOnly || !mineChip || e.mine === true) && matchesQuery(e, q) + ) ); // 36-community (C6): a provider that can write a like puts a heart on its cards const canLike = $derived(tab === 'community' && typeof $communityProvider?.toggleLike === 'function'); @@ -141,8 +183,10 @@ } }); }); - // the Community manifest fetches lazily, first time the tab is opened + // the Community manifest fetches lazily, first time the tab is opened — and again when the + // query/sort it answers changes (76 FW3; loadCommunityGallery memoizes per query) $effect(() => { + void $communityQuery; const wants = $templatesModalOpen && tab === 'community'; untrack(() => { if (wants) loadCommunityGallery(); @@ -329,6 +373,35 @@ {/snippet}
+ +
+ + {#if sorts.length} +
+ {#each sorts as s (s.id)} + + {/each} +
+ {/if} +
+ {#if tab === 'community' && $communitySearching} +

Searching…

+ {/if} + @@ -480,6 +553,8 @@
{#if !shown.length && mineOnly}

Nothing published yet — publish a scene and it shows here.

+ {:else if !shown.length && q && !$communitySearching} +

No scenes match “{q}”.

{/if}

{#if !provided} @@ -492,7 +567,9 @@

{$communityState === 'error' ? "Couldn't reach the community gallery — check your connection." - : 'No community scenes yet — be the first!'} + : $communityQuery.q + ? `No scenes match “${$communityQuery.q}”.` + : 'No community scenes yet — be the first!'}

{#if !provided}

@@ -531,6 +608,31 @@ gap: 6px; margin-bottom: var(--space-4); } + /* 76 FW3: the search field + the provider's sort chips share one row, wrapping on a phone */ + .tpl-search-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-3); + } + .tpl-search-row > :global(.sf) { + flex: 1 1 220px; + } + .tpl-sorts { + display: flex; + gap: 6px; + } + /* a touch screen wider than the phone breakpoint (Find N6 unfolded, a tablet) still needs + 44 px targets — SearchField's own 44 px rule is width-based */ + @media (pointer: coarse) { + .tpl-search-row > :global(.sf) { + height: 44px; + } + .tpl-search-row .tpl-chip { + height: 44px; + } + } .tpl-chip { height: var(--control-h-sm); padding: 0 12px; diff --git a/src/lib/communitySearch.js b/src/lib/communitySearch.js new file mode 100644 index 000000000..619ff98bb --- /dev/null +++ b/src/lib/communitySearch.js @@ -0,0 +1,36 @@ +// 76 FW3: the Templates modal's search field — pure, so the unit suite and the modal agree. +// A query is up to 60 characters of words; a card matches when EVERY word appears somewhere +// in its title, description, author or tags (case-insensitive substring), so "space race" +// finds a scene titled "Race" tagged #space. The cloud provider searches the same four +// fields server-side (title, summary, tags, the maker's @handle), so a card the server +// returned also passes this filter. + +/** @param {unknown} raw @returns {string} trimmed, single-spaced, lower-case, ≤ 60 chars */ +export function normQuery(raw) { + return String(raw ?? '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase() + .slice(0, 60) + .trim(); +} + +/** @param {unknown} q @returns {string[]} the distinct words, at most 6 (a leading # or @ is dropped) */ +export function queryWords(q) { + const words = normQuery(q) + .split(' ') + .map((w) => w.replace(/^[#@]+/, '')) + .filter(Boolean); + return [...new Set(words)].slice(0, 6); +} + +/** @param {any} entry a normalized card entry @param {unknown} q @returns {boolean} */ +export function matchesQuery(entry, q) { + const words = queryWords(q); + if (!words.length) return true; + const hay = [entry?.title, entry?.description, entry?.author, ...(Array.isArray(entry?.tags) ? entry.tags : [])] + .filter((v) => typeof v === 'string' && v) + .join('\n') + .toLowerCase(); + return words.every((w) => hay.includes(w)); +} diff --git a/src/lib/sceneTemplates.js b/src/lib/sceneTemplates.js index e946bff68..a25fd3960 100644 --- a/src/lib/sceneTemplates.js +++ b/src/lib/sceneTemplates.js @@ -8,6 +8,7 @@ import { claimLoad, isLive, endLoad, updateLoad } from './sceneLoader'; // 28-A6: the Community source a cloud plugin may install (store-only, no cycle — the // objectPermissions import above already reaches cloudHooks' family) import { communityProvider, notifyTemplateOpen } from './cloudHooks'; +import { normQuery } from './communitySearch.js'; import { primeLoadOrigin } from './gameIdentity.js'; // 33 (L3): the Clear scene modal's setup check and full clear (see confirmClearScene) import * as sceneSwitch from './sceneSwitch'; @@ -81,6 +82,23 @@ export const loadingSlug = writable(null); * the OSS build, so the modal renders no row. * @type {import('svelte/store').Writable<{text: string, href?: string} | null>} */ export const communityNotice = writable(null); +/** 76 FW3: what the Community tab asks its provider for — `q` (normQuery'd text, '' = none) + * and `sort` (an id from `provider.sorts`, '' = the provider's default). Written by the modal; + * a provider that declared `search: true` / `sorts` receives both in `list()`. + * @type {import('svelte/store').Writable<{q: string, sort: string}>} */ +export const communityQuery = writable({ q: '', sort: '' }); +/** 76 FW3: true while a query/sort re-list is in flight — the grid stays up (no skeleton + * flash on every keystroke) and the modal shows a quiet "Searching…" instead. + * @type {import('svelte/store').Writable} */ +export const communitySearching = writable(false); +/** 76 FW3: the modal's one write path for the above (normalized here, so a store reader never + * sees raw input) @param {{q?: unknown, sort?: unknown}} next */ +export function setCommunityQuery(next) { + const q = normQuery(next?.q); + const sort = typeof next?.sort === 'string' ? next.sort.slice(0, 40) : ''; + const cur = get(communityQuery); + if (cur.q !== q || cur.sort !== sort) communityQuery.set({ q, sort }); +} /** Resolve an index path against a CDN base: absolute http(s) URLs pass through * (big .tpscene files >~20MB point at raw.githubusercontent — the jsDelivr file @@ -147,6 +165,10 @@ export function matchesTags(entry, active) { * the modal in a tab that outlived a deploy re-fetches instead of showing the old list. */ let templatesStale = false; let communityStale = false; +/** 76 FW3: the newest Community list request — an older answer that lands late is dropped — + * and the query it asked (`q\nsort`), so a changed query re-lists instead of hitting the memo */ +let communitySeq = 0; +let askedKey = '\n'; onContentStale(() => { templatesStale = true; communityStale = true; @@ -231,23 +253,40 @@ function normalizeProviderEntry(e) { * way, so the modal needs no state work. */ export async function loadCommunityGallery(force = false) { const state = get(communityState); - if (!force && !communityStale && (state === 'ready' || state === 'empty' || state === 'loading')) return; - communityStale = false; - communityState.set('loading'); const provider = get(communityProvider); + // 76 FW3: the part of the query this source answers — absent = absent, so a provider that + // declared neither `search` nor `sorts` (and the GitHub path) sees the 36-era call + const { q, sort } = get(communityQuery); + /** @type {any} */ + const ask = { force: !!force }; + if (provider?.search === true && q) ask.q = q; + if (Array.isArray(provider?.sorts) && sort) ask.sort = sort; + const key = `${ask.q ?? ''}\n${ask.sort ?? ''}`; + const requery = key !== askedKey; + if (!force && !requery && !communityStale && (state === 'ready' || state === 'empty' || state === 'loading')) return; + communityStale = false; + askedKey = key; + // a query/sort re-list keeps the grid that is showing; only a first load skeletons + const keepGrid = requery && (state === 'ready' || state === 'empty'); + if (keepGrid) communitySearching.set(true); + else communityState.set('loading'); + const seq = ++communitySeq; if (provider) { try { - const raw = await provider.list({ force: !!force }); + const raw = await provider.list(ask); // the provider may have been swapped/cleared while its list was in flight — a - // late answer must not overwrite what the new source (or the GitHub path) shows - if (get(communityProvider) !== provider) return; + // late answer must not overwrite what the new source (or the GitHub path) shows; + // 76 FW3: nor may an older query's answer overwrite a newer one's + if (get(communityProvider) !== provider || seq !== communitySeq) return; const list = (Array.isArray(raw) ? raw : []).map(normalizeProviderEntry); communityEntries.set(list); communityState.set(list.length ? 'ready' : 'empty'); } catch { - if (get(communityProvider) !== provider) return; + if (get(communityProvider) !== provider || seq !== communitySeq) return; communityEntries.set([]); communityState.set('error'); + } finally { + if (seq === communitySeq) communitySearching.set(false); } let notice = null; try { @@ -259,6 +298,8 @@ export async function loadCommunityGallery(force = false) { return; } communityNotice.set(null); + // the GitHub gallery is one small file: its search and sort are the modal's client-side filter + communitySearching.set(false); try { const res = await fetchIndex(GALLERY_JSON_URL); if (res.ok) { @@ -573,5 +614,6 @@ communityProvider.subscribe((provider) => { lastProvider = provider; communityEntries.set([]); communityNotice.set(null); + communitySearching.set(false); communityState.set('idle'); }); diff --git a/tests/e2e/battery.json b/tests/e2e/battery.json index a94c07622..fdc2337dd 100644 --- a/tests/e2e/battery.json +++ b/tests/e2e/battery.json @@ -305,6 +305,7 @@ "collider-viz": 20, "color-picker-15c": 38, "command-palette": 158, + "community-search": 160, "community-seams": 210, "connect-decision": 812, "connect-overlay": 15, diff --git a/tests/e2e/community-search.test.cjs b/tests/e2e/community-search.test.cjs new file mode 100644 index 000000000..39438e50d --- /dev/null +++ b/tests/e2e/community-search.test.cjs @@ -0,0 +1,220 @@ +// Roadmap 76 FW3 — the Templates modal's search field + the community provider's sorts. +// Single page, no signaling, no plugin file: providers are installed through the debug +// store (`cloudHooks.setCommunityProvider`, the seam the cloud plugin calls). +// 1. OSS: the GitHub gallery is filtered CLIENT-side over title, description, author and +// tags (every word must match); no sort chips; the query survives a tab switch; Escape +// clears a non-empty field before it closes the modal. +// 2. A 36-era provider (no `search`, no `sorts`) is asked exactly as before — list({force}) +// with no q / sort keys — and still filtered client-side. +// 3. A provider with `search: true` + `sorts`: chips render (first = active), the query +// reaches list({q}) DEBOUNCED, a sort click reaches list({sort}), picking the default +// sort sends none, the grid stays up with "Searching…" while a re-list is in flight, +// and an OLDER query's late answer never overwrites a newer one. +// 4. Phones (Find N6 folded 390x896, unfolded 770x850, touch): a 44 px field, chips on screen, +// no sideways scroll, typing + a tap on a sort chip work. +// Counterfactuals: each "absent" key is paired with a provider that declared the member and +// DID receive it; the stale-answer check is paired with the slow answer actually arriving. +const h = require('./helpers.cjs'); + +const { check } = h; + +const GALLERY = [ + { slug: 'g-race', title: 'Race', description: 'Laps against the clock', author: 'maker-one', license: 'CC0-1.0', tags: ['space'], scene: 's/a.tpscene' }, + { slug: 'g-boat', title: 'Boat trip', description: 'Calm water', author: 'maker-two', license: 'CC0-1.0', tags: ['water'], scene: 's/b.tpscene' }, + { slug: 'g-tower', title: 'Tower', description: 'Stack blocks', author: 'maker-one', license: 'MIT', tags: ['puzzle'], scene: 's/c.tpscene' } +]; + +const slugs = (page) => page.evaluate(() => [...document.querySelectorAll('#templates-modal [data-scene-slug]')].map((el) => el.getAttribute('data-scene-slug')).sort()); + +h.run(async () => { + const browser = await h.launch(); + const A = await h.setupPage(browser, 'A'); + await A.page.route('**/raw.githubusercontent.com/theprototype-app/community-gallery/**', (route) => route.fulfill({ json: { entries: GALLERY } })); + + // ---------------------------------------------------------------- 1. the OSS gallery + await A.page.evaluate(() => window.__stores.templatesModalOpen.set(true)); + await A.page.locator('#templates-tab-community').click(); + await h.eventually(() => slugs(A.page), (s) => s.length === 3, 'premise: the GitHub gallery shows its 3 rows'); + check((await A.page.locator('#templates-search').count()) === 1, 'the search field renders'); + check((await A.page.locator('#community-sorts').count()) === 0, 'OSS: no sort chips without a provider that declares sorts'); + + await A.page.locator('#templates-search').fill('clock'); + await h.eventually(() => slugs(A.page), (s) => s.join() === 'g-race', 'OSS: a DESCRIPTION word finds its card ("clock")'); + await A.page.locator('#templates-search').fill('maker-one'); + await h.eventually(() => slugs(A.page), (s) => s.join() === 'g-race,g-tower', 'OSS: an AUTHOR finds every card by them'); + await A.page.locator('#templates-search').fill('#water'); + await h.eventually(() => slugs(A.page), (s) => s.join() === 'g-boat', 'OSS: a #tag finds its card'); + await A.page.locator('#templates-search').fill('maker-one stack'); + await h.eventually(() => slugs(A.page), (s) => s.join() === 'g-tower', 'OSS: every word must match (author AND description)'); + await A.page.locator('#templates-search').fill('submarine'); + await h.eventually(() => slugs(A.page), (s) => s.length === 0, 'OSS: no match empties the grid'); + check(await A.page.locator('#community-search-empty').isVisible(), 'OSS: ...and says "No scenes match"'); + + // the query survives a tab switch (General filters by the same field) + await A.page.locator('#templates-search').fill('zzzz-nothing'); + await A.page.locator('#templates-tab-general').click(); + await A.page.waitForTimeout(300); + const general = await A.page.evaluate(() => ({ + value: /** @type {HTMLInputElement} */ (document.querySelector('#templates-search'))?.value, + cards: document.querySelectorAll('#templates-modal [data-scene-slug]').length, + blank: !!document.querySelector('#template-blank') + })); + check(general.value === 'zzzz-nothing' && general.cards === 0 && general.blank, 'the query survives a tab switch and filters General too (Blank stays)'); + await A.page.locator('#templates-search').fill(''); + await h.eventually(() => A.page.locator('#templates-modal [data-scene-slug]').count(), (n) => n > 0, 'premise: General has template cards again with the field empty'); + + // Escape: clears first, closes second + await A.page.locator('#templates-tab-community').click(); + await A.page.locator('#templates-search').fill('boat'); + await A.page.locator('#templates-search').press('Escape'); + await A.page.waitForTimeout(200); + const afterEsc = await A.page.evaluate(() => ({ + value: /** @type {HTMLInputElement} */ (document.querySelector('#templates-search'))?.value, + open: !!document.querySelector('#templates-modal') + })); + check(afterEsc.value === '' && afterEsc.open, 'Escape in a non-empty field clears it and keeps the modal open'); + + // ---------------------------------------------------------- 2. a 36-era provider + await A.page.evaluate((rows) => { + window.__asks = []; + window.__stores.cloudHooks.setCommunityProvider({ + list: async (ask) => { + window.__asks.push(ask); + return rows.map((r) => ({ ...r, slug: 'p-' + r.slug.slice(2) })); + }, + load: async () => true + }); + }, GALLERY); + await h.eventually(() => slugs(A.page), (s) => s.length === 3 && s[0].startsWith('p-'), 'premise: the 36-era provider lists its 3 rows'); + await A.page.locator('#templates-search').fill('water'); + await h.eventually(() => slugs(A.page), (s) => s.join() === 'p-boat', 'a provider WITHOUT search is still filtered client-side'); + await A.page.waitForTimeout(700); // past the 300 ms debounce + const oldAsks = await A.page.evaluate(() => window.__asks); + check( + oldAsks.length >= 1 && oldAsks.every((a) => Object.keys(a).join() === 'force'), + `a provider without search/sorts is asked list({force}) only — no q, no sort (${JSON.stringify(oldAsks)})` + ); + check(oldAsks.length === 1, `...and a query change does not re-list it (${oldAsks.length} calls)`); + check((await A.page.locator('#community-sorts').count()) === 0, '...and it gets no sort chips'); + await A.page.locator('#templates-search').fill(''); + await A.page.waitForTimeout(700); // the cleared field reaches the query after the debounce — or the next provider's first list() still carries "water" + + // ------------------------------------------- 3. a provider with search + sorts + await A.page.evaluate(() => { + window.__asks = []; + /** rows the "server" knows: one per query word, so the answer shows WHICH query it answered */ + window.__stores.cloudHooks.setCommunityProvider({ + search: true, + sorts: [ + { id: 'featured', label: 'Featured' }, + { id: 'recent', label: 'Recent' }, + { id: 'top', label: 'Top' } + ], + list: async (ask) => { + window.__asks.push(ask); + const q = ask.q || ''; + // the SLOW query: "rock" answers after 1.6 s, everything else after 0.5 s + await new Promise((r) => setTimeout(r, q === 'rock' ? 1600 : 500)); + if (q === 'nothing') return []; + const tag = q || 'all'; + return [{ slug: 's-' + tag.replace(/\s+/g, '-') + '-' + (ask.sort || 'default'), title: 'Answer ' + tag, tags: [tag], sceneUrl: 'https://example.com/x.tpscene' }]; + }, + load: async () => true + }); + }); + await h.eventually(() => slugs(A.page), (s) => s.join() === 's-all-default', 'premise: the searching provider lists its default answer'); + const chips = await A.page.evaluate(() => [...document.querySelectorAll('#community-sorts [data-sort]')].map((b) => ({ id: b.getAttribute('data-sort'), on: b.getAttribute('aria-pressed') }))); + check(chips.map((c) => c.id).join() === 'featured,recent,top' && chips[0].on === 'true' && chips[1].on === 'false', `the provider's sorts render as chips, the first active (${JSON.stringify(chips)})`); + const firstAsk = (await A.page.evaluate(() => window.__asks))[0]; + check(Object.keys(firstAsk).join() === 'force', `the first list() carries no q / sort (nothing typed, default sort) (${JSON.stringify(firstAsk)})`); + + await A.page.evaluate(() => (window.__asks = [])); + await A.page.locator('#templates-search').fill('rocket'); + await h.eventually(() => A.page.locator('#community-searching').isVisible(), (v) => v === true, 'a search re-list shows "Searching…"'); + check((await A.page.locator('#templates-modal .animate-pulse').count()) === 0, 'the grid is NOT replaced by skeletons while searching'); + await h.eventually(() => slugs(A.page), (s) => s.join() === 's-rocket-default', 'the query reaches the provider and its answer is shown'); + const rocketAsks = await A.page.evaluate(() => window.__asks); + check(rocketAsks.length === 1 && rocketAsks[0].q === 'rocket' && !('sort' in rocketAsks[0]), `the provider was asked ONCE, debounced, with q (${JSON.stringify(rocketAsks)})`); + check(!(await A.page.locator('#community-searching').isVisible()), '"Searching…" goes once the answer lands'); + + await A.page.evaluate(() => (window.__asks = [])); + await A.page.locator('#community-sorts [data-sort="top"]').click(); + await h.eventually(() => slugs(A.page), (s) => s.join() === 's-rocket-top', 'a sort chip re-lists with sort + the current query'); + const topAsk = (await A.page.evaluate(() => window.__asks)).at(-1); + check(topAsk.sort === 'top' && topAsk.q === 'rocket', `list() got {q, sort: top} (${JSON.stringify(topAsk)})`); + check((await A.page.locator('#community-sorts [data-sort="top"]').getAttribute('aria-pressed')) === 'true', 'the Top chip is pressed'); + await A.page.locator('#community-sorts [data-sort="featured"]').click(); + await h.eventually(() => slugs(A.page), (s) => s.join() === 's-rocket-default', 'picking the FIRST sort goes back to the default'); + check(!('sort' in (await A.page.evaluate(() => window.__asks)).at(-1)), '...and sends no sort key'); + + // an older query's late answer must not win: "rock" (1.6 s) then "rocks" (0.5 s) + await A.page.locator('#templates-search').fill('rock'); + await A.page.waitForTimeout(450); // the debounce fires "rock" + await A.page.locator('#templates-search').fill('rocks'); + await h.eventually(() => slugs(A.page), (s) => s.join() === 's-rocks-default', 'the newer query answers first'); + await A.page.waitForTimeout(1600); + const rockAsked = (await A.page.evaluate(() => window.__asks)).some((a) => a.q === 'rock'); + check(rockAsked, 'counterfactual premise: the slow "rock" query WAS sent (and has answered by now)'); + check((await slugs(A.page)).join() === 's-rocks-default', 'the late "rock" answer did NOT overwrite the "rocks" grid'); + + await A.page.locator('#templates-search').fill('nothing'); + await h.eventually(() => A.page.locator('#community-empty').isVisible(), (v) => v === true, 'a server answer of [] shows the empty card'); + check(/No scenes match “nothing”/.test((await A.page.locator('#community-empty').textContent()) || ''), '...worded for the search, not "be the first"'); + + await A.page.evaluate(() => window.__stores.cloudHooks.setCommunityProvider(null)); + await A.ctx.close(); + + // ------------------------------------------ 4. phones: Find N6 folded + unfolded + const SHOTS = process.env.SHOTS || ''; + for (const [name, context] of [ + ['folded', { viewport: { width: 390, height: 896 }, deviceScaleFactor: 2.9, hasTouch: true, isMobile: true }], + ['unfolded', { viewport: { width: 770, height: 850 }, deviceScaleFactor: 2.9, hasTouch: true, isMobile: true }] + ]) { + const P = await h.setupPage(browser, name, { context, storage: { toursSeen: '{"editor":true,"editor-touch":true}' } }); + await P.page.route('**/raw.githubusercontent.com/theprototype-app/community-gallery/**', (route) => route.fulfill({ json: { entries: GALLERY } })); + await P.page.evaluate((rows) => { + window.__stores.cloudHooks.setCommunityProvider({ + search: true, + sorts: [ + { id: 'featured', label: 'Featured' }, + { id: 'recent', label: 'Recent' }, + { id: 'top', label: 'Top' } + ], + list: async () => rows, + load: async () => true + }); + window.__stores.templatesModalOpen.set(true); + }, GALLERY); + await P.page.locator('#templates-tab-community').click(); + await h.eventually(() => slugs(P.page), (s) => s.length === 3, `${name}: premise — the provider rows show`); + const m = await P.page.evaluate(() => { + const r = (sel) => document.querySelector(sel)?.getBoundingClientRect(); + const field = document.querySelector('#templates-search')?.closest('.sf')?.getBoundingClientRect(); + const sorts = r('#community-sorts'); + const body = document.querySelector('#templates-modal .tpl-body'); + return { + fieldH: field?.height ?? 0, + fieldRight: field?.right ?? 0, + sortsRight: sorts?.right ?? 0, + sortsBottom: sorts?.bottom ?? 0, + vw: window.innerWidth, + vh: window.innerHeight, + overflowX: document.documentElement.scrollWidth - document.documentElement.clientWidth, + bodyOverflowX: body ? body.scrollWidth - body.clientWidth : -1, + chipH: document.querySelector('#community-sorts [data-sort]')?.getBoundingClientRect().height ?? 0 + }; + }); + check(m.fieldH >= 44, `${name}: the search field is a 44 px touch target (${m.fieldH})`); + check(m.chipH >= 44, `${name}: the sort chips are 44 px touch targets (${m.chipH})`); + check(m.fieldRight <= m.vw && m.sortsRight <= m.vw && m.sortsBottom <= m.vh, `${name}: field and sort chips fit the screen (${JSON.stringify(m)})`); + check(m.overflowX <= 0 && m.bodyOverflowX <= 0, `${name}: no sideways scroll (page ${m.overflowX}, modal ${m.bodyOverflowX})`); + await P.page.locator('#templates-search').tap(); + await P.page.keyboard.type('water'); + await h.eventually(() => slugs(P.page), (s) => s.join() === 'g-boat', `${name}: typing on touch filters the grid`); + await P.page.locator('#community-sorts [data-sort="top"]').tap(); + await h.eventually(() => P.page.locator('#community-sorts [data-sort="top"]').getAttribute('aria-pressed'), (v) => v === 'true', `${name}: a tap on Top selects it`); + if (SHOTS) await P.page.screenshot({ path: require('path').join(SHOTS, `community-search-${name}.png`) }); + await P.ctx.close(); + } + await h.finish(browser); +}); diff --git a/tests/unit/communitySearch.test.js b/tests/unit/communitySearch.test.js new file mode 100644 index 000000000..2d34b3a0f --- /dev/null +++ b/tests/unit/communitySearch.test.js @@ -0,0 +1,48 @@ +// 76 FW3: the Templates modal's search — every word must appear in title, description, +// author or tags; the provider seam's query is normalized before anything reads it. +import { describe, expect, it } from 'vitest'; +import { matchesQuery, normQuery, queryWords } from '../../src/lib/communitySearch.js'; + +const race = { title: 'Race', description: 'Laps against the clock', author: '@maker-one', tags: ['space', 'game'] }; + +describe('normQuery', () => { + it('trims, single-spaces, lower-cases and caps at 60', () => { + expect(normQuery(' Space RACE \n')).toBe('space race'); + expect(normQuery('x'.repeat(80))).toHaveLength(60); + expect(normQuery(null)).toBe(''); + expect(normQuery(undefined)).toBe(''); + }); + it('never ends on a space after the cap', () => { + expect(normQuery('a'.repeat(59) + ' b')).toBe('a'.repeat(59)); + }); +}); + +describe('queryWords', () => { + it('splits, drops leading # and @, dedupes, keeps at most 6', () => { + expect(queryWords('#space @maker space')).toEqual(['space', 'maker']); + expect(queryWords('a b c d e f g h')).toHaveLength(6); + expect(queryWords('# @')).toEqual([]); + }); +}); + +describe('matchesQuery', () => { + it('an empty query matches everything', () => { + expect(matchesQuery(race, '')).toBe(true); + expect(matchesQuery({}, ' ')).toBe(true); + }); + it('finds a word beyond the title: description, author, tags', () => { + expect(matchesQuery(race, 'clock')).toBe(true); + expect(matchesQuery(race, '@maker-one')).toBe(true); + expect(matchesQuery(race, '#space')).toBe(true); + expect(matchesQuery(race, 'SPACE race')).toBe(true); + }); + it('every word must match (AND), substrings count', () => { + expect(matchesQuery(race, 'space boat')).toBe(false); + expect(matchesQuery(race, 'lap')).toBe(true); + }); + it('tolerates missing and non-string fields', () => { + expect(matchesQuery({ title: 'Boat', tags: [3, null, 'water'] }, 'water')).toBe(true); + expect(matchesQuery({ title: 'Boat', tags: 'water' }, 'water')).toBe(false); + expect(matchesQuery(null, 'x')).toBe(false); + }); +}); diff --git a/tests/unit/contentIndex.test.js b/tests/unit/contentIndex.test.js index 890d87b27..7452f954c 100644 --- a/tests/unit/contentIndex.test.js +++ b/tests/unit/contentIndex.test.js @@ -116,7 +116,8 @@ describe('an app update drops the index memos', () => { const templates = source('lib/sceneTemplates.js'); expect(templates).toMatch(/onContentStale\(\(\) => \{\s*templatesStale = true;\s*communityStale = true;/); expect(templates).toContain("if (!force && !templatesStale && (state === 'ready'"); - expect(templates).toContain("if (!force && !communityStale && (state === 'ready'"); + // 76 FW3: the memo also re-opens when the query/sort it answers changed (`requery`) + expect(templates).toContain("if (!force && !requery && !communityStale && (state === 'ready'"); expect(source('lib/moduleGallery.js')).toMatch(/onContentStale\(\(\) => \{\s*loaded = false;/); expect(source('lib/packs.js')).toMatch(/onContentStale\(\(\) => \{\s*for \(const name of Object\.keys\(itemCache\)\) delete itemCache\[name\];/); });