aboutsummaryrefslogtreecommitdiffstats
path: root/tools/picture-pager-ready-test.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-17 05:50:08 -0700
committerAdam Soltys <[email protected]>2026-10-17 05:50:08 -0700
commitbfcdc0beb9b0016aa0c58043c993eaad73825fe9 (patch)
treea170c3494a999ca63ba5d92b6486475f72ea14dd /tools/picture-pager-ready-test.js
parent5615d645875f80300ea813de266b292f02ccd93f (diff)
downloadcoinosv3-bfcdc0beb9b0016aa0c58043c993eaad73825fe9.tar.gz
coinosv3-bfcdc0beb9b0016aa0c58043c993eaad73825fe9.zip
The picture pager works like the video pager: pictures join once decoded, the screen's way round with the shape toggle, and it flies (and counts) once ten are ready; flights end without the crawl, and the feed beneath doesn't repaint mid-flight.
Adam, 2026-10-17: "image pager should work similar. we need the tall only chip there too. i'm noticing some stutter/choppiness when flying images. especially towards the end of the fly as it's snapping to the one it's landing on." Measured on his phone (Pixel, 60 Hz) over synthesized touch flings in the live image pager: frames of 33–183 ms mid-flight, some with no script running (pictures fetched and decoded as the flight came near them: the near observer attached every post within 250% of the screen, a few of them originals — GIFs, hosts the server copies can't reach); the feed under the pager re-rendering (renderThreadStable, 40–55 ms each) during flights; and the flight's ease-out cube spending its last ~400 ms under 3 px a frame, the landing picture stepping a pixel at a time. - A picture post waits out of the carousel until its first picture is decoded (img.decode(), the copy falling back to the original as before). pagerWarmPics keeps four decoding at a time, up to sixteen ready ahead of the reader and three behind; a post whose copy and original both fail stays out. The picture's size, learned from the decode, places it for the shape rule. - The shape rule and its toggle ("Tall only" / "Wide only" / "All shapes", one setting with the videos) are in the picture pager's top bar too. - Flying needs ten posts ready, for pictures as for clips; the counter is hidden until then while posts are still warming (a pager with fewer than ten, all of them ready, counts them). The counter fade on settling applies to every pager (the frame loop's motion flag now runs for pictures too). - A flight eases out on a square instead of a cube: no long crawl at the end, and it sets off nearer the finger's speed. - scheduleRepaint holds off while the pager is moving. - pagerLoadAhead fills to the posts found (waiting ones too), not just the ready ones, so a gated pager doesn't crawl page after page. Tests: new tools/picture-pager-ready-test.js (a picture held back by the server waits out and a step passes over it; it joins above the one on screen without moving it; the wide one is out; no counter while one is warming, 3 / 6 once all are in; the toggle lets the wide one in and out and is as tall as the close button). pager-gone-picture now checks the dead picture is passed over. Picture tests not about shape turn all shapes on; pager-swipe-fly has twelve posts (ten ready before it flies) and waits out the longer flights; pager-loading's fixture gains pagerFound; pager-ready-gate: four clips all ready show their count. All pager tests pass except feed-pager-ahead and feed-video-pager, which fail the same checks as on a clean checkout. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'tools/picture-pager-ready-test.js')
-rw-r--r--tools/picture-pager-ready-test.js102
1 files changed, 102 insertions, 0 deletions
diff --git a/tools/picture-pager-ready-test.js b/tools/picture-pager-ready-test.js
new file mode 100644
index 0000000..7cfaa45
--- /dev/null
+++ b/tools/picture-pager-ready-test.js
@@ -0,0 +1,102 @@
+// The picture pager works like the video pager (Adam, 2026-10-17: "image
+// pager should work similar. we need the tall only chip there too"): a
+// picture post joins the carousel once its picture is decoded, so a step
+// passes over one still on its way; the shapes the wrong way round for
+// the screen stay out, with the "Tall only" / "All shapes" toggle in the
+// top bar; and with fewer than ten ready and more still warming, no
+// counter shows and a flick goes one post. Run: bun tools/picture-pager-ready-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
+// t1..t4 tall, w1 wide, sq square; slow (tall) is held back until released
+const PICS = { t1: png('300x400'), t2: png('300x420'), slow: png('300x440'), t3: png('300x460'), w1: png('480x270'), sq: png('320x320'), t4: png('300x480') };
+let release; const held = new Promise((r) => (release = r));
+const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
+ const m = /^\/(\w+)\.png$/.exec(path);
+ if (m && PICS[m[1]]) { if (m[1] === 'slow') await held; return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png', 'cache-control': 'no-store' } }); }
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.png';
+const ORDER = ['t1', 'slow', 't2', 'w1', 'sq', 't3', 't4'];
+const seeded = ORDER.map((n, i) => post('picture ' + n + ' ' + U(n), 100 + i * 100));
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+const name = (k) => k.split('|')[1].split('/').pop().replace('.png', '');
+const carousel = () => page.evaluate(() => [...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].map((g) => g.querySelector('.mp-slide').getAttribute('data-k').split('/').pop().replace('.png', '')).join(' '));
+const cur = () => page.evaluate(() => { const s = document.querySelector('.mp-slide.mp-cur'); return s ? s.getAttribute('data-k').split('/').pop().replace('.png', '') : null; });
+const resting = (n) => waitFor((n) => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 2 && Math.abs(r.left) < 2; }); return !!s && s.getAttribute('data-k').endsWith('/' + n + '.png'); }, n, 5000);
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ check('the posts are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed [data-key]').length >= 7, null, 20000));
+ await waitFor(() => !!document.querySelector('.notes-feed img.note-img'), null, 10000);
+ await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture t1')); r.scrollIntoView({ block: 'center' }); });
+ await sleep(400);
+ const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture t1')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; });
+ await page.mouse.click(at.x, at.y);
+ check('the first picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager .mp-slide'), null, 4000) && await resting('t1'), await cur());
+
+ console.log('\n[a picture joins once decoded]');
+ check('the pictures decoded join: t2, t3, t4 and the square', await waitFor(() => document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)').length >= 5, null, 8000), await carousel());
+ check('the one still on its way waits out of the carousel', !(await carousel()).includes('slow'), await carousel());
+ check('the wide one is out for its shape', !(await carousel()).includes('w1'), await carousel());
+ check('with fewer than ten ready and one still warming, no counter shows', await page.evaluate(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'hidden'));
+ await page.keyboard.press('ArrowDown');
+ check('down from t1 passes over it, to t2', await resting('t2'), await cur());
+ release();
+ check('once it has come, it joins, above the one on screen', await waitFor(() => /t1 slow t2/.test([...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].map((g) => g.querySelector('.mp-slide').getAttribute('data-k').split('/').pop().replace('.png', '')).join(' ')), null, 8000), await carousel());
+ check('…without moving it', await resting('t2'), await cur());
+ check('everything in, the counter shows: 3 / 6', await waitFor(() => getComputedStyle(document.querySelector('.mp-position')).visibility === 'visible' && document.querySelector('.mp-position').textContent === '3 / 6', null, 3000), await page.evaluate(() => document.querySelector('.mp-position').textContent));
+ await page.keyboard.press('ArrowUp');
+ check('up lands on it', await resting('slow'), await cur());
+
+ console.log('\n[the shape toggle in the picture pager]');
+ const label = await page.evaluate(() => document.querySelector('.media-pager .mp-fit')?.textContent);
+ check('the top bar has the toggle, saying tall only', label === 'Tall only', String(label));
+ check('…as tall as the close button', await page.evaluate(() => document.querySelector('.mp-fit').offsetHeight === document.querySelector('.mp-x').offsetHeight));
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped: the wide picture joins', await waitFor(() => [...document.querySelectorAll('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off)')].some((g) => /w1\.png$/.test(g.querySelector('.mp-slide').getAttribute('data-k'))), null, 6000), await carousel());
+ check('…and the toggle says all shapes', (await page.evaluate(() => document.querySelector('.mp-fit').textContent)) === 'All shapes');
+ check('…the picture on screen stayed put', await resting('slow'), await cur());
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('tapped again: the wide one is out', await waitFor(() => !document.querySelector('.mp-scroll > .mp-group:not(.mp-wait):not(.mp-off) .mp-slide[data-k$="/w1.png"]'), null, 3000), await carousel());
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} catch (e) { console.error(e); ok = false; } finally { release(); await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ the picture pager: decoded pictures only, the screen\'s way round, with the toggle' : '\n❌ failed');
+process.exit(ok ? 0 : 1);