morphit/apps/web/scripts/coin-carousel-invariants-smoke.ts

370 lines
13 KiB
TypeScript

#!/usr/bin/env tsx
/**
* coin-carousel-invariants-smoke.ts
*
* Verifies that CoinCarousel.svelte preserves its 6 load-bearing
* invariants:
*
* I-1 Registry-driven — the visible coins are computed by
* filtering ASSETS from the canonical registry, NOT by
* a hardcoded ticker list. Adding a new asset to the
* registry should automatically light it up in the
* carousel; removing one should remove it. Hardcoding
* defeats the rationale.
*
* I-2 Operator-disabled filter — the carousel checks
* $instance.disabled_assets and skips any ticker listed
* there. Memory rule: never show a coin the operator
* explicitly turned off.
*
* I-3 Dedupe by icon-file basename — if two registry entries
* share an icon file (e.g. ETH used as both an asset
* and a network indicator), only one carousel slot
* renders. Memory rule: don't display the same icon
* twice in the same carousel.
*
* I-4 Lazy-loaded images — every <img> in the carousel has
* loading="lazy" + decoding="async". Below-the-fold
* rule (priority #4 tiny footprint).
*
* I-5 IntersectionObserver lazy-mount — the component
* defers rendering the actual marquee track until the
* carousel scrolls within ~200px of the viewport. A
* first-time visitor who never scrolls past the hero
* pays zero bytes for the track.
*
* I-6 prefers-reduced-motion honored — the CSS marquee
* animation is disabled (transform paused) under the
* reduced-motion media query.
*/
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, '..', '..', '..');
const COMPONENT = resolve(REPO, 'apps/web/src/lib/components/CoinCarousel.svelte');
const STATIC_ROOT = resolve(REPO, 'apps/web/static');
const src = readFileSync(COMPONENT, 'utf8');
interface Scenario {
name: string;
test: () => string | null;
}
const scenarios: Scenario[] = [
{
name: 'I-1: visible-slot set is derived from the ASSETS registry',
test: () => {
if (!src.includes("import { ASSETS } from '$lib/assets/registry'")) {
return 'ASSETS not imported from the canonical registry path';
}
if (!/visibleSlots[\s\S]{0,200}?\$derived\.by/.test(src)) {
return 'visibleSlots not declared as a $derived.by reactive computation';
}
if (!/for\s*\(\s*const\s+a\s+of\s+ASSETS\s*\)/.test(src)) {
return 'visibleSlots computation does not iterate ASSETS';
}
return null;
}
},
{
name: 'I-2: operator-disabled assets are excluded',
test: () => {
if (!src.includes('disabled_assets')) {
return 'visibleCoins computation does not reference disabled_assets';
}
if (!/\$instance/.test(src)) {
return '$instance store not referenced';
}
// Verify the disabled-set check is actually applied as a filter.
if (!/disabled\.has/.test(src)) {
return 'disabled-asset Set.has check missing from filter pipeline';
}
return null;
}
},
{
name: 'I-3: dedupe by icon-file basename',
test: () => {
if (!/seenBasenames\s*=\s*new\s+Set/.test(src)) {
return 'seenBasenames Set not constructed for dedupe';
}
if (!/seenBasenames\.has\(basename\)/.test(src)) {
return 'seenBasenames.has(basename) check missing — duplicates would slip through';
}
if (!/logoSvgPath\.split\('\/'\)\.pop\(\)/.test(src)) {
return 'basename extracted via wrong method — must be basename(logoSvgPath)';
}
return null;
}
},
{
name: 'I-4: every <img> has loading="lazy" + decoding="async"',
test: () => {
// Scope the search to the template (before the <style> block)
// so we don't match CSS selectors like `.foo img { ... }`.
const styleIdx = src.indexOf('<style>');
const template = styleIdx === -1 ? src : src.slice(0, styleIdx);
// Match self-closing <img …/> and <img …> forms; require at
// least one attribute (a space after `img`) so `.foo img {`
// in any stray CSS gets ignored.
const imgs = template.match(/<img\s[^>]*?>/g) ?? [];
if (imgs.length === 0) {
return 'no <img> tags found in component template';
}
for (const img of imgs) {
if (!/loading="lazy"/.test(img)) {
return `<img> missing loading="lazy": ${img.slice(0, 80)}`;
}
if (!/decoding="async"/.test(img)) {
return `<img> missing decoding="async": ${img.slice(0, 80)}`;
}
}
return null;
}
},
{
name: 'I-5: IntersectionObserver lazy-mount is implemented',
test: () => {
if (!src.includes('IntersectionObserver(')) {
return 'IntersectionObserver not instantiated';
}
if (!/mounted\s*=\s*\$state\(false\)/.test(src)) {
return 'mounted reactive flag not declared (lazy-mount gate)';
}
if (!/mounted\s*=\s*true/.test(src)) {
return 'mounted flag never flipped to true (lazy-mount never triggers)';
}
if (!/rootMargin:\s*['"][^'"]+['"]/.test(src)) {
return 'IntersectionObserver missing rootMargin (would mount only ON-screen, too late)';
}
return null;
}
},
{
name: 'I-6: prefers-reduced-motion disables marquee animation',
test: () => {
if (!/@media\s*\(\s*prefers-reduced-motion:\s*reduce\s*\)/.test(src)) {
return 'no @media (prefers-reduced-motion: reduce) rule in <style>';
}
if (!/animation:\s*none/.test(src)) {
return 'reduced-motion block does not set animation: none';
}
return null;
}
},
{
name: 'I-7: exactly 5 network slots; all icons exist on disk',
test: () => {
// Locate the NETWORK_SLOTS array. Each entry has an
// iconBasename matching `icon-network-<name>.svg`.
const blockMatch = src.match(/NETWORK_SLOTS[\s\S]*?\]/);
if (!blockMatch) return 'NETWORK_SLOTS array declaration not found';
const block = blockMatch[0];
const expected = new Set([
'icon-network-arbitrum.svg',
'icon-network-base.svg',
'icon-network-bep20.svg',
'icon-network-polygon.svg',
'icon-network-trc20.svg'
]);
const found = new Set<string>();
const re = /iconBasename:\s*'([^']+)'/g;
let m: RegExpExecArray | null;
while ((m = re.exec(block)) !== null) {
found.add(m[1]!);
}
if (found.size !== 5) {
return `expected exactly 5 network slots, found ${found.size}`;
}
for (const want of expected) {
if (!found.has(want)) {
return `missing required network slot: ${want}`;
}
}
for (const got of found) {
if (!expected.has(got)) {
return `unexpected network slot: ${got}`;
}
const onDisk = join(STATIC_ROOT, 'icons/networks', got);
if (!existsSync(onDisk)) {
return `network icon referenced in carousel but missing on disk: ${onDisk}`;
}
}
return null;
}
},
{
name: 'I-8: barter slot present; icon exists on disk',
test: () => {
if (!/iconPath:\s*'\/icons\/icon-barter\.svg'/.test(src)) {
return 'barter slot iconPath does not point at /icons/icon-barter.svg';
}
if (!/home\.coin_carousel\.barter\.label/.test(src)) {
return 'barter label i18n key not referenced';
}
if (!/home\.coin_carousel\.barter\.sr/.test(src)) {
return 'barter screen-reader i18n key not referenced';
}
const onDisk = join(STATIC_ROOT, 'icons/icon-barter.svg');
if (!existsSync(onDisk)) {
return `barter icon referenced in carousel but missing on disk: ${onDisk}`;
}
return null;
}
},
{
name: 'I-9: dedupe by basename spans coin + network + barter sources',
test: () => {
// All three source loops must consult the SAME seenBasenames
// Set so an icon collision between sources collapses to one
// slot. Verify by counting `seenBasenames.has(...)` checks
// — there must be at least 3 (one per source).
const checks = src.match(/seenBasenames\.has\(/g) ?? [];
if (checks.length < 3) {
return `expected ≥3 seenBasenames.has() checks (one per source), found ${checks.length}`;
}
const adds = src.match(/seenBasenames\.add\(/g) ?? [];
if (adds.length < 3) {
return `expected ≥3 seenBasenames.add() calls (one per source), found ${adds.length}`;
}
return null;
}
},
{
name: 'I-10: icons render at uniform opacity: 0.85 (no color modification)',
test: () => {
// cp115-cp4: Ken wants the asset ICON ARTWORK shipped full-
// color, no SVG modification, no `filter: grayscale`-style
// color tampering. cp115-cp6: but the carousel-item gets
// a uniform 0.85 alpha applied at the stacking level so it
// sits as a decorative ribbon under the bolder priorities-
// cards above without competing for attention. This smoke
// pins the EXACT value so drift in either direction is
// caught: removing the dimming (e.g. to 1.0) AND amplifying
// it (e.g. to 0.5) both fail this scenario.
const styleStart = src.indexOf('<style>');
const styleEnd = src.indexOf('</style>');
if (styleStart === -1 || styleEnd === -1) {
return '<style> block not found';
}
const styleBlock = src.slice(styleStart, styleEnd);
const itemRule = styleBlock.match(
/\.coin-carousel-item\s*\{[^}]*\}/
);
if (!itemRule) {
return '.coin-carousel-item style rule not found';
}
const opacityMatch = itemRule[0].match(/opacity:\s*([^;}\s]+)/);
if (!opacityMatch) {
return '.coin-carousel-item missing opacity declaration — Ken set it to 0.85; verify this rule still ships';
}
if (opacityMatch[1] !== '0.85') {
return `.coin-carousel-item opacity drifted from 0.85 to ${opacityMatch[1]} — pinned value (cp115-cp6)`;
}
// Also reject any filter: declaration on the item (would
// modify icon color, which violates "don't modify them").
const filterMatch = itemRule[0].match(/filter:\s*([^;}]+)/);
if (filterMatch && !/^\s*none\s*$/.test(filterMatch[1]!)) {
return `carousel-item rule modifies icons via filter: ${filterMatch[1]} — Ken's rule: render icons at full color, no modification`;
}
return null;
}
},
{
name: 'I-11: two carousel rows declared (rowA + rowB)',
test: () => {
// cp115-cp6: Ken asked for two rows so all 22+ slots can be
// seen sooner without waiting for a 60-second loop. Verify
// both derived row variables exist in the script section
// (not just inline filter() in the template).
if (!/const\s+rowA\s*=\s*\$derived/.test(src)) {
return 'rowA $derived declaration missing';
}
if (!/const\s+rowB\s*=\s*\$derived/.test(src)) {
return 'rowB $derived declaration missing';
}
// Verify the template actually renders both, with distinct
// CSS classes for the per-track direction.
if (!/coin-carousel-track-a/.test(src) || !/coin-carousel-track-b/.test(src)) {
return 'Template missing one or both of coin-carousel-track-{a,b} class names';
}
return null;
}
},
{
name: 'I-12: rows scroll in opposite directions',
test: () => {
// Row A uses default (normal) animation direction; row B
// uses reverse. This is the "opposite directions" rule —
// catches a future regression where someone copy-pastes the
// same rule for both tracks.
const styleStart = src.indexOf('<style>');
const styleEnd = src.indexOf('</style>');
const styleBlock = src.slice(styleStart, styleEnd);
const aRule = styleBlock.match(
/\.coin-carousel-track-a\s*\{[^}]*\}/
);
const bRule = styleBlock.match(
/\.coin-carousel-track-b\s*\{[^}]*\}/
);
if (!aRule || !bRule) {
return 'coin-carousel-track-a or coin-carousel-track-b style rule missing';
}
const aDir = aRule[0].match(/animation-direction:\s*([^;}\s]+)/);
const bDir = bRule[0].match(/animation-direction:\s*([^;}\s]+)/);
if (!aDir) return 'track-a missing animation-direction';
if (!bDir) return 'track-b missing animation-direction';
if (aDir[1] === bDir[1]) {
return `both tracks scroll the same direction (${aDir[1]}) — Ken's rule cp115-cp6: opposite directions`;
}
return null;
}
},
{
name: 'I-13: row split is balanced (alternating even/odd)',
test: () => {
// cp115-cp6 rationale: as the registry grows, the split
// must stay 50/50. Alternating is the simplest balanced
// strategy (rowA = even-index, rowB = odd-index). Catches
// a future regression to slice(0, N/2) which would put all
// the cp3-era coins in one row + all cp21+ additions in
// the other.
if (!/rowA\s*=\s*\$derived\(visibleSlots\.filter\(\(_,\s*i\)\s*=>\s*i\s*%\s*2\s*===\s*0\)\)/.test(src)) {
return 'rowA split is not "i % 2 === 0" alternating — risk of unbalanced rows as catalogue grows';
}
if (!/rowB\s*=\s*\$derived\(visibleSlots\.filter\(\(_,\s*i\)\s*=>\s*i\s*%\s*2\s*===\s*1\)\)/.test(src)) {
return 'rowB split is not "i % 2 === 1" alternating — risk of unbalanced rows as catalogue grows';
}
return null;
}
}
];
console.log('\n── coin-carousel-invariants smoke (cp115) ───────────────\n');
let failed = 0;
let passed = 0;
for (const s of scenarios) {
const err = s.test();
if (err === null) {
console.log(`${s.name}`);
passed++;
} else {
console.log(`${s.name}`);
console.log(` ${err}`);
failed++;
}
}
console.log('');
console.log('──────────────────────────────────────────────────────');
if (failed === 0) {
console.log(`✓ all ${passed} coin-carousel-invariants scenarios passed`);
process.exit(0);
}
console.log(`${failed}/${passed + failed} scenarios failed`);
process.exit(1);