diff --git a/internal/check-poster-wall-host-seams.mjs b/internal/check-poster-wall-host-seams.mjs index 1824f4f5e..aaa60792f 100644 --- a/internal/check-poster-wall-host-seams.mjs +++ b/internal/check-poster-wall-host-seams.mjs @@ -401,6 +401,32 @@ try { if (!child || textOf(child).trim() !== 'gate-loader-ok') fail(`L: the child .vue did not render through the loader (found ${child ? JSON.stringify(textOf(child)) : 'no .gate-child'})`) app.unmount() } + + // ── M: items ordered count DESC, then a FIXED-locale (en-US) name tie-break ─────────────────────── + // The Driver's "countries with the most unreached people groups first". The fixture's INPUT order puts + // the name-later tie item (Zeta) FIRST and the highest-count item (Zzz) LAST, so only a real + // (count desc, then name asc en-US) sort yields Zzz > Alpha > Zeta. POISON: drop the localeCompare term + // and the stable count-only sort renders Zeta before Alpha (input order) — this arm then FAILS. + { + const w = await mountWall({ groupBy: () => 'G', sectionTotal: () => 19 }, + [{ id: 'm-zeta', label: 'Zeta', count: 5 }, { id: 'm-alpha', label: 'Alpha', count: 5 }, { id: 'm-big', label: 'Zzz', count: 9 }]) + // the card's name renders as an attribute (the thumbnail alt / aria-label), so read text AND string props + const cardText = (card) => { let s = textOf(card); walk(card, (n) => { for (const v of Object.values(n.props || {})) if (typeof v === 'string') s += ' ␟' + v }); return s } + const order = w.cards.map(cardText) + const seq = ['Zzz', 'Alpha', 'Zeta'] + const ok = w.cards.length === 3 && seq.every((name, i) => order[i] && order[i].includes(name)) + if (!ok) fail(`M: expected cards ordered ${seq.join(' > ')} (count desc, then name asc en-US), got ${JSON.stringify(order.map((t) => t.replace(/\s+/g, ' ').trim().slice(0, 20)))}`) + w.app.unmount() + // count-less gallery (the Poster Design template gallery builds items with NO count): the tie-break must NOT + // fire, so the AUTHORED input order is kept. POISON: an unconditional tie-break alphabetises it (Alpha, Beta, Gamma). + const w2 = await mountWall({ groupBy: () => 'G', sectionTotal: () => 0 }, + [{ id: 't-gamma', label: 'Gamma' }, { id: 't-alpha', label: 'Alpha' }, { id: 't-beta', label: 'Beta' }]) + const order2 = w2.cards.map(cardText) + const seq2 = ['Gamma', 'Alpha', 'Beta'] + const ok2 = w2.cards.length === 3 && seq2.every((name, i) => order2[i] && order2[i].includes(name)) + if (!ok2) fail(`M: a count-less gallery must keep its authored order ${seq2.join(' > ')}, got ${JSON.stringify(order2.map((t) => t.replace(/\s+/g, ' ').trim().slice(0, 20)))}`) + w2.app.unmount() + } } catch (e) { fail(`threw: ${e?.stack || e}`) } finally { @@ -412,5 +438,5 @@ if (failures.length) { console.error(`POSTER-WALL-HOST-SEAMS: FAIL (${failures.length})\n - ${failures.join('\n - ')}`) process.exitCode = 1 } else { - console.log(`POSTER-WALL-HOST-SEAMS: PASS (12 arms, ${buttonsSeen} stickers rendered; defaults unchanged: the Show-on-map toggle and the stored webp; headerAction emits, previewSrc is lazy, one at a time, cached and bounded)`) + console.log(`POSTER-WALL-HOST-SEAMS: PASS (13 arms, ${buttonsSeen} stickers rendered; defaults unchanged: the Show-on-map toggle and the stored webp; headerAction emits, previewSrc is lazy, one at a time, cached and bounded; items sort count-desc then en-US name tie-break)`) } diff --git a/library/components/poster/PosterWall.vue b/library/components/poster/PosterWall.vue index de0946649..a567ee99a 100644 --- a/library/components/poster/PosterWall.vue +++ b/library/components/poster/PosterWall.vue @@ -360,7 +360,11 @@ function onPreviewError(id) { } // Sections ordered by TOTAL count (largest first), items within a section by their -// own count — the densest prayer need leads at BOTH levels. +// own count — the densest prayer need leads at BOTH levels. Equal-count items break +// the tie by name with a FIXED locale ('en-US'), so a sheet's order never depends on +// the reader's browser. The tie-break applies ONLY when BOTH items carry a count: +// a count-less wall (the Poster Design template gallery) keeps its AUTHORED order, +// since the sort is stable and every item ties. const sections = computed(() => { const byGroup = new Map() for (const it of props.items || []) { @@ -372,7 +376,7 @@ const sections = computed(() => { return [...byGroup.entries()] .map(([name, items]) => ({ name, - items: items.slice().sort((a, b) => (b.count || 0) - (a.count || 0)), + items: items.slice().sort((a, b) => (b.count || 0) - (a.count || 0) || (a.count != null && b.count != null ? String(a.label || a.id || '').localeCompare(String(b.label || b.id || ''), 'en-US') : 0)), total: props.sectionTotal ? props.sectionTotal(name, items) : items.reduce((n, x) => n + (x.count || 0), 0), })) .sort((a, b) => b.total - a.total) diff --git a/library/components/poster/types/countryPosterType.js b/library/components/poster/types/countryPosterType.js index 5c613bff8..e09c9b67a 100644 --- a/library/components/poster/types/countryPosterType.js +++ b/library/components/poster/types/countryPosterType.js @@ -8,7 +8,8 @@ * a country with no region is 'other'. */ import data from '@map/data/country-posters.json' -import { makeCountrySheet, sheetWithheld } from '@map/packet/countrySheet.js' +import { makeCountrySheet, sheetWithheld, largeSheet } from '@map/packet/countrySheet.js' +import { isPhonePrint } from '@map/composables/printInPlace.js' import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js' import { keyedError } from '@map/i18n/keyedError.js' import { countryName } from '@map/utils/countryName.js' @@ -18,10 +19,14 @@ export const regionKey = (s) => String(s || '').normalize('NFD').replace(/[\u030 .replace(/['’.]/g, '').replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'other' const TOO_MANY = 'Too many people groups to fit on one poster legibly — a solution is in progress.' const inScope = (scope) => (c) => regionKey(c.region) === regionKey(scope) +// A phone cannot draw a desktop-only large sheet (same rule as the Country Posters wall, country-poster-wall.vue). +// Evaluated at call time so the type is correct in whatever runtime context a host mounts it. +const phoneBlocked = (c) => isPhonePrint() && Boolean(largeSheet(c)?.desktopOnly) export default { scopes: () => [...new Set(data.countries.map((c) => regionKey(c.region)))], items(scope, locale) { + // Order is applied once in PosterWall (count desc + stable name tie-break), so this returns unordered rows. const rows = data.countries.filter(inScope(scope)) if (/^en\b/i.test(locale || 'en')) return rows return rows.map((c) => ({ ...c, label: countryName(c.iso3, locale) || c.label })) @@ -39,10 +44,12 @@ export default { const byId = new Map(data.countries.map((c) => [c.id, c])) return { // a withheld whole-country poster withholds its list, packet and cards too (countrySheet.js WITHHELD) - records: (c) => (sheetWithheld(c) ? Promise.reject(keyedError('messages.countryPosterTooMany', TOO_MANY)) : recordsFor({ type: 'country', values: [c.iso3] })), + records: (c) => (sheetWithheld(c) ? Promise.reject(keyedError('messages.countryPosterTooMany', TOO_MANY)) + : phoneBlocked(c) ? Promise.reject(keyedError('poster.phoneLimitAll', 'This needs a desktop or laptop computer.')) + : recordsFor({ type: 'country', values: [c.iso3] })), // the sheet gets the data's own record (English, the series index by id), as on the wall sheet: (card, format) => sheet(byId.get(card.id) || card, format), - unsupported: (c) => (sheetWithheld(c) || c.unsupportedReason ? t('messages.countryPosterTooMany') : null), + unsupported: (c) => (sheetWithheld(c) ? t('messages.countryPosterTooMany') : phoneBlocked(c) ? t('poster.phoneLimitAll') : c.unsupportedReason ? t('messages.countryPosterTooMany') : null), unit: 'UPG', itemCountKey: 'regions.countryCount', eventName: 'doxa-country-posters:show-on-map', downloadPrefix: 'doxa-country', cardStocks: true, } }, diff --git a/plugin-designs/country-posters/scripts/check-poster-section.mjs b/plugin-designs/country-posters/scripts/check-poster-section.mjs index 23db06b43..886d58a31 100644 --- a/plugin-designs/country-posters/scripts/check-poster-section.mjs +++ b/plugin-designs/country-posters/scripts/check-poster-section.mjs @@ -68,6 +68,22 @@ async function arms({ types, country, element, sectionSrc }) { const local = new Set(['sec', 'z', 'item', 'n', 's', 'v', 'el', 'true', 'false', 'null', 'String', 'Number', '$event', 'in', 'label', 'zIndex']) const missing = [...used].filter((x) => !local.has(x) && !injected.has(x)) if (missing.length) fails.push(`E: PosterSection.vue uses names it does not inject: ${missing.join(', ')}`) + // F: a phone cannot print a desktop-only large sheet THROUGH THE TYPE — the same rule as country-poster-wall.vue, + // mirrored in countryPosterType so the delegating regions list and any block it too. The only + // desktop-only large sheet is India's (withheld today), so the fixture is a NON-withheld India (indiaState => not + // withheld => largeSheet.desktopOnly). POISON: drop the phone rule and records() resolves / unsupported() is null. + { + const savedNav = globalThis.navigator + globalThis.navigator = { userAgent: 'iPhone', maxTouchPoints: 0 } + try { + const act = country.actions({ assetsBase: () => '', apiBase: () => '', lang: () => 'en', profileConfig: null, t: (k) => k }) + const bigIndia = { id: 'country-ind-x', iso3: 'IND', indiaState: true, label: 'India', count: 855 } + if (act.unsupported(bigIndia) !== 'poster.phoneLimitAll') fails.push(`F: on a phone, unsupported() of a desktop-only large sheet should read poster.phoneLimitAll, got ${JSON.stringify(act.unsupported(bigIndia))}`) + let why = '' + try { await act.records(bigIndia); why = 'resolved' } catch (e) { why = e && (e.i18nKey || e.message) } + if (why !== 'poster.phoneLimitAll') fails.push(`F: on a phone, records() of a desktop-only large sheet should reject poster.phoneLimitAll, got ${JSON.stringify(why)}`) + } finally { if (savedNav === undefined) delete globalThis.navigator; else globalThis.navigator = savedNav } + } return fails } @@ -95,7 +111,7 @@ try { } else { const f = await arms(real) if (f.length) { f.forEach((x) => console.log('FAIL ' + x)); code = 1 } - else console.log(`PASS check:poster-section — the 'country' type; ${DXL.length} DX Life scopes cover every country once (and their other spellings); withheld India stays withheld; the element's attributes; PosterSection injects every name it uses`) + else console.log(`PASS check:poster-section — the 'country' type; ${DXL.length} DX Life scopes cover every country once (and their other spellings); withheld India stays withheld; a phone blocks a desktop-only large sheet (poster.phoneLimitAll); the element's attributes; PosterSection injects every name it uses`) } } finally { await server.close() } process.exit(code)