aboutsummaryrefslogtreecommitdiffstats
path: root/tools/thread-cache-test.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-23 22:51:58 -0700
committerAdam Soltys <[email protected]>2026-09-23 22:51:58 -0700
commit75679fdd5d24d36e3c54316e88ccefcf954afc82 (patch)
treec86a369455da3c2e9532d51d2e72898afda8fccc /tools/thread-cache-test.js
parentfb170b4783c9b49c73e926c08aba9bc4d1bb418e (diff)
downloadcoinosv3-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.js120
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 }); }