Quality gates / quality (pull_request) Successful in 3m9s
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
124 lines
7.1 KiB
JavaScript
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');
|