@@ -60,16 +60,20 @@ function createLogger(): PluginLogger {
6060function createRuntime ( options : {
6161 transmitting ?: boolean ;
6262 operator ?: StandardQSOPluginOperator ;
63+ maxStreams ?: number ;
6364} = { } ) {
6465 let transmitting = options . transmitting ?? false ;
66+ let maxStreams = options . maxStreams ?? 1 ;
6567 const runtimeOptions : AssistedQSOQueueRuntimeOptions = {
6668 operator : options . operator ?? createOperator ( ) ,
6769 isTransmitting : ( ) => transmitting ,
6870 logger : createLogger ( ) ,
71+ getMaxStreams : ( ) => maxStreams ,
6972 } ;
7073 return {
7174 runtime : new AssistedQSOQueueRuntime ( runtimeOptions ) ,
7275 setTransmitting ( value : boolean ) { transmitting = value ; } ,
76+ setMaxStreams ( value : number ) { maxStreams = value ; } ,
7377 logger : runtimeOptions . logger ,
7478 } ;
7579}
@@ -181,6 +185,112 @@ describe('AssistedQSOQueueRuntime queue capability', () => {
181185 expect ( runtime . getQueueSnapshot ( ) . version ) . toBe ( activeVersion ) ;
182186 } ) ;
183187
188+ it ( 'runs two or three stable protocol lanes when configured while defaulting to one' , async ( ) => {
189+ const defaultRuntime = createRuntime ( { transmitting : true } ) . runtime ;
190+ defaultRuntime . enqueueTarget ( { callsign : 'JA1AAA' } ) ;
191+ defaultRuntime . enqueueTarget ( { callsign : 'JA2BBB' } ) ;
192+ const defaultDecision = await defaultRuntime . decide ( [ ] , decision ( ) ) ;
193+ expect ( defaultDecision . transmissions ) . toHaveLength ( 1 ) ;
194+ expect ( defaultRuntime . getQueueSnapshot ( ) ) . toMatchObject ( {
195+ maxActiveStreams : 1 ,
196+ activeEntryIds : [ defaultRuntime . getQueueSnapshot ( ) . rows [ 0 ] ?. entryId ] ,
197+ } ) ;
198+
199+ const parallel = createRuntime ( { transmitting : true , maxStreams : 3 } ) . runtime ;
200+ parallel . enqueueTarget ( { callsign : 'JA1AAA' } ) ;
201+ parallel . enqueueTarget ( { callsign : 'JA2BBB' } ) ;
202+ parallel . enqueueTarget ( { callsign : 'JA3CCC' } ) ;
203+ const parallelDecision = await parallel . decide ( [ ] , decision ( ) ) ;
204+
205+ expect ( parallelDecision . transmissions ) . toMatchObject ( [
206+ { streamId : 'stream-1' , text : expect . stringContaining ( 'JA1AAA' ) } ,
207+ { streamId : 'stream-2' , text : expect . stringContaining ( 'JA2BBB' ) } ,
208+ { streamId : 'stream-3' , text : expect . stringContaining ( 'JA3CCC' ) } ,
209+ ] ) ;
210+ expect ( parallelDecision . transmissions ! . map ( ( item ) => item . audioFrequencyHz ) ) . toEqual ( [
211+ 1_500 ,
212+ 1_560 ,
213+ 1_620 ,
214+ ] ) ;
215+ expect ( parallelDecision . snapshot . streams ) . toHaveLength ( 3 ) ;
216+ expect ( parallel . getQueueSnapshot ( ) . activeEntryIds ) . toHaveLength ( 3 ) ;
217+ } ) ;
218+
219+ it ( 'applies non-preemptive dynamic shrink and uses a newly raised limit on the next decision' , async ( ) => {
220+ const { runtime, setMaxStreams } = createRuntime ( { transmitting : true , maxStreams : 2 } ) ;
221+ runtime . enqueueTarget ( { callsign : 'JA1AAA' } ) ;
222+ runtime . enqueueTarget ( { callsign : 'JA2BBB' } ) ;
223+ runtime . enqueueTarget ( { callsign : 'JA3CCC' } ) ;
224+ await runtime . decide ( [ ] , decision ( ) ) ;
225+ expect ( runtime . getQueueSnapshot ( ) . activeEntryIds ) . toHaveLength ( 2 ) ;
226+
227+ setMaxStreams ( 3 ) ;
228+ await runtime . decide ( [ ] , decision ( 2 ) ) ;
229+ expect ( runtime . getQueueSnapshot ( ) ) . toMatchObject ( { maxActiveStreams : 3 } ) ;
230+ expect ( runtime . getQueueSnapshot ( ) . activeEntryIds ) . toHaveLength ( 3 ) ;
231+
232+ setMaxStreams ( 1 ) ;
233+ await runtime . decide ( [ ] , decision ( 3 ) ) ;
234+ expect ( runtime . getQueueSnapshot ( ) ) . toMatchObject ( { maxActiveStreams : 1 } ) ;
235+ expect ( runtime . getQueueSnapshot ( ) . activeEntryIds ) . toHaveLength ( 3 ) ;
236+ } ) ;
237+
238+ it ( 'routes decoded replies to one standard protocol lane without advancing its peers' , async ( ) => {
239+ const { runtime } = createRuntime ( { transmitting : true , maxStreams : 3 } ) ;
240+ runtime . enqueueTarget ( { callsign : 'JA1AAA' , lastMessage : selected ( 'CQ JA1AAA PM95' ) } ) ;
241+ runtime . enqueueTarget ( { callsign : 'JA2BBB' , lastMessage : selected ( 'CQ JA2BBB PM95' ) } ) ;
242+ runtime . enqueueTarget ( { callsign : 'JA3CCC' , lastMessage : selected ( 'CQ JA3CCC PM95' ) } ) ;
243+ const initial = await runtime . decide ( [ ] , decision ( ) ) ;
244+ runtime . onTransmissionsCompleted ( initial . transmissions ! . map ( ( transmission ) => ( {
245+ ...transmission ,
246+ frameId : 'frame-1' ,
247+ revision : 1 ,
248+ physicalConfirmed : true as const ,
249+ } ) ) ) ;
250+
251+ const replyAt = BASE_TIME + MODES . FT8 . slotMs ;
252+ const reply = parsed ( 'BG5DRB JA1AAA -05' , replyAt , { snr : - 7 } ) ;
253+ runtime . observeDecodedMessages ( [ reply ] , observation ( replyAt ) ) ;
254+ const advanced = await runtime . decide ( [ reply ] , decision ( 2 ) ) ;
255+
256+ expect ( advanced . transmissions ) . toMatchObject ( [
257+ { streamId : 'stream-1' , text : expect . stringContaining ( 'R-07' ) } ,
258+ { streamId : 'stream-2' , text : expect . stringContaining ( 'JA2BBB' ) } ,
259+ { streamId : 'stream-3' , text : expect . stringContaining ( 'JA3CCC' ) } ,
260+ ] ) ;
261+ expect ( advanced . snapshot . streams ) . toMatchObject ( [
262+ { streamId : 'stream-1' , currentState : 'TX3' , targetCallsign : 'JA1AAA' } ,
263+ { streamId : 'stream-2' , currentState : 'TX1' , targetCallsign : 'JA2BBB' } ,
264+ { streamId : 'stream-3' , currentState : 'TX1' , targetCallsign : 'JA3CCC' } ,
265+ ] ) ;
266+ } ) ;
267+
268+ it ( 'hot-updates protocol and physical lane frequencies through one bounded resolver' , async ( ) => {
269+ const source = createOperator ( ) ;
270+ const config = { ...source . config , frequency : 299 } ;
271+ const operator : StandardQSOPluginOperator = {
272+ get config ( ) { return config ; } ,
273+ hasWorkedCallsign : ( callsign , options ) => source . hasWorkedCallsign ( callsign , options ) ,
274+ isTargetBeingWorkedByOthers : ( callsign ) => source . isTargetBeingWorkedByOthers ( callsign ) ,
275+ } ;
276+ const { runtime } = createRuntime ( { transmitting : true , maxStreams : 3 , operator } ) ;
277+ runtime . enqueueTarget ( { callsign : 'JA1AAA' } ) ;
278+ runtime . enqueueTarget ( { callsign : 'JA2BBB' } ) ;
279+ runtime . enqueueTarget ( { callsign : 'JA3CCC' } ) ;
280+ await runtime . decide ( [ ] , decision ( ) ) ;
281+ expect ( runtime . getTransmissions ( ) . map ( ( item ) => item . audioFrequencyHz ) ) . toEqual ( [ 1_500 , 1_560 , 1_620 ] ) ;
282+
283+ config . frequency = 4_700 ;
284+ expect ( runtime . getTransmissions ( ) . map ( ( item ) => item . audioFrequencyHz ) ) . toEqual ( [ 4_700 , 4_760 , 4_820 ] ) ;
285+ expect ( runtime . getSnapshot ( ) . context ?. actualFrequency ) . toBeUndefined ( ) ;
286+
287+ config . frequency = 4_701 ;
288+ expect ( runtime . getTransmissions ( ) . map ( ( item ) => item . audioFrequencyHz ) ) . toEqual ( [ 1_500 , 1_560 , 1_620 ] ) ;
289+
290+ config . mode = MODES . FT4 ;
291+ expect ( runtime . getTransmissions ( ) . map ( ( item ) => item . audioFrequencyHz ) ) . toEqual ( [ 1_500 , 1_600 , 1_700 ] ) ;
292+ } ) ;
293+
184294 it ( 'uses standard TX6 CQ only when no queue target is executable' , async ( ) => {
185295 const { runtime, setTransmitting } = createRuntime ( ) ;
186296 expect ( runtime . getTransmitText ( ) ) . toBeNull ( ) ;
@@ -454,6 +564,35 @@ describe('AssistedQSOQueueRuntime queue capability', () => {
454564 . toEqual ( [ 'JA1AAA' , 'JA2BBB' , 'JA3CCC' ] ) ;
455565 } ) ;
456566
567+ it ( 'gives a preempted lane to the direct opportunity that triggered preemption' , async ( ) => {
568+ const { runtime } = createRuntime ( { transmitting : true , maxStreams : 3 } ) ;
569+ runtime . enqueueTarget ( { callsign : 'JA1AAA' } ) ;
570+ runtime . enqueueTarget ( { callsign : 'JA2BBB' } ) ;
571+ runtime . enqueueTarget ( { callsign : 'JA3CCC' } ) ;
572+ await runtime . decide ( [ ] , decision ( ) ) ;
573+ runtime . enqueueTarget ( { callsign : 'JA4DDD' } ) ;
574+ const directAt = BASE_TIME ;
575+ runtime . observeDecodedMessages ( [
576+ parsed ( 'BG5DRB JA5EEE PM95' , directAt ) ,
577+ ] , observation ( directAt ) ) ;
578+ const promoted = runtime . getQueueSnapshot ( ) ;
579+ const direct = promoted . rows . find ( ( row ) => row . callsign === 'JA5EEE' ) ! ;
580+ expect ( runtime . reorderTarget ( direct . entryId , null , promoted . version ) . snapshot . rows . map ( ( row ) => row . callsign ) )
581+ . toEqual ( [ 'JA1AAA' , 'JA2BBB' , 'JA3CCC' , 'JA4DDD' , 'JA5EEE' ] ) ;
582+
583+ const preempted = await runtime . decide ( [ ] , decision ( 2 ) ) ;
584+
585+ expect ( preempted . transmissions ) . toMatchObject ( [
586+ { streamId : 'stream-1' , text : expect . stringContaining ( 'JA5EEE' ) } ,
587+ { streamId : 'stream-2' , text : expect . stringContaining ( 'JA2BBB' ) } ,
588+ { streamId : 'stream-3' , text : expect . stringContaining ( 'JA3CCC' ) } ,
589+ ] ) ;
590+ expect ( runtime . getQueueSnapshot ( ) . rows . slice ( 0 , 3 ) . map ( ( row ) => row . callsign ) )
591+ . toEqual ( [ 'JA5EEE' , 'JA2BBB' , 'JA3CCC' ] ) ;
592+ expect ( runtime . getQueueSnapshot ( ) . rows . slice ( 3 ) . map ( ( row ) => row . callsign ) )
593+ . toEqual ( [ 'JA4DDD' , 'JA1AAA' ] ) ;
594+ } ) ;
595+
457596 it ( 'releases an unengaged active target when it starts working another station' , async ( ) => {
458597 const { runtime } = createRuntime ( { transmitting : true } ) ;
459598 runtime . enqueueTarget ( { callsign : 'JA1AAA' , lastMessage : selected ( 'CQ JA1AAA PM95' ) } ) ;
@@ -687,6 +826,11 @@ describe('AssistedQSOQueueRuntime queue capability', () => {
687826 currentState : 'TX5' ,
688827 context : { targetCallsign : 'JA1AAA' } ,
689828 } ) ;
829+ expect ( retry . snapshot . streams ) . toMatchObject ( [ {
830+ streamId : 'stream-1' ,
831+ currentState : 'TX1' ,
832+ targetCallsign : 'JA2BBB' ,
833+ } ] ) ;
690834 expect ( retry . qsoCompletion ) . toBeUndefined ( ) ;
691835 expect ( runtime . getQueueSnapshot ( ) ) . toMatchObject ( {
692836 activeEntryId : nextEntryId ,
@@ -701,6 +845,38 @@ describe('AssistedQSOQueueRuntime queue capability', () => {
701845 expect ( runtime . getQueueSnapshot ( ) . activeEntryId ) . toBe ( nextEntryId ) ;
702846 } ) ;
703847
848+ it ( 'clears an unscheduled final-73 lease after the completed row is gone' , async ( ) => {
849+ const { runtime } = createRuntime ( { transmitting : true } ) ;
850+ runtime . enqueueTarget ( {
851+ callsign : 'JA1AAA' ,
852+ lastMessage : selected ( 'BG5DRB JA1AAA -08' ) ,
853+ } ) ;
854+ const first = await runtime . decide ( [ ] , decision ( ) ) ;
855+ runtime . onTransmissionQueued ( first . transmission ! ) ;
856+ const rrr = parsed ( 'BG5DRB JA1AAA RR73' , BASE_TIME + MODES . FT8 . slotMs ) ;
857+ runtime . observeDecodedMessages ( [ rrr ] , observation ( rrr . timestamp ) ) ;
858+ const completion = await runtime . decide ( [ rrr ] , decision ( 2 ) ) ;
859+ runtime . settleQSOCompletion ( {
860+ lifecycleEpoch : completion . qsoCompletion ! . lifecycleEpoch ,
861+ recordId : completion . qsoCompletion ! . record . id ,
862+ status : 'committed' ,
863+ } ) ;
864+ runtime . onTransmissionQueued ( completion . transmission ! ) ;
865+ const releaseAt = BASE_TIME + MODES . FT8 . slotMs * 2 ;
866+ runtime . observeDecodedMessages ( [ ] , observation ( releaseAt ) ) ;
867+ await runtime . decide ( [ ] , decision ( 3 ) ) ;
868+ expect ( runtime . getQueueSnapshot ( ) . rows ) . toHaveLength ( 0 ) ;
869+
870+ const beforeClear = runtime . getQueueSnapshot ( ) ;
871+ const cleared = runtime . clearTargets ( beforeClear . version ) ;
872+ expect ( cleared . snapshot . version ) . toBe ( beforeClear . version + 1 ) ;
873+ const repeated = parsed ( 'BG5DRB JA1AAA RR73' , releaseAt ) ;
874+ runtime . observeDecodedMessages ( [ repeated ] , observation ( releaseAt ) ) ;
875+ const afterClear = await runtime . decide ( [ repeated ] , decision ( 4 ) ) ;
876+ expect ( afterClear . transmission ) . toBe ( 'CQ BG5DRB OL32' ) ;
877+ expect ( afterClear . transmission ) . not . toBe ( 'JA1AAA BG5DRB 73' ) ;
878+ } ) ;
879+
704880 it ( 'does not let a completed target retry preempt an engaged next QSO' , async ( ) => {
705881 const { runtime } = createRuntime ( { transmitting : true } ) ;
706882 const { releaseSlotStartMs } = await prepareFinal73Lease ( runtime ) ;
@@ -720,7 +896,7 @@ describe('AssistedQSOQueueRuntime queue capability', () => {
720896 } ) ;
721897 } ) ;
722898
723- it ( 'holds a delegate-released completion until asynchronous log settlement arrives ' , async ( ) => {
899+ it ( 'releases a delegate completion when durable persistence merged to a different record id ' , async ( ) => {
724900 const { runtime } = createRuntime ( { transmitting : true } ) ;
725901 runtime . enqueueTarget ( {
726902 callsign : 'JA1AAA' ,
@@ -742,6 +918,7 @@ describe('AssistedQSOQueueRuntime queue capability', () => {
742918 runtime . settleQSOCompletion ( {
743919 lifecycleEpoch : completion . qsoCompletion ! . lifecycleEpoch ,
744920 recordId : completion . qsoCompletion ! . record . id ,
921+ persistedRecordId : 'existing-merged-qso' ,
745922 status : 'committed' ,
746923 } ) ;
747924 expect ( runtime . getQueueSnapshot ( ) ) . toMatchObject ( {
0 commit comments