diff --git a/data/posters/country-ind.webp b/data/posters/country-ind.webp new file mode 100644 index 000000000..ddd04d40b Binary files /dev/null and b/data/posters/country-ind.webp differ diff --git a/package.json b/package.json index baf075a4a..81bd3aadf 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ "check:composites-mount": "node internal/check-composites-mount.mjs", "check:country-names": "node internal/check-country-names.mjs", "check:country-sheet-one-path": "node plugin-designs/country-posters/scripts/check-country-sheet-one-path.mjs", + "check:side-lanes": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs", + "check:side-lanes:self-test": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs --self-test", "check:country-sheet-one-path:self-test": "node plugin-designs/country-posters/scripts/check-country-sheet-one-path.mjs --self-test", "check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs", "check:coverage-consistent:chained": "VERIFY_MAX_AGE_DAYS=36500 node internal/check-gospel-coverage-consistent.mjs", @@ -348,7 +350,7 @@ "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight", - "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:wall-photo-weight check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue", + "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:wall-photo-weight check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", @@ -455,6 +457,8 @@ "check:composites-mount": "every capability-4/5 composite's OWN setup() and mounted hooks run without throwing, in en and ar, headless (no browser): vite loads each composite as its bundle entry defines it (configureApp, and ProfileLoader's provides where the entry mounts ProfileLoader), and @vue/runtime-core mounts it on a no-op renderer. Exists because 08f89f55 shipped two composites calling getRuntimeLocale/detectLocale/RTL_LOCALES with no import: the build was green, setup threw a ReferenceError in every locale, and Big Picture and Simple Research rendered nothing. Proves setup runs; cannot prove the children render.", "check:country-names": "every ISO 3166-1 alpha-3 our data uses must resolve to a localized country name. Guards the JOIN, not Intl: library/data/iso3-to-alpha2.json drifting from library/data/countries.json makes a country render as a bare code, which still looks like a value. Checks en/ar/zh (ar is our only RTL locale; zh makes an untranslated Latin code unmistakable) and asserts BOTH directions of drift. Carries two negative arms — an unassigned code and a malformed one — and FAILS if either resolves, because a coverage count cannot otherwise tell a hit from a lookup that returns its input.", "check:country-sheet-one-path": "a country sheet, and an Indian state or union territory sheet, each draw through ONE path (@map/packet/countrySheet.js; country-posters/src/indiaStateSheet.js), so one place cannot print two posters. Parses (comments cannot count) plugin sources: a direct generateCountrySheet FAILS anywhere, a direct generateSheet FAILS inside country-posters outside the state helper, every makeCountrySheet call must pass `countries`, and the known callers must be found. Named data answers: seriesFor over country-posters.json (India 1 of 1, each 3x3 country N of 173, an absent code no series, exactly one no-sheet country); over the served india-states.json + gb-IND-ADM1.geojson: 36 divisions, the 34 with groups numbered 1..34 each with an outline, the rest named, the shared ADM1 never mutated.", + "check:side-lanes": "the large side-lane sheets (60x96, 66x66, 36x60) through the real poster-render.js, on a SEEDED worst case (855 groups, stacked duplicates, tight clusters, real and placeholder photos): every name drawn exactly once, leader crossings 0 (re-measured from the SVG, and equal to the renderer's own count), no overflow rows, photos only where a real one exists and the format allows them.", + "check:side-lanes:self-test": "a poster-render.js copy WITHOUT the lane clamp must make check:side-lanes' crossing arm FAIL on the same seeded data (it does: 36 crossings), so the arm is shown able to fail.", "check:country-sheet-one-path:self-test": "plants each defect check:country-sheet-one-path guards (a direct generateCountrySheet call, makeCountrySheet without countries, the old guard that skipped India, a position given to an absent code) and requires each to FAIL; a comment naming generateCountrySheet must NOT.", "check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.", "check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.", diff --git a/plugin-designs/country-posters/assets/previews/country-ind.webp b/plugin-designs/country-posters/assets/previews/country-ind.webp new file mode 100644 index 000000000..2905670e7 Binary files /dev/null and b/plugin-designs/country-posters/assets/previews/country-ind.webp differ diff --git a/plugin-designs/country-posters/country-poster-wall.vue b/plugin-designs/country-posters/country-poster-wall.vue index 3098734cb..1685db618 100644 --- a/plugin-designs/country-posters/country-poster-wall.vue +++ b/plugin-designs/country-posters/country-poster-wall.vue @@ -37,7 +37,7 @@ */ import { computed, inject, ref, watch } from 'vue' import PosterWall from '@map/components/poster/PosterWall.vue' -import { useOptionalT, useOptionalI18n } from '@map/composables/useOptionalT.js' +import { useOptionalT, useOptionalI18n, pickPluralForm, fillParams } from '@map/composables/useOptionalT.js' // A country's name in the reader's language (Intl, joined from our ISO-3 codes). import { countryName } from '@map/utils/countryName.js' import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js' @@ -149,7 +149,10 @@ const recordsFor = createPeopleGroupRecords({ lang: () => getRuntimeLocale() || detectLocale(), profileConfig, }) -const countryRecords = (c) => (c.indiaState +// ★ THE WHOLE-INDIA POSTER IS THE POSTER ONLY: no prayer list, packet or cards (855 groups would make a book), so its +// card's rows are empty. Its sheet still draws every group: makeCountrySheet fetches them itself. +const isWholeIndia = (c) => !c.indiaState && String(c.iso3 || '').toUpperCase() === 'IND' +const countryRecords = (c) => (isWholeIndia(c) ? Promise.resolve([]) : c.indiaState ? (c.slugs.length ? recordsFor({ type: 'pg_slugs', values: c.slugs }) : Promise.resolve([])) : recordsFor({ type: 'country', values: [c.iso3] })) // ★ GENERATED AT THE CLICK, through the ONE shared path (@map/packet/countrySheet.js, which the Regions @@ -165,7 +168,19 @@ const sheetForRecord = makeCountrySheet({ assetsBase: () => assetsBase.value, countries: data.countries || [], }) -const countryOnlySheet = (card, format) => sheetForRecord(byId.get(card.id) || card, format) +// ★ INDIA PRINTS AS ONE LARGE SIDE-LANE SHEET (60x96: every group named beside the map, one leader per cluster), +// whatever paper the wall asks for, until the shared size picker also offers 66x66 and 36x60. Its facts row names +// the true count of its divisions, in English like the rest of the sheet, from the same plural key as the wall head. +const INDIA_FORMAT = '60x96' +const indiaAdminAreas = () => { + const n = (indiaStates.value || []).length + return n ? fillParams(pickPluralForm(tStatic('countryPosters.indiaItemCount', 'en') || '', n, 'en'), { n }) : undefined +} +const countryOnlySheet = (card, format) => { + const rec = byId.get(card.id) || card + if (isWholeIndia(rec) && format !== 'packet') return sheetForRecord({ ...rec, adminAreas: indiaAdminAreas() }, INDIA_FORMAT) + return sheetForRecord(rec, format) +} // A state's sheet through its own ONE path (src/indiaStateSheet.js), in India's hue and the indiaState series. const stateSheet = makeIndiaStateSheet({ records: recordsFor, assetsBase: () => assetsBase.value, states: () => indiaStates.value, region: INDIA_REGION, @@ -223,7 +238,7 @@ const collapsedBy = (sec) => sec.total < 60 // unsupportedReason is an English note (and a flag), not display text. The card already states the // count above it, so the sentence needs no number and no plural agreement. // A requested country with no poster at all says so in regions-list.vue's words (the regions catalogue). -const unsupported = (c) => (c.noGroups ? t('countryPosters.indiaStateNoGroups') : c.noPoster ? tRegions('No poster has been generated for this country yet.') : c.unsupportedReason ? t('messages.countryPosterTooMany') : null) +const unsupported = (c) => (c.noGroups ? t('countryPosters.indiaStateNoGroups') : c.noPoster ? tRegions('No poster has been generated for this country yet.') : c.unsupportedReason && !isWholeIndia(c) ? t('messages.countryPosterTooMany') : null) // ★ THROUGH THE CATALOGUE, like the UPG-100 wall: the heading, the tag, the intro and the counts read // in the page's language. The intro keeps its one emphasis as three plain keys (A, the emphasis, B) diff --git a/plugin-designs/country-posters/scripts/check-side-lanes.mjs b/plugin-designs/country-posters/scripts/check-side-lanes.mjs new file mode 100644 index 000000000..c0ea11328 --- /dev/null +++ b/plugin-designs/country-posters/scripts/check-side-lanes.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// check:side-lanes — the large side-lane sheets (60x96 / 66x66 / 36x60) name every group once, horizontally, with +// leaders that never cross, on a DETERMINISTIC worst case: a seeded synthetic country of 855 groups with stacked +// duplicates and tight clusters (district geocodes are like that), long and short names, real and placeholder photos. +// Rendered through the real poster-render.js (renderPoster), so it measures what prints. +// +// Asserts per format: every name drawn exactly once; leader crossings 0 (the renderer's own count, re-measured here +// from the SVG); no overflow rows; photos = the pins with a real photo (60x96/66x66) or none (36x60). +// --self-test: a renderer copy WITHOUT the lane clamp must FAIL the crossing arm (else the arm proves nothing). +// Exit 0 pass, 1 fail. +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const J = (p) => JSON.parse(readFileSync(join(ROOT, p), 'utf8')) +const RENDER = join(ROOT, 'library/packet/poster-render.js') + +function synthetic(n = 855) { + let seed = 20261001 + const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648) + const syll = ['ka', 'ra', 'mu', 'thi', 'ban', 'dho', 'li', 'sa', 'ga', 'pa', 'rwa', 'kot', 'ni', 'al', 'u'] + const centres = Array.from({ length: 60 }, () => [68 + rnd() * 28, 8 + rnd() * 27]) + const pins = [] + for (let i = 0; i < n; i++) { + const c = centres[Math.floor(rnd() * centres.length)] + const stacked = rnd() < 0.15 && pins.length // an exact duplicate of an earlier position + const lng = stacked ? pins[pins.length - 1].lng : c[0] + (rnd() - 0.5) * 1.6 + const lat = stacked ? pins[pins.length - 1].lat : c[1] + (rnd() - 0.5) * 1.6 + const words = 1 + Math.floor(rnd() * (rnd() < 0.05 ? 5 : 2)) + const name = Array.from({ length: words }, () => Array.from({ length: 2 + Math.floor(rnd() * 3) }, () => syll[Math.floor(rnd() * syll.length)]).join('')).join(' ') + .replace(/^./, (m) => m.toUpperCase()) + ` ${i}` + const image_url = rnd() < 0.18 ? `https://example.invalid/p/${i}.jpg` : 'https://example.invalid/no-photo-images/asia.jpg' + pins.push({ name, lat, lng, slug: `g-${i}`, country_code: 'IND', population: 1000 + i, image_url }) + } + return pins +} + +async function measure(createPosterGenerator, format, pins) { + const adm0 = J('public/dx-maps/assets/boundaries/world-adm0.geojson') + const series = J('library/scripts/posters/series.json') + const zone = { id: 'country-ind', zoneKey: 'country-ind', label: 'India', count: pins.length, color: '#d4a640', adm0: ['IND'], selector: { type: 'country_codes', values: ['IND'] } } + const zoneFC = { type: 'FeatureCollection', features: adm0.features.filter((f) => f.properties?.iso3 === 'IND') } + const gen = createPosterGenerator({ env: { FORMAT: format, SPARSE: '1', POSTER_DATE: '2026-10-01' }, SERIES_DEF: series.country, ISO3_NAME: { IND: 'India' }, adm0, adm1Ind: null, adm1Chn: null, nePlaces: [], series: { totalGroups: pins.length, totalLists: 1, index: 1 } }) + const out = await gen.renderPoster(zone, pins, zoneFC) + const html = out.html || '' + const names = [...html.matchAll(/]*>([^<]*)<\/text>/g)].map((m) => m[1]) + const photos = (html.match(/ m[1].split('L').map((q) => q.trim().split(' ').map(Number))) + const segs = polys.map((pl) => pl.slice(1).map((q, k) => [pl[k], q])) + const sx = (p, q, r) => (r[1] - p[1]) * (q[0] - p[0]) - (q[1] - p[1]) * (r[0] - p[0]) + const X = (a, b) => (sx(a[0], a[1], b[0]) > 0) !== (sx(a[0], a[1], b[1]) > 0) && (sx(b[0], b[1], a[0]) > 0) !== (sx(b[0], b[1], a[1]) > 0) + let crossings = 0 + for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) if (segs[i].some((a) => segs[j].some((b) => X(a, b)))) crossings++ + return { stats: out.sideLanes, names, photos, crossings, leaders: polys.length } +} + +function judge(format, m, pins, wantPhotos) { + const fails = [] + const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + const want = new Map(pins.map((p) => [esc(p.name), 0])) + for (const n of m.names) if (want.has(n)) want.set(n, want.get(n) + 1) + const missing = [...want].filter(([, v]) => v === 0).length, dup = [...want].filter(([, v]) => v > 1).length + if (!m.stats) fails.push(`${format}: renderPoster returned no sideLanes stats (not a side-lane format?)`) + if (m.names.length !== pins.length || missing || dup) fails.push(`${format}: ${m.names.length} names drawn for ${pins.length} groups (${missing} missing, ${dup} duplicated)`) + if (m.crossings !== 0) fails.push(`${format}: ${m.crossings} leader crossings (re-measured from the SVG)`) + if (m.stats && m.stats.crossings !== m.crossings) fails.push(`${format}: the renderer reports ${m.stats.crossings} crossings, the SVG has ${m.crossings}`) + if (m.stats && m.stats.overflowRows) fails.push(`${format}: ${m.stats.overflowRows} rows overflow the band`) + const real = pins.filter((p) => !/no-photo-images/.test(p.image_url)).length + if (m.photos !== (wantPhotos ? real : 0)) fails.push(`${format}: ${m.photos} photos drawn, expected ${wantPhotos ? real : 0}`) + return fails +} + +const selfTest = process.argv.includes('--self-test') +const src = readFileSync(RENDER, 'utf8') +const pins = synthetic() +if (selfTest) { + // POISON: drop the lane clamp (each lane at its own pin's height); stubs/runs/fans must then cross on this data + const a = 'let y = Math.min(Math.max(c.y, lo + LANE_GAP), hi - LANE_GAP);' + if (!src.includes(a)) { console.log('NOT MEASURED: the lane clamp line moved; update the poison'); process.exitCode = 75 } + else { + const poisoned = src.replace(a, 'let y = c.y; lo = -Infinity; hi = Infinity;') + const { createPosterGenerator } = await import('data:text/javascript;base64,' + Buffer.from(poisoned).toString('base64')) + const m = await measure(createPosterGenerator, '60x96', pins) + const f = judge('60x96', m, pins, true) + console.log(f.some((x) => /crossings/.test(x)) ? `✓ caught: no lane clamp -> ${m.crossings} crossings` : '✗ MISSED: the crossing arm stayed green without the lane clamp') + process.exitCode = f.some((x) => /crossings/.test(x)) ? 0 : 1 + } +} else { + const { createPosterGenerator } = await import(RENDER) + const fails = [], lines = [] + for (const [format, photos] of [['60x96', true], ['66x66', true], ['36x60', false]]) { + const m = await measure(createPosterGenerator, format, pins) + fails.push(...judge(format, m, pins, photos)) + lines.push(`${format}: ${m.names.length} names, ${m.leaders} leaders, ${m.crossings} crossings, ${m.photos} photos, ${m.stats?.namePt} pt`) + } + if (fails.length) { fails.forEach((f) => console.log('FAIL ' + f)); process.exitCode = 1 } + else console.log(`PASS check:side-lanes — synthetic ${pins.length} groups (stacked + clustered); ${lines.join(' | ')}`) +}