Files
timmy-talking-turd/tests/photo-lifecycle.acceptance.mjs
T
timmy ebee52fd8a
Quality gates / quality (pull_request) Successful in 3m9s
feat: separate saved-photo lifecycle from the confirmed log (#36)
A confirmed log and the image attached to it are separate things the
person owns. The journal now reports a saved photo as a label instead of
a thumbnail, reveals the image only on explicit request, removes the
photo without touching any confirmed field, and deletes a log through a
separate two-step decision that takes its photo with it. Delete-all is
regression tested to remove the ledger and every stored photo together.

- src/photo-lifecycle.js: presence decided by stored image bytes;
  withoutPhoto/withoutEntry return new lists and never mutate the ledger
- app.js: journal rows expose Open-entry controls; entry sheet states
  presence, offers explicit reveal, remove-photo-keeps-log, and a
  two-step log delete; privacy copy names photo removal and delete-all
- tests: 6 unit cases + browser acceptance (presence, no default
  thumbnail, explicit reveal, remove keeps log, two-step delete,
  delete-all leaves no image bytes, Escape dismiss, 44px targets)
- record_release_demo.mjs: on-camera photo lifecycle scenes driven by a
  synthetic 1x1 PNG, with delete-all actually performed and asserted;
  build_release gates the new captions
- CI/release/syntax gates include the new suite

Closes #36
2026-10-19 08:24:44 +00:00

124 lines
7.1 KiB
JavaScript

import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
// Issue #36 — a saved photo is reported and removable without touching the confirmed log.
// Every image below is a synthetic 1x1 PNG constant; no real photo is loaded or written.
const SYNTHETIC_PHOTO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
await mkdir('artifacts', { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', error => errors.push(error.message));
page.on('dialog', dialog => dialog.accept());
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
const sample = (id, daysAgo, photoDataUrl = '', overrides = {}) => ({
id,
occurredAt: new Date(Date.now() - daysAgo * 86400000).toISOString(),
bristolType: 4,
color: 'brown',
urgency: 1,
discomfort: 0,
note: '',
photoDataUrl,
symptoms: {},
...overrides,
});
const ledgerKey = () => page.evaluate(() => `timmy:${document.querySelector('meta[name="timmy-base-path"]')?.content || '/'}:ledger-v1`);
async function seedLedger(list) {
const key = await ledgerKey();
await page.evaluate(({ storeKey, entries }) => localStorage.setItem(storeKey, JSON.stringify(entries)), { storeKey: key, entries: list });
await page.reload({ waitUntil: 'networkidle' });
}
async function storedEntries() {
const key = await ledgerKey();
return page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]'), key);
}
// 1. Presence is visible in the journal without a thumbnail.
await seedLedger([sample('photo-log', 2, SYNTHETIC_PHOTO), sample('plain-log', 5, '', { bristolType: 6 })]);
await page.locator('[data-view="calendar"]').last().click();
await page.locator('.entries-card .entry').first().waitFor();
assert.equal(await page.locator('.entries-card .entry').count(), 2, 'both confirmed logs must be listed');
assert.equal(await page.locator('.entries-card [data-photo-indicator="present"]').count(), 1, 'exactly the log holding a photo is marked');
assert.equal(await page.locator('.entries-card img').count(), 0, 'the journal must not render a thumbnail by default');
assert.equal(await page.getByText('Photo saved', { exact: true }).count(), 1);
// Visual acceptance: the label sits inside the 390px viewport and adds no horizontal scroll.
const chipBox = await page.locator('.entries-card [data-photo-indicator="present"]').boundingBox();
assert.ok(chipBox && chipBox.width > 0, 'the photo label must be rendered');
assert.ok(chipBox.x + chipBox.width <= 390, 'the photo label must fit the mobile viewport');
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth) <= 390, 'the journal must not scroll horizontally');
// 2. The entry sheet reports the saved photo without showing it.
await page.locator('[data-entry-open="photo-log"]').click();
await page.locator('[data-photo-lifecycle="present"]').waitFor();
assert.equal(await page.locator('#entry-photo').count(), 0, 'opening a log must not show the photo until asked');
assert.match(await page.locator('[data-photo-lifecycle]').innerText(), /Photo saved with this log/i);
assert.match(await page.locator('[data-photo-lifecycle]').innerText(), /stays in this browser/i);
// Touch targets stay usable: every lifecycle action is at least 44px tall on mobile.
for (const selector of ['#show-entry-photo', '#remove-entry-photo', '#delete-entry']) {
const box = await page.locator(selector).boundingBox();
assert.ok(box && box.height >= 44, `${selector} must be a 44px touch target`);
}
// 3. Showing it is an explicit request.
await page.locator('#show-entry-photo').click();
const shown = page.locator('#entry-photo');
await shown.waitFor();
assert.match(await shown.getAttribute('src'), /^data:image\/png;base64,/, 'the revealed image is the stored bytes');
assert.match(await shown.getAttribute('alt'), /photo saved with this log/i);
// 4. Removing the photo keeps the confirmed log.
await page.locator('#remove-entry-photo').click();
await page.locator('[data-photo-lifecycle="absent"]').waitFor();
await page.getByText(/Photo removed\. Your log is still here\./).waitFor();
let stored = await storedEntries();
assert.equal(stored.length, 2, 'removing a photo must not delete the log');
const kept = stored.find(item => item.id === 'photo-log');
assert.ok(kept, 'the log that held the photo must still exist');
assert.equal(kept.photoDataUrl, '', 'only the image bytes are cleared');
assert.equal(kept.bristolType, 4);
assert.equal(kept.occurredAt, stored.find(item => item.id === 'photo-log').occurredAt);
await page.locator('#entry-sheet-close').click();
await page.locator('.entries-card .entry').first().waitFor();
assert.equal(await page.locator('.entries-card [data-photo-indicator="present"]').count(), 0, 'the indicator must follow the stored bytes');
// The sheet is a dialog: Escape dismisses it.
await page.locator('[data-entry-open="photo-log"]').click();
await page.locator('.sheet-backdrop').waitFor();
await page.keyboard.press('Escape');
await page.locator('.sheet-backdrop').waitFor({ state: 'detached' });
// 5. Deleting a log asks once and takes its photo with it.
await seedLedger([sample('delete-me', 1, SYNTHETIC_PHOTO), sample('keep-me', 3, '')]);
await page.locator('[data-view="calendar"]').last().click();
await page.locator('[data-entry-open="delete-me"]').click();
await page.locator('#delete-entry').click();
await page.locator('#delete-entry-confirm').waitFor();
assert.equal((await storedEntries()).length, 2, 'the first tap only arms the delete');
await page.locator('#delete-entry-confirm').click();
await page.locator('.entries-card .entry').first().waitFor();
stored = await storedEntries();
assert.deepEqual(stored.map(item => item.id), ['keep-me'], 'the confirmed log and its photo are gone together');
// 6. Delete-all removes the ledger and every saved photo.
await seedLedger([sample('wipe-1', 1, SYNTHETIC_PHOTO), sample('wipe-2', 2, SYNTHETIC_PHOTO)]);
await page.locator('[data-view="calendar"]').last().click();
await page.locator('.settings-row[data-view="privacy"]').click();
await page.locator('#delete-all').click();
await page.getByText(/Local ledger and photos deleted/).waitFor();
const key = await ledgerKey();
assert.equal(await page.evaluate(storeKey => localStorage.getItem(storeKey), key), null, 'delete-all must remove the ledger');
assert.equal(await page.evaluate(() => JSON.stringify(localStorage).includes('data:image')), false, 'no photo bytes may survive delete-all');
await page.locator('[data-view="calendar"]').last().click();
await page.getByText(/Quiet bowl, clean slate\./).waitFor();
assert.deepEqual(errors, []);
await browser.close();
console.log('PASS photo presence → reveal on request → remove photo keeps log → delete log → delete-all');