@@ -2,13 +2,52 @@ import assert from "node:assert/strict";
22import test from "node:test" ;
33
44import {
5+ ReadStateManager ,
56 applyRemoteContextTimestamp ,
67 evictDominatedEntries ,
78 resolveEffectiveTimestamp ,
89 splitContextsIntoBudgetedSlots ,
910 trimContextsToBudget ,
1011} from "./readStateManager.ts" ;
1112
13+ // ── ReadStateManager integration helpers ─────────────────────────────────────
14+ // Provide browser globals required by ReadStateManager (localStorage,
15+ // window.setTimeout/clearTimeout). Each test that uses ReadStateManager
16+ // constructs a fresh in-memory store so tests are isolated.
17+
18+ function makeLocalStorage ( ) {
19+ const store = new Map ( ) ;
20+ return {
21+ getItem : ( key ) => store . get ( key ) ?? null ,
22+ setItem : ( key , value ) => store . set ( key , value ) ,
23+ removeItem : ( key ) => store . delete ( key ) ,
24+ } ;
25+ }
26+
27+ // Install browser globals required by ReadStateManager. window.localStorage is
28+ // replaced per-test for isolation; the bare `localStorage` global proxies to it.
29+ {
30+ const ls = makeLocalStorage ( ) ;
31+ if ( typeof globalThis . window === "undefined" ) {
32+ globalThis . window = {
33+ localStorage : ls ,
34+ clearTimeout : ( id ) => clearTimeout ( id ) ,
35+ setTimeout : ( fn , ms ) => setTimeout ( fn , ms ) ,
36+ } ;
37+ } else {
38+ globalThis . window . localStorage = ls ;
39+ if ( ! globalThis . window . clearTimeout ) {
40+ globalThis . window . clearTimeout = ( id ) => clearTimeout ( id ) ;
41+ globalThis . window . setTimeout = ( fn , ms ) => setTimeout ( fn , ms ) ;
42+ }
43+ }
44+ // Ensure bare `localStorage` always proxies to window.localStorage.
45+ Object . defineProperty ( globalThis , "localStorage" , {
46+ get : ( ) => globalThis . window . localStorage ,
47+ configurable : true ,
48+ } ) ;
49+ }
50+
1251const threadKey = `thread:${ "a" . repeat ( 64 ) } ` ;
1352const channelKey = "channel-1" ;
1453const channelResolver = ( ctx ) =>
@@ -572,3 +611,70 @@ test("splitContextsIntoBudgetedSlots_threadMsgTrimmedWhenPrimarySlotOverBudget",
572611 const size = blobSize ( CLIENT_ID , result . slots [ 0 ] ) ;
573612 assert . ok ( size <= budget , `slot 0 size ${ size } exceeds budget ${ budget } ` ) ;
574613} ) ;
614+
615+ // ── ReadStateManager.publish — no-op suppression in split mode ────────────────
616+
617+ // Verify that publishSplitSlots returns early (no relay writes) when the
618+ // union of all slot contexts is identical to lastPublishedContexts.
619+ //
620+ // Strategy: construct a ReadStateManager with enough channel keys to force
621+ // split mode, then mock publishOneSlot (private, accessed via bracket notation)
622+ // to avoid tauri calls while still simulating its effect on lastPublishedContexts.
623+ // Call publish() twice with the same effectiveState and assert that
624+ // publishOneSlot is called only on the first publish (no-op on the second).
625+ test ( "publishSplitSlots_noopSuppression_skipsWhenUnchanged" , async ( ) => {
626+ // Isolate localStorage so slot IDs don't leak between tests.
627+ globalThis . window . localStorage = makeLocalStorage ( ) ;
628+
629+ const fakeRelay = {
630+ fetchEvents : async ( ) => [ ] ,
631+ publishEvent : async ( ) => { } ,
632+ subscribeLive : ( ) => ( ) => { } ,
633+ } ;
634+
635+ const pubkey = "b" . repeat ( 64 ) ;
636+ const mgr = new ReadStateManager ( pubkey , fakeRelay ) ;
637+
638+ // Add enough channel keys to exceed the 32KB single-slot budget.
639+ // Each key is ~70 bytes in the blob; 700 keys ≈ 49KB > 32KB.
640+ const ts = 1_000_000 ;
641+ for ( let i = 0 ; i < 700 ; i ++ ) {
642+ const channelId = `channel-${ i . toString ( ) . padStart ( 64 , "0" ) } ` ;
643+ mgr . markContextRead ( channelId , ts ) ;
644+ }
645+
646+ // Confirm split mode: currentContexts() must return null.
647+ assert . equal (
648+ mgr . currentContexts ( ) ,
649+ null ,
650+ "precondition: 700 channel keys must exceed single-slot budget" ,
651+ ) ;
652+
653+ // Replace publishOneSlot with a stub that records calls and simulates the
654+ // lastPublishedContexts merge (the only side-effect the no-op check depends
655+ // on). This avoids tauri (nip44EncryptToSelf / signRelayEvent) while keeping
656+ // the suppression logic under test.
657+ let publishOneSlotCallCount = 0 ;
658+ mgr . publishOneSlot = async ( _slotId , contexts ) => {
659+ publishOneSlotCallCount ++ ;
660+ for ( const [ key , tsVal ] of Object . entries ( contexts ) ) {
661+ mgr . lastPublishedContexts [ key ] = tsVal ;
662+ }
663+ } ;
664+
665+ // First publish: contexts differ from lastPublishedContexts ({}) → must publish.
666+ await mgr . publish ( ) ;
667+ const callsAfterFirst = publishOneSlotCallCount ;
668+ assert . ok ( callsAfterFirst > 0 , "first publish must call publishOneSlot" ) ;
669+
670+ // Second publish with identical effectiveState: union equals lastPublishedContexts
671+ // → no-op suppression must fire → publishOneSlot must NOT be called again.
672+ await mgr . publish ( ) ;
673+ assert . equal (
674+ publishOneSlotCallCount ,
675+ callsAfterFirst ,
676+ "second publish with unchanged state must not call publishOneSlot (no-op suppression)" ,
677+ ) ;
678+
679+ mgr . destroy ( ) ;
680+ } ) ;
0 commit comments