diff options
| author | Adam Soltys <[email protected]> | 2026-09-23 22:51:58 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-23 22:51:58 -0700 |
| commit | 75679fdd5d24d36e3c54316e88ccefcf954afc82 (patch) | |
| tree | c86a369455da3c2e9532d51d2e72898afda8fccc /tools/thread-cache-test.js | |
| parent | fb170b4783c9b49c73e926c08aba9bc4d1bb418e (diff) | |
| download | coinosv3-75679fdd5d24d36e3c54316e88ccefcf954afc82.tar.gz coinosv3-75679fdd5d24d36e3c54316e88ccefcf954afc82.zip | |
Feed: threads cached locally, posts never inserted under the reader, rows keep their first look.
Work by Codex, wrapped up and verified here.
Threads: a viewed conversation is persisted (thread-cache.js, up to 12
threads / 160 events), so a refresh or a cached note link shows the root
and ancestors at once instead of the selected reply and a spinner; relays
are re-asked in the background and an empty answer keeps the cache.
Feed window (feed-window.js): arrivals are merged without inserting
between visible rows — a gap fill under the reader's eyes waits until
that part of the feed is offscreen, prepends anchor the reading position,
the notice counts what went in above, and only its tap goes to the top.
Older pages are prepared a few screens ahead; a page's pictures and faces
are decoded before it is shown.
Rows keep the presentation they were admitted with (feedPaint): background
profile and quote refreshes no longer rewrite text or swap faces mid-read;
media dimensions are known before paint. Thumbnails queue instead of being
dropped past the first three.
Tests: thread-cache, feed-window, feed-stability; feed-live updated for
the new arrival rules (the notice counts up while you read).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools/thread-cache-test.js')
| -rw-r--r-- | tools/thread-cache-test.js | 120 |
1 files changed, 120 insertions, 0 deletions
diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js new file mode 100644 index 0000000..fd3f5c8 --- /dev/null +++ b/tools/thread-cache-test.js @@ -0,0 +1,120 @@ +// Persistent thread context and the actual thread loader, with relay queries +// controlled in-process. No browser, network access, or publishing required. +// Run: bun tools/thread-cache-test.js +import assert from 'node:assert/strict'; +import { rm } from 'node:fs/promises'; +import { createThreadStore } from '../src/thread-cache.js'; +const memory = () => { + const values = new Map(); + return { getItem: (k) => values.get(k) ?? null, setItem: (k, v) => values.set(k, v) }; +}; +const storage = memory(); +globalThis.localStorage = storage; +const id = (n) => n.toString(16).padStart(64, '0'); +const note = (n, tags = []) => ({ id: id(n), pubkey: id(900), kind: 1, content: 'Post ' + n, created_at: n, tags }); +const root = note(1), parent = note(2, [['e', root.id, '', 'reply']]), reply = note(3, [['e', parent.id, '', 'reply']]); +const check = (name, fn) => { fn(); console.log('✓ ' + name); }; +const store = createThreadStore(storage); +check('a fresh store restores full content and tags by any viewed event', () => { + const long = { ...reply, content: 'x'.repeat(5000), tags: [...reply.tags, ['emoji', 'wave', 'https://example.com/wave.png']] }; + store.save({ root, replies: [parent, long] }, long.id); + const restored = createThreadStore(storage).find(long.id); + assert.deepEqual(restored, { rootId: root.id, root, replies: [long, parent] }); + assert.equal(store.find(parent.id).root.id, root.id); +}); +check('a large thread retains the selected reply and its ancestors', () => { + const siblings = Array.from({ length: 200 }, (_, i) => note(1000 + i, [['e', root.id, '', 'root']])); + store.save({ root, replies: [...siblings, parent, reply] }, reply.id); + const saved = store.find(reply.id); + assert(saved.replies.some((e) => e.id === parent.id)); + assert(saved.replies.length < 160); +}); +check('the thread cache is bounded and recent conversations win', () => { + for (let i = 10; i < 30; i++) store.save({ root: note(i), replies: [] }, id(i)); + assert.equal(store.find(root.id), null); + assert.equal(store.find(id(10)), null); + assert(store.find(id(29))); +}); +check('corrupt or unavailable storage does not break opening a thread', () => { + assert.equal(createThreadStore({ getItem: () => '{' }).find(root.id), null); + assert.equal(createThreadStore({ getItem: () => JSON.stringify([{ root: { ...root, tags: null }, replies: [] }]) }).find(root.id), null); + assert.doesNotThrow(() => createThreadStore({ getItem() { throw Error(); }, setItem() { throw Error(); } }).save({ root, replies: [] }, root.id)); +}); + +// Expose the real loader only in this test bundle, as the browser tests do. +const bundle = await Bun.build({ entrypoints: ['src/features/messages.js'], target: 'bun', plugins: [{ name: 'thread-cache-test', setup(build) { + build.onLoad({ filter: /src\/features\/messages\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text()) + .replace("id: 'messages',", "id: 'messages', testThread: threadFor, testOpen: openNoteRef,") })); + build.onLoad({ filter: /src\/nostr\.js$/ }, async ({ path }) => ({ loader: 'js', contents: (await Bun.file(path).text()) + .replace('export async function queryOn(relays, filter, maxWait = 1500) {', 'export async function queryOn(relays, filter, maxWait = 1500) { return globalThis.__threadQuery(filter);') + .replace(/export function subscribeOn\(([^)]*)\) \{/, 'export function subscribeOn($1) { return () => {};') })); +} }] }); +assert(bundle.success, bundle.logs.join('\n')); +const path = '/tmp/coinos-thread-cache-test-' + process.pid + '.mjs'; +await Bun.write(path, await bundle.outputs[0].text()); +try { + const { messagesFeature } = await import(path); + let requests = [], waiting = []; + globalThis.__threadQuery = (filter) => { + if (filter.kinds?.includes(1) && (filter.ids || filter['#e'])) { + requests.push(filter); + return new Promise((resolve) => waiting.push({ filter, resolve })); + } + return Promise.resolve([]); + }; + const makeFeature = () => { + const ui = { screen: 'wallet' }; + const feature = messagesFeature({ ui, wallet: { loaded: true, nostrRelays: () => [], loadFeatureState: (k, d) => d, saveFeatureState() {} }, + h() {}, render() {}, toast() {}, hook() {} }); + return { feature, ui }; + }; + // Use new IDs, so none of the storage-only tests can seed this lookup. + const r = note(400), p = note(401, [['e', r.id, '', 'reply']]), e = note(402, [['e', p.id, '', 'reply']]); + const first = makeFeature(); + const loading = first.feature.testThread(e); + assert.equal(loading.status, 'loading'); + const wait = async (condition) => { + for (let i = 0; i < 100; i++) { if (condition()) return; await Bun.sleep(5); } + assert(condition(), 'expected loader progress'); + }; + await wait(() => waiting.length === 2); + for (const q of waiting.splice(0)) q.resolve(q.filter.ids ? [p] : []); + await wait(() => waiting.length === 2); + for (const q of waiting.splice(0)) q.resolve(q.filter.ids ? [r] : []); + await wait(() => loading.status === 'ready'); + check('the loader persists the discovered root and intermediate ancestor', () => { + const cached = store.find(e.id); + assert.equal(cached.rootId, r.id); + assert(cached.replies.some((x) => x.id === p.id)); + assert(cached.replies.some((x) => x.id === e.id)); + }); + requests = []; + const refreshed = makeFeature(); + refreshed.ui.noteThread = { rootId: p.id, focusId: e.id, seed: e }; + const restored = refreshed.feature.testThread(e); + check('refresh returns the complete thread synchronously with no loading state', () => { + assert.equal(restored.status, 'ready'); + assert.equal(restored.root.id, r.id); + assert(restored.replies.some((x) => x.id === p.id)); + assert(restored.replies.some((x) => x.id === e.id)); + assert.equal(refreshed.ui.noteThread.rootId, r.id); + }); + await wait(() => waiting.length === 1); + check('background refresh asks for replies to the canonical root', () => { + assert.equal(requests.length, 1); + assert.deepEqual(requests[0]['#e'], [r.id]); + }); + for (const q of waiting.splice(0)) q.resolve([]); + await Bun.sleep(20); + check('an empty relay refresh preserves locally cached context', () => { + assert.equal(restored.root.id, r.id); + assert.equal(restored.replies.length, 2); + }); + const deepLink = makeFeature(); + requests = []; + await deepLink.feature.testOpen({ id: e.id }); + check('a cached note reference opens immediately without an event lookup', () => { + assert.equal(deepLink.ui.noteThread.seed.id, e.id); + assert.equal(requests.length, 0); + }); +} finally { await rm(path, { force: true }); } |
