Sort poster cards by most people groups first; block desktop-only sheets on a phone
PosterWall orders items by descending count with a stable, fixed-locale (en-US) name tie-break for equal counts — BUT only when both items carry a count, so a count-less wall (the Poster Design template gallery) keeps its authored order (the sort is stable). Every host with counts (the Country Posters wall and every dxm-poster-section: DX Life /regions, the Regions dashboard, Poster Sections) shows the densest prayer need first, in an order that never depends on the reader's browser. countryPosterType mirrors the Country Posters wall's phone rule: on a phone a desktop-only large sheet (India's 60x96) is blocked — records reject poster.phoneLimitAll and the card reads the same message — so the delegating regions list and any poster-section embed cannot print it on a phone. Gates (poison-proven): check-poster-wall-host-seams arm M — a counted tie fixture (mis-sorts if the tie-break is removed) AND a count-less gallery fixture (alphabetises if the tie-break is unconditional); check-poster-section arm F — a phone + a desktop-only large sheet via the type is blocked.
This commit is contained in:
parent
fcd6d51910
commit
b09c14ac9f
4 changed files with 60 additions and 7 deletions
|
|
@ -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)`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 <dxm-poster-section> 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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue