From 44f625c9db1e7cc831aca6bbeab0663228287159 Mon Sep 17 00:00:00 2001 From: JohnsonKC201 Date: Thu, 6 Aug 2026 23:26:21 -0700 Subject: [PATCH] feat(settings): group the window into sections and speak the pet's language Eleven cards in one 3400px column inside a window fixed at 560px meant reaching the pomodoro or the calendar was a blind scroll past ten cards you were not looking for, with nothing to aim at. The window is five tabs now - Pet, Play, Sound, Focus, Feeds - and the tallest is 1130px, with Sound fitting on screen whole. The rail is keyboard-drivable (arrows, Home/End) and carries the ARIA a tablist is supposed to. The wording was the cat's, hard-coded in the markup, so a dog owner read "your cat", "the cat calls you by it" and "Butterfly visits" while the tray, reading the species registry, already called that same toggle "Ball to chase". Those strings move into pets.js beside the tray's, so one toggle cannot be described two ways, and a test fails if any dog-facing string mentions a cat. The header also still read "pixelcat" after the 0.3.0 rename. Two bugs surfaced on the way: - The coat preview read its dog palettes off window.DOG_PATTERNS, but dog-sprite.js is a classic script whose top-level consts are global lexical bindings and never become window properties. The lookup came back undefined and the draw bailed out early and silently, so picking a breed relabelled everything and went on showing the cat. The preview had never once rendered a dog. - Only the custom-coat IPC reply rebuilt the coat dropdown, so whenever it beat the config reply a dog owner was offered the cat's coats. Also: the toggle switches are appearance:none, which had taken their focus ring with it, so tabbing through the window showed nothing; and the height is draggable now (420 min, 640 default) rather than frozen, with the width still pinned to the one-column layout. --- CHANGELOG.md | 9 + README.md | 7 +- eslint.config.js | 1 + src/main.js | 6 +- src/pets.js | 56 ++++- src/settings-renderer.js | 61 ++++- src/settings.html | 447 +++++++++++++++++++++--------------- tests/settings-copy.test.js | 136 +++++++++++ 8 files changed, 522 insertions(+), 201 deletions(-) create mode 100644 tests/settings-copy.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index bd82b2a..716e207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,16 @@ Notable changes to **pixelpets**. All art and sound are original/procedural (no ## [Unreleased] +### Settings window +- **Sections instead of one long scroll.** Everything lived in a single column: eleven cards, about 3400px of it, inside a window fixed at 560px. Reaching the pomodoro or the calendar meant scrolling past ten cards you were not looking for, with nothing to aim at and no sense of how much was left. The window is now five tabs - Pet, Play, Sound, Focus, Feeds - and the tallest of them is 1130px, with Sound fitting on screen whole. The rail is keyboard-drivable (arrows, Home/End) and carries the ARIA a tablist is supposed to. +- **The window stopped calling itself pixelcat.** 0.3.0 renamed the app, the repo and the docs, but the settings header still read "pixelcat" - at people who had just chosen a dog. +- **A dog owner is no longer told about a cat.** The window hard-coded the cat's nouns, so a dog owner read "your cat", "the cat calls you by it", "Butterfly visits - a butterfly drops by and the cat plays with it" and "Test meow", while the tray - reading the species registry - already called that same toggle "Ball to chase". Those strings now live in `pets.js` beside the tray's, so the two windows cannot describe one toggle differently, and a test fails if any dog-facing string mentions a cat. +- **Settings can be made taller.** Width stays pinned to the one-column layout, but the height is draggable now (420 minimum, 640 default) rather than frozen at 560. +- **Keyboard focus is visible again.** The toggle switches are `appearance:none`, which had taken their focus ring with them, so tabbing through the window gave no clue which switch was about to be flipped. + ### Fixes +- **The dog coat preview drew a cat.** Picking a breed relabelled everything correctly and then went on showing the previously drawn cat, because the preview read its palettes off `window.DOG_PATTERNS` - and `dog-sprite.js` is a classic script whose top-level `const`s are global *lexical* bindings that never become window properties. The lookup came back undefined and the draw bailed out early and silently, so the preview had never once rendered a dog. +- **The coat list could belong to the wrong species.** The first config and the first custom-coat list arrive as two independent IPC replies, and only the themes reply rebuilt the dropdown, so whenever it won the race a dog owner was offered the cat's coats. - **Speech bubbles hold their text.** The panel was capped at 260px but the whole message was drawn anyway, so anything past roughly 44 characters spilled white text onto the wallpaper either side of the box, where it is unreadable. Reminders and the pinned note are allowed 80 characters and calendar event titles had no cap at all, so this was the normal case rather than an edge case. Messages now wrap, an over-long word is broken instead of left hanging, a message too long to show at all is ellipsised rather than silently cut, and the panel is clamped onto the screen with its tail still pointing at the pet - which matters because the pet's default resting spot is a screen corner. - **Alerts no longer overwrite each other.** Two arriving together - two reminders set for the same minute, or a reminder landing during a calendar nudge - meant the second replaced the first on the spot, so the first could be gone milliseconds after it appeared. Only identical messages were ever suppressed, so two different alerts always collided. They now queue, and each gets its full time on screen, including while the pet is hunting or startled. - **Calendar event titles are length-capped** before they become a bubble and a Windows toast. They come from someone else's calendar and, unlike reminders and the pinned note, arrived with no bound at all. diff --git a/README.md b/README.md index 9eddd77..fcafa30 100644 --- a/README.md +++ b/README.md @@ -384,11 +384,12 @@ Ideas only; all code here is original to pixelpets. ## Custom coats -Design your own under Settings > Custom coats > "+ Add a custom coat": pick a -name, a body build (standard, slender, stocky, or fluffy), optional tabby +Design your own under Settings > Pet > Custom coats > "+ Add a custom coat": pick +a name, a body build (standard, slender, stocky, or fluffy), optional tabby stripes, and eight colours (coat, marks, white, patch, eyes, nose, inner ear, outline). Your coat shows up in the Coat dropdown and the tray menu next to the -14 built-ins. +14 built-ins. Custom coats are built from the cat's geometry, so they apply to +cats only; the Pet tab says so when a dog is selected. Custom coats live in `themes.json` in your app-data folder (`%APPDATA%/pixelcat/themes.json`) and can be hand-edited too: diff --git a/eslint.config.js b/eslint.config.js index e9c3fe3..ce46d89 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,7 @@ const sharedOverlay = { // pets.js provides the species registry: PET_SPECIES: 'readonly', SPECIES_IDS: 'readonly', speciesOf: 'readonly', coatsFor: 'readonly', isSpecies: 'readonly', defaultCoatIndex: 'readonly', CAT_COATS: 'readonly', DOG_COATS: 'readonly', + SETTINGS_TEXT: 'readonly', settingsText: 'readonly', BUILDS: 'readonly', TABBY: 'readonly', PATTERN_BUILD: 'readonly', // art-frames.js provides the generated baked-pose table: ART_FRAMES: 'readonly', diff --git a/src/main.js b/src/main.js index 2e4dd45..78e1160 100644 --- a/src/main.js +++ b/src/main.js @@ -481,7 +481,11 @@ function rebuildTrayMenu() { function openSettings() { if (settingsWin && !settingsWin.isDestroyed()) { settingsWin.show(); settingsWin.focus(); return; } settingsWin = new BrowserWindow({ - width: 400, height: 560, resizable: false, fullscreenable: false, maximizable: false, + // Width is pinned (the layout is designed for one column at 400), but height is + // now draggable: the tallest section still overflows 640px on a short screen and + // a fixed window left no way out of that but scrolling. + width: 400, height: 640, minWidth: 400, maxWidth: 400, minHeight: 420, + resizable: true, fullscreenable: false, maximizable: false, title: 'pixelpets settings', skipTaskbar: false, alwaysOnTop: true, icon: path.join(__dirname, '..', 'assets', 'icon.png'), // taskbar icon for the settings window show: false, backgroundColor: '#191b22', // dark from the first paint - no white flash diff --git a/src/pets.js b/src/pets.js index 9cce112..17ccfdf 100644 --- a/src/pets.js +++ b/src/pets.js @@ -38,6 +38,13 @@ const SPECIES = { // The companion the pet plays with on its own once you step away. playNoun: 'butterfly', playToggleLabel: 'Butterfly visits', + // Vocabulary the settings window writes into SETTINGS_TEXT below. + noun: 'cat', + voice: 'meow', + voiceLine: 'meow & purr', + chase: 'pounces', + playArrival: 'drops by', + customCoatNote: 'Design your own and pick it from Coat above.', }, dog: { id: 'dog', @@ -50,9 +57,56 @@ const SPECIES = { giveChannel: 'ball', playNoun: 'ball', playToggleLabel: 'Ball to chase', + noun: 'dog', + voice: 'bark', + voiceLine: 'bark & pant', + chase: 'chases', + playArrival: 'rolls in', + // Custom coats are built from the cat's geometry (see populateCoats), so a dog + // owner needs to be told that rather than left staring at a list that never grows. + customCoatNote: 'Custom coats are cat-only for now. Switch to Cat to use one.', }, }; +// Every string in the settings window whose wording depends on the pet, keyed by +// the element id that displays it. The window used to hard-code the cat's nouns in +// its markup, so a dog owner read "the cat calls you by it" on rows the TRAY had +// already learned to call "Ball to chase" - two UIs disagreeing about one toggle. +// +// %token% is looked up on the species entry. The braces in the reminders hint are a +// DIFFERENT substitution (fillPlaceholders expands {name}/{time}/{date} at meow time) +// and must survive this pass untouched, which is why these use percent signs. +const SETTINGS_TEXT = { + petCardTitle: 'your %noun%', + nameLabel: 'Your name - the %noun% calls you by it', + coatLabel: '%coatNoun%', + huntSub: '%chase% when the mouse moves fast', + playTitle: '%playToggleLabel%', + playSub: 'a %playNoun% %playArrival% and the %noun% plays with it', + workModeSub: 'parks the %noun% in its rest corner on the taskbar & hides the %playNoun% while you work', + onTopSub: 'keep the %noun% above other windows', + soundSub: '%voiceLine% (synthesized)', + pomoSub: 'a pixel timer floats next to the %noun%; it stretches with you on breaks', + emailSub: 'the %noun% tells you when new mail arrives (IMAP)', + calSub: 'the %noun% reminds you before calendar events (.ics)', + remindersHint: 'The %noun% %voice%s your message at a set time. Placeholders: {name} {time} {date}', + pinnedNoteLabel: "Pinned note - stays above the %noun%'s head (leave empty to hide)", + coatsHint: '%customCoatNote%', + testSound: '🔊 Test %voice%', +}; + +// Resolve SETTINGS_TEXT for one species: { elementId: finalString }. An unknown +// %token% is left alone rather than blanked, so a typo shows up in the window as +// literal "%typo%" instead of silently deleting half a sentence. +function settingsText(species) { + const sp = speciesOf(species); + const out = {}; + for (const [id, tpl] of Object.entries(SETTINGS_TEXT)) { + out[id] = tpl.replace(/%(\w+)%/g, (m, k) => (typeof sp[k] === 'string' ? sp[k] : m)); + } + return out; +} + const SPECIES_IDS = Object.keys(SPECIES); const isSpecies = (s) => Object.prototype.hasOwnProperty.call(SPECIES, s); const speciesOf = (s) => SPECIES[isSpecies(s) ? s : 'cat']; @@ -62,7 +116,7 @@ const defaultCoatIndex = (s) => { return Math.max(0, sp.coats.indexOf(sp.defaultCoat)); }; -const api = { SPECIES, SPECIES_IDS, CAT_COATS, DOG_COATS, isSpecies, speciesOf, coatsFor, defaultCoatIndex }; +const api = { SPECIES, SPECIES_IDS, CAT_COATS, DOG_COATS, isSpecies, speciesOf, coatsFor, defaultCoatIndex, SETTINGS_TEXT, settingsText }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else if (typeof window !== 'undefined') Object.assign(window, api, { PET_SPECIES: SPECIES }); diff --git a/src/settings-renderer.js b/src/settings-renderer.js index b0f3a6f..6b714f1 100644 --- a/src/settings-renderer.js +++ b/src/settings-renderer.js @@ -4,6 +4,46 @@ const $ = (id) => document.getElementById(id); let cfg = null; +// ---- section rail ---------------------------------------------------------- +// Everything used to live in one 3400px column inside a 560px window, so finding +// "pomodoro" meant scrolling past ten cards. Panels are shown by the `hidden` +// attribute alone; aria-selected and the roving tabindex follow it. +const TABS = Array.from(document.querySelectorAll('.tab')); +function selectTab(key, { focus = false } = {}) { + for (const t of TABS) { + const on = t.dataset.panel === key; + t.setAttribute('aria-selected', String(on)); + t.tabIndex = on ? 0 : -1; // one stop for the whole rail, arrows move within it + const panel = $('panel-' + t.dataset.panel); + if (panel) panel.hidden = !on; + if (on && focus) t.focus(); + } + window.scrollTo(0, 0); // a fresh panel starts at its top, not the last panel's offset +} +TABS.forEach((t, i) => { + t.addEventListener('click', () => selectTab(t.dataset.panel)); + t.addEventListener('keydown', (e) => { + const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; + let next = null; + if (step) next = TABS[(i + step + TABS.length) % TABS.length]; + else if (e.key === 'Home') next = TABS[0]; + else if (e.key === 'End') next = TABS[TABS.length - 1]; + if (!next) return; + e.preventDefault(); + selectTab(next.dataset.panel, { focus: true }); + }); +}); +selectTab('pet'); + +// ---- per-species wording --------------------------------------------------- +// The strings live in pets.js next to the tray's, so the two windows cannot end up +// describing the same toggle differently (the settings window said "Butterfly +// visits" at dog owners while the tray already said "Ball to chase"). +function applySpeciesText() { + const text = settingsText(curSpecies()); + for (const [id, s] of Object.entries(text)) { const el = $(id); if (el) el.textContent = s; } +} + // Populate the coat dropdown from the built-in names plus any custom coats. let themes = []; // Which species the window is currently editing, and therefore which list of @@ -18,13 +58,11 @@ function populateCoats() { const base = dog ? (window.DOG_COATS || []) : (window.PATTERN_NAMES || []); const names = dog ? base.slice() : base.concat(themes.map((t) => t.name)); names.forEach((name, i) => { const o = document.createElement('option'); o.value = String(i); o.textContent = name; sel.appendChild(o); }); - const lbl = $('coatLabel'); if (lbl) lbl.textContent = dog ? 'Breed' : 'Coat'; if (cfg) sel.value = String(cfg[coatField()] || 0); else if (cur) sel.value = cur; const spSel = $('species'); if (spSel) spSel.value = curSpecies(); - // The Sound row names the voices you will actually hear, which are not the same - // two sounds for both species. - const sub = $('soundSub'); - if (sub) sub.textContent = dog ? 'bark & pant (synthesized)' : 'meow & purr (synthesized)'; + // Coat vs Breed, which voice the Sound row promises, whether custom coats apply: + // all of it is one lookup now. + applySpeciesText(); drawPreview(); } function drawPreview() { @@ -32,9 +70,14 @@ function drawPreview() { if (!P || !cv) return; const i = Number($('pattern').value) || 0; if (curSpecies() === 'dog') { - const pal = (window.DOG_PATTERNS || [])[i]; + // Bare identifiers, NOT window.*: dog-sprite.js is a classic script whose top-level + // `const`s live in the global LEXICAL scope and never become window properties (it + // has no window-export branch, only module.exports). Reading window.DOG_PATTERNS + // got undefined, so this returned early and left the cat on the canvas - the dog + // coat preview never drew once. cat-preview.js's drawDog reads them the same way. + const pal = DOG_PATTERNS[i]; if (!pal || !P.drawDog) return; - P.drawDog(cv, pal, (window.DOG_PATTERN_BUILD || [])[i]); + P.drawDog(cv, pal, DOG_PATTERN_BUILD[i]); return; } let pal, build, tabby; @@ -61,6 +104,10 @@ function render() { // Don't stomp the name field while the user is typing in it (a broadcast config // echo would otherwise overwrite it with the normalized value and jump the caret). if (document.activeElement !== $('name')) $('name').value = cfg.name || ''; + // Rebuild from the config's species before reading a coat index out of it. The + // first config and the first theme list arrive as two independent IPC replies, so + // whenever the themes won the race a dog owner got the cat's coat list. + populateCoats(); $('pattern').value = String(cfg[coatField()] || 0); $('breakMinutes').value = String(cfg.breakMinutes || 0); $('followCursor').checked = !!cfg.followCursor; diff --git a/src/settings.html b/src/settings.html index 3e810e1..069c1dd 100644 --- a/src/settings.html +++ b/src/settings.html @@ -23,13 +23,20 @@ body::-webkit-scrollbar { width: 10px; } body::-webkit-scrollbar-thumb { background: var(--line); border-radius: 6px; border: 2px solid var(--bg); } + /* Keyboard focus has to be visible on every control. The toggles below are + appearance:none, so without this a keyboard user tabbing through the window + has no idea which switch they are about to flip. */ + :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + + /* header + tabs travel together, so the tab rail needs no magic top offset */ + .top { position: sticky; top: 0; z-index: 5; } + /* sticky header bar - game-HUD feel */ .hdr { - position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 9px; - padding: 13px 16px; margin-bottom: 14px; + padding: 13px 16px; background: linear-gradient(180deg, #1e2027, #181a20); - border-bottom: 2px solid #0f1116; box-shadow: 0 3px 0 rgba(0,0,0,.3); + border-bottom: 1px solid #0f1116; } .hdr .paw { width: 24px; height: 24px; flex: none; display: grid; place-items: center; @@ -40,7 +47,35 @@ .hdr b i { color: var(--accent); font-style: normal; } .hdr .tag { margin-left: auto; color: var(--faint); font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; } - .wrap { padding: 0 16px; } + /* section rail - 11 cards in one scroll was a 6x blind scroll with nothing to + aim at; the rail turns "keep scrolling and hope" into one click. */ + .tabs { + display: grid; grid-template-columns: repeat(5, 1fr); + background: linear-gradient(180deg, #1a1c23, #15171d); + border-bottom: 2px solid #0f1116; box-shadow: 0 3px 0 rgba(0,0,0,.3); + } + .tab { + display: flex; flex-direction: column; align-items: center; gap: 3px; + background: transparent; color: var(--muted); + border: 0; border-bottom: 3px solid transparent; border-radius: 0; box-shadow: none; + padding: 7px 2px 6px; font: inherit; font-size: 9.5px; font-weight: 700; + letter-spacing: .7px; text-transform: uppercase; cursor: pointer; + transition: color .12s, background .12s, border-color .12s; + } + .tab .ic { font-size: 15px; line-height: 1; filter: grayscale(.6) opacity(.7); transition: filter .12s; } + .tab:hover { color: var(--fg); background: rgba(255,255,255,.045); } + .tab:active { transform: none; box-shadow: none; } /* not a chunky push-button */ + .tab:focus-visible { outline-offset: -3px; } + .tab[aria-selected="true"] { color: var(--accent); background: rgba(232,148,60,.1); border-bottom-color: var(--accent); } + .tab[aria-selected="true"] .ic { filter: none; } + + .wrap { padding: 14px 16px 0; } + + /* Visibility is the `hidden` attribute and nothing else - a parallel .on class + is one more thing that can drift out of step with aria-selected. */ + .panel { animation: panelIn .16s ease-out; } + @keyframes panelIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } + @media (prefers-reduced-motion: reduce) { .panel { animation: none; } } /* section cards */ .card { @@ -57,9 +92,9 @@ .row { margin-bottom: 12px; } .row:last-child { margin-bottom: 0; } label.lbl { display: block; color: var(--muted); margin-bottom: 5px; font-size: 12px; } - input[type=text], input[type=number], input[type=time], select { + input[type=text], input[type=password], input[type=number], input[type=time], select { width: 100%; background: var(--bg); color: var(--fg); - border: 2px solid var(--line); border-radius: 7px; padding: 8px 9px; font: inherit; outline: none; + border: 2px solid var(--line); border-radius: 7px; padding: 8px 9px; font: inherit; transition: border-color .12s; } input:focus, select:focus { border-color: var(--accent); } @@ -75,6 +110,7 @@ border: 1px solid transparent; transition: background .12s, border-color .12s; } .check:hover { background: var(--panel2); border-color: var(--line); } + .check:has(input:focus-visible) { background: var(--panel2); border-color: var(--accent); } .check:last-child { margin-bottom: 0; } .check input { appearance: none; -webkit-appearance: none; width: 38px; height: 20px; flex: none; background: #14161c; border: 2px solid var(--line); border-radius: 12px; position: relative; cursor: pointer; transition: background .15s, border-color .15s; } @@ -84,10 +120,13 @@ .check input:checked::after { transform: translateX(18px); background: var(--accent); } .check label, .check > span.t { font-size: 12.5px; color: var(--fg); cursor: pointer; flex: 1; } .check .sub { display: block; color: var(--faint); font-size: 11px; margin-top: 1px; } + @media (prefers-reduced-motion: reduce) { + .check input, .check input::after { transition: none; } + } /* range */ input[type=range] { width: 100%; appearance: none; -webkit-appearance: none; height: 6px; border-radius: 4px; - background: #14161c; border: 1px solid var(--line); outline: none; } + background: #14161c; border: 1px solid var(--line); } input[type=range]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; width: 16px; height: 16px; border-radius: 4px; background: var(--accent); border: 2px solid #0f1116; box-shadow: 1px 1px 0 rgba(0,0,0,.4); cursor: pointer; } @@ -102,10 +141,19 @@ button.ghost { background: var(--panel2); color: var(--fg); font-weight: 600; } button.ghost:hover { background: #313542; } - .coatPreview-wrap { display: flex; justify-content: center; margin-top: 12px; } - canvas#coatPreview { display: block; image-rendering: pixelated; - background: radial-gradient(circle at 50% 35%, rgba(232,148,60,.08), rgba(255,255,255,.03)); - border: 2px solid var(--line); border-radius: 10px; } + /* coat preview - the one place the app shows you what you are actually picking, + so it gets a lit stage and a floor to sit on rather than a bare swatch box. */ + .stage { + display: grid; justify-items: center; margin-top: 12px; padding: 10px 10px 11px; + background: + radial-gradient(130px 62px at 50% 88%, rgba(232,148,60,.14), transparent 70%), + linear-gradient(180deg, #1c1f27, #171920); + border: 2px solid #14161c; border-radius: 10px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.035); + } + canvas#coatPreview { display: block; image-rendering: pixelated; } + .stage .floor { width: 76%; height: 2px; border-radius: 2px; + background: linear-gradient(90deg, transparent, var(--line) 22%, var(--line) 78%, transparent); } .hint { color: var(--faint); font-size: 11px; } @@ -146,205 +194,226 @@ -
- 🐾 - pixelcat - settings +
+
+ 🐾 + pixelpets + settings +
+ +
- -
-
your cat
-
- - -
-
- - - - -
- + +
+
+
your pet
+
+ +
-
-
+
+ + +
+
+ + +
+ + +
+
+ - -
-
behaviour
-
- - - min (0 = off) -
- - - - - - -
- - -
- - +
+
custom coats
+ Design your own and pick it from Coat above. +
    +
    +
    + Add a custom coat +
    + + + +
    +
    + + + + + + + + +
    + +
    +
    - -
    -
    performance
    - - -
    + +
    diff --git a/tests/settings-copy.test.js b/tests/settings-copy.test.js new file mode 100644 index 0000000..a656832 --- /dev/null +++ b/tests/settings-copy.test.js @@ -0,0 +1,136 @@ +// The settings window's wording is data (pets.js SETTINGS_TEXT) applied to element +// ids by settings-renderer.js. Nothing here boots Electron: the point is that the +// strings resolve for every species and that the ids they target still exist in the +// markup, which is exactly the pair that drifts apart silently. +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const pets = require('../src/pets.js'); + +const SRC = path.join(__dirname, '..', 'src'); +const html = fs.readFileSync(path.join(SRC, 'settings.html'), 'utf8'); +const renderer = fs.readFileSync(path.join(SRC, 'settings-renderer.js'), 'utf8'); + +const idsIn = (s) => new Set([...s.matchAll(/\bid="([\w-]+)"/g)].map((m) => m[1])); +const HTML_IDS = idsIn(html); + +// "is this pattern still in the source" checks have to read the CODE, not the prose +// around it - the comment explaining why window.DOG_PATTERNS was wrong otherwise +// trips the guard against window.DOG_PATTERNS. Whole-line comments only, so a URL +// in a string keeps its line. +const codeOnly = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .split('\n').filter((l) => !l.trim().startsWith('//')).join('\n'); +const RENDERER_CODE = codeOnly(renderer); + +test('every settings string resolves for every species', () => { + for (const id of pets.SPECIES_IDS) { + const text = pets.settingsText(id); + assert.deepEqual(Object.keys(text).sort(), Object.keys(pets.SETTINGS_TEXT).sort(), + `${id} resolved a different set of keys than the template`); + for (const [key, s] of Object.entries(text)) { + assert.ok(s && s.trim(), `${id}.${key} resolved to nothing`); + assert.doesNotMatch(s, /%\w+%/, `${id}.${key} left an unresolved token: ${s}`); + } + } +}); + +test('every string the window writes has somewhere to be written', () => { + // applySpeciesText() looks each key up with getElementById and quietly skips a + // miss, so renaming an id in the markup would blank the species wording on that + // row with nothing to notice it by. + for (const key of Object.keys(pets.SETTINGS_TEXT)) { + assert.ok(HTML_IDS.has(key), `SETTINGS_TEXT.${key} targets an id that settings.html does not have`); + } +}); + +test('a dog owner is never told about a cat', () => { + // Regression: the window hard-coded the cat's nouns, so a dog owner read "the cat + // calls you by it" and "Butterfly visits" while the TRAY - reading the same + // registry - already said "Ball to chase". + const dog = pets.settingsText('dog'); + // coatsHint is the one exception: its whole job is to say the feature is cat-only. + for (const [key, s] of Object.entries(dog)) { + if (key === 'coatsHint') continue; + assert.doesNotMatch(s, /\bcats?\b/i, `dog.${key} mentions a cat: ${s}`); + assert.doesNotMatch(s, /\b(meow|purr|butterfly)\b/i, `dog.${key} uses the cat's vocabulary: ${s}`); + } + assert.match(dog.coatsHint, /cat/i, 'the dog needs telling that custom coats are not for it'); +}); + +test('the play toggle says the same thing here as it does in the tray', () => { + for (const id of pets.SPECIES_IDS) { + const sp = pets.speciesOf(id); + const text = pets.settingsText(id); + assert.equal(text.playTitle, sp.playToggleLabel, `${id}'s settings row and tray item disagree`); + assert.match(text.playSub, new RegExp(sp.playNoun, 'i'), `${id}'s play blurb never names the ${sp.playNoun}`); + assert.equal(text.coatLabel, sp.coatNoun); + } +}); + +test('reminder placeholders survive the species pass', () => { + // Species tokens are %percent% precisely so this hint can keep advertising the + // {name}/{time}/{date} placeholders that fillPlaceholders expands much later. A + // resolver that also ate braces would silently delete the documentation for them. + for (const id of pets.SPECIES_IDS) { + const hint = pets.settingsText(id).remindersHint; + for (const ph of ['{name}', '{time}', '{date}']) { + assert.ok(hint.includes(ph), `${id}'s reminder hint lost ${ph}`); + } + } +}); + +test('an unknown token is left visible rather than blanked', () => { + const out = pets.settingsText('cat'); + assert.ok(Object.keys(out).length > 0); + // speciesOf() falls back to the cat, so a bad species must not blank the window. + assert.deepEqual(pets.settingsText('ferret'), pets.settingsText('cat')); +}); + +test('every tab points at a panel that exists, and exactly one starts open', () => { + const tabs = [...html.matchAll(/