@@ -41,31 +41,40 @@ function createContestContext(options: {
4141 contestYear ?: number ;
4242 logBookId ?: string | null ;
4343 records ?: QSORecord [ ] ;
44+ awaitReady ?: ( ) => Promise < void > ;
45+ simulation ?: boolean ;
4446} = { } ) {
45- const queryQSOs = vi . fn ( async ( ) => options . records ?? [ ] ) ;
47+ const queryQSOs = vi . fn ( async ( _filter ?: unknown ) => options . records ?? [ ] ) ;
4648 const ctx = createMockContext ( {
47- permissions : [ 'logbook:read ' , 'operator:transmit-control' , 'plugin:event-bus' ] as const ,
49+ permissions : [ 'logbook:session ' , 'operator:transmit-control' , 'plugin:event-bus' ] as const ,
4850 callsign : 'BG5DRB' ,
4951 grid : 'OL32' ,
52+ radio : { isSimulation : options . simulation === true } ,
5053 config : {
5154 contestYear : options . contestYear ?? 2026 ,
5255 location : 'DX' ,
5356 categoryBand : 'ALL' ,
5457 categoryPower : 'LOW' ,
5558 } ,
56- logbook : {
57- forCallsign : ( ) => ( {
58- callsign : 'BG5DRB' ,
59- getLogBookId : async ( ) => options . logBookId === undefined ? 'logbook-BG5DRB' : options . logBookId ,
60- queryQSOs,
61- readQsoSnapshot : async ( ) => ( { revision : 'revision-1' , records : [ ] } ) ,
62- countQSOs : async ( ) => 0 ,
63- getStatistics : async ( ) => null ,
64- addQSO : async ( record : QSORecord ) => record ,
65- updateQSO : async ( _id : string , updates : Partial < QSORecord > ) => qsoRecord ( 'updated' , 0 , updates . contestId ) ,
66- applyQsoBatch : async ( ) => ( { revision : 'revision-1' , outcomes : [ ] } ) ,
67- notifyUpdated : async ( ) => { } ,
68- } ) ,
59+ logbookSessions : {
60+ open : async ( descriptor ) => {
61+ if ( options . logBookId === null ) throw new Error ( 'WW Digi logbook is unavailable' ) ;
62+ return {
63+ id : options . logBookId ?? 'plugin-session-ww-digi-2026' ,
64+ title : descriptor . title ,
65+ callsign : 'BG5DRB' ,
66+ getLogBookId : async ( ) => options . logBookId ?? 'plugin-session-ww-digi-2026' ,
67+ awaitReady : options . awaitReady ?? ( async ( ) => { } ) ,
68+ queryQSOs,
69+ readQsoSnapshot : async ( ) => ( { revision : 'revision-1' , records : [ ] } ) ,
70+ countQSOs : async ( ) => 0 ,
71+ getStatistics : async ( ) => null ,
72+ addQSO : async ( record : QSORecord ) => record ,
73+ updateQSO : async ( _id : string , updates : Partial < QSORecord > ) => qsoRecord ( 'updated' , 0 , updates . contestId ) ,
74+ applyQsoBatch : async ( ) => ( { revision : 'revision-1' , outcomes : [ ] } ) ,
75+ notifyUpdated : async ( ) => { } ,
76+ } ;
77+ } ,
6978 } ,
7079 } ) ;
7180 return { ctx, queryQSOs } ;
@@ -80,7 +89,27 @@ describe('WW Digi contest edition persistence', () => {
8089 } ) ;
8190 } ) ;
8291
83- it ( 'reconciles all eligible FT4/FT8 records into a shared callsign/year session' , async ( ) => {
92+ it ( 'waits for the host logbook readiness signal before startup reconciliation' , async ( ) => {
93+ let releaseReady ! : ( ) => void ;
94+ const awaitReady = vi . fn ( )
95+ . mockImplementationOnce ( ( ) => new Promise < void > ( ( resolve ) => { releaseReady = resolve ; } ) )
96+ . mockResolvedValue ( undefined ) ;
97+ const { ctx, queryQSOs } = createContestContext ( { awaitReady } ) ;
98+ const loading = wwDigiStrategyPlugin . onLoad ! ( ctx as never ) ;
99+
100+ await vi . waitFor ( ( ) => expect ( awaitReady ) . toHaveBeenCalledOnce ( ) ) ;
101+ expect ( queryQSOs ) . not . toHaveBeenCalled ( ) ;
102+
103+ releaseReady ( ) ;
104+ await loading ;
105+ expect ( queryQSOs ) . toHaveBeenCalledOnce ( ) ;
106+ expect ( ctx . store . operator . get ( wwDigiTestables . runtimeLogbookIdKey ( 2026 ) ) )
107+ . toBe ( 'plugin-session-ww-digi-2026' ) ;
108+ expect ( ctx . store . global . get < { health ?: { state ?: string } } > ( wwDigiTestables . sessionKey ( 'BG5DRB' , 2026 ) ) ?. health )
109+ . toMatchObject ( { state : 'healthy' } ) ;
110+ } ) ;
111+
112+ it ( 'refreshes the contest projection only from its independent session' , async ( ) => {
84113 const in2026 = qsoRecord ( 'qso-2026' , Date . UTC ( 2026 , 7 , 29 , 12 , 0 ) ) ;
85114 const in2025 = qsoRecord ( 'qso-2025' , Date . UTC ( 2025 , 7 , 30 , 12 , 0 ) ) ;
86115 const nonContest = qsoRecord ( 'not-ww-digi' , Date . UTC ( 2026 , 7 , 29 , 12 , 1 ) , 'OTHER' ) ;
@@ -90,7 +119,7 @@ describe('WW Digi contest edition persistence', () => {
90119 [ contestQso ( 'retained-2025' , Date . UTC ( 2025 , 7 , 30 , 12 ) ) ] ,
91120 ) ;
92121
93- await expect ( wwDigiTestables . reconcileLedger ( ctx , 2026 ) ) . resolves . toEqual ( { imported : 1 , total : 2 } ) ;
122+ await expect ( wwDigiTestables . refreshContestProjection ( ctx , 2026 ) ) . resolves . toEqual ( { total : 1 } ) ;
94123
95124 expect ( queryQSOs ) . toHaveBeenCalledWith ( expect . objectContaining ( {
96125 orderDirection : 'asc' ,
@@ -104,23 +133,52 @@ describe('WW Digi contest edition persistence', () => {
104133 expect ( await wwDigiTestables . readContestRecords ( ctx , 2026 ) )
105134 . toEqual ( expect . arrayContaining ( [
106135 expect . objectContaining ( { qsoId : 'qso-2026' , source : 'ww-digi' } ) ,
107- expect . objectContaining ( { qsoId : 'not-ww-digi' , source : 'reconciled' } ) ,
108136 ] ) ) ;
109137 expect ( ctx . store . operator . get < ContestQso [ ] > ( wwDigiTestables . ledgerKey ( 2025 ) ) )
110138 . toEqual ( [ expect . objectContaining ( { qsoId : 'retained-2025' } ) ] ) ;
111139 expect ( ctx . store . global . get < { health ?: { state ?: string } } > ( wwDigiTestables . sessionKey ( 'BG5DRB' , 2026 ) ) ?. health )
112140 . toEqual ( expect . objectContaining ( { state : 'healthy' } ) ) ;
113141 } ) ;
114142
143+ it ( 'includes out-of-period FT4/FT8 records only for a virtual radio' , async ( ) => {
144+ const outsidePeriod = qsoRecord ( 'virtual-qso' , Date . UTC ( 2026 , 7 , 28 , 9 , 20 , 15 ) ) ;
145+ const physical = createContestContext ( { records : [ outsidePeriod ] } ) ;
146+ const virtual = createContestContext ( { records : [ outsidePeriod ] , simulation : true } ) ;
147+
148+ await expect ( wwDigiTestables . refreshContestProjection ( physical . ctx , 2026 ) )
149+ . resolves . toEqual ( { total : 0 } ) ;
150+ expect ( physical . queryQSOs ) . toHaveBeenCalledWith ( expect . objectContaining ( {
151+ timeRange : expect . any ( Object ) ,
152+ } ) ) ;
153+
154+ await expect ( wwDigiTestables . refreshContestProjection ( virtual . ctx , 2026 ) )
155+ . resolves . toEqual ( { total : 1 } ) ;
156+ expect ( virtual . queryQSOs . mock . calls [ 0 ] ?. [ 0 ] ) . not . toHaveProperty ( 'timeRange' ) ;
157+ await expect ( wwDigiTestables . readContestRecords ( virtual . ctx , 2026 ) )
158+ . resolves . toEqual ( [ expect . objectContaining ( { qsoId : 'virtual-qso' } ) ] ) ;
159+ } ) ;
160+
115161 it ( 'marks an unavailable logbook degraded and refuses Cabrillo rendering' , async ( ) => {
116162 const { ctx } = createContestContext ( { logBookId : null } ) ;
117163
118- await expect ( wwDigiTestables . reconcileLedgerWithHealth ( ctx , 2026 ) )
164+ await expect ( wwDigiTestables . refreshContestProjectionWithHealth ( ctx , 2026 ) )
119165 . rejects . toThrow ( / l o g b o o k i s u n a v a i l a b l e / ) ;
120166 expect ( ctx . store . global . get < { health ?: { state ?: string } } > ( wwDigiTestables . sessionKey ( 'BG5DRB' , 2026 ) ) ?. health )
121167 . toEqual ( expect . objectContaining ( { state : 'degraded' } ) ) ;
122168 await expect ( wwDigiTestables . renderCabrillo ( ctx , 2026 ) )
123- . rejects . toThrow ( / r e c o n c i l e i t b e f o r e d o w n l o a d / ) ;
169+ . rejects . toThrow ( / l o g b o o k i s u n a v a i l a b l e / ) ;
170+ } ) ;
171+
172+ it ( 'exports only independent WW Digi session records as standard ADIF' , async ( ) => {
173+ const contest = qsoRecord ( 'contest' , Date . UTC ( 2026 , 7 , 29 , 12 , 0 ) ) ;
174+ const unrelated = { ...qsoRecord ( 'other' , Date . UTC ( 2026 , 7 , 29 , 12 , 1 ) , 'OTHER' ) , callsign : 'K1OTHER' } ;
175+ const { ctx } = createContestContext ( { records : [ contest , unrelated ] } ) ;
176+
177+ const adif = await wwDigiTestables . renderADIF ( ctx , 2026 ) ;
178+ expect ( adif ) . toContain ( '<programid:13>TX5DR-WW-DIGI' ) ;
179+ expect ( adif ) . toContain ( '<call:6>JA1AAA' ) ;
180+ expect ( adif ) . toContain ( '<station_callsign:6>BG5DRB' ) ;
181+ expect ( adif ) . not . toContain ( 'K1OTHER' ) ;
124182 } ) ;
125183
126184 it ( 'migrates a schema v1 session to an unconfirmed v2 session without losing overrides' , async ( ) => {
@@ -138,7 +196,7 @@ describe('WW Digi contest edition persistence', () => {
138196 operatorTransmitters : { } , migratedOperators : { } , health : { state : 'healthy' } ,
139197 } ) ;
140198
141- await wwDigiTestables . reconcileLedger ( ctx , 2026 ) ;
199+ await wwDigiTestables . refreshContestProjection ( ctx , 2026 ) ;
142200 expect ( ctx . store . global . get < Record < string , unknown > > ( key ) ) . toMatchObject ( {
143201 schemaVersion : 2 ,
144202 setup : { status : 'unconfirmed' } ,
@@ -161,6 +219,25 @@ describe('WW Digi contest edition persistence', () => {
161219 . toEqual ( expect . objectContaining ( { 'qso-2026' : expect . objectContaining ( { operatorId : 'operator-0' , transmitterId : 0 } ) } ) ) ;
162220 } ) ;
163221
222+ it ( 'records an out-of-period completion only when the active radio is virtual' , async ( ) => {
223+ const record = qsoRecord ( 'local-simulation-qso' , Date . UTC ( 2026 , 7 , 28 , 9 , 20 , 15 ) ) ;
224+ const physical = createContestContext ( { records : [ record ] } ) ;
225+ const virtual = createContestContext ( { records : [ record ] , simulation : true } ) ;
226+ const hook = wwDigiStrategyPlugin . hooks ?. onQSOComplete ;
227+
228+ await hook ! ( record , physical . ctx ) ;
229+ expect ( physical . ctx . store . global . get < { overrides ?: Record < string , unknown > } > (
230+ wwDigiTestables . sessionKey ( 'BG5DRB' , 2026 ) ,
231+ ) ?. overrides ) . toBeUndefined ( ) ;
232+
233+ await hook ! ( record , virtual . ctx ) ;
234+ expect ( virtual . ctx . store . global . get < { overrides ?: Record < string , unknown > } > (
235+ wwDigiTestables . sessionKey ( 'BG5DRB' , 2026 ) ,
236+ ) ?. overrides ) . toEqual ( expect . objectContaining ( {
237+ 'local-simulation-qso' : expect . objectContaining ( { operatorId : 'operator-0' } ) ,
238+ } ) ) ;
239+ } ) ;
240+
164241 it ( 'defaults the setting to the current UTC year with bounded input' , ( ) => {
165242 const descriptor = wwDigiStrategyPlugin . settings ?. contestYear ;
166243 expect ( descriptor ) . toMatchObject ( {
0 commit comments