@@ -23,6 +23,7 @@ import {
2323import { streamRowGutter } from "./stream.js" ;
2424import {
2525 APPROVAL_UNAVAILABLE_MESSAGE ,
26+ PRIMARY_ASK_OPERATOR_SOURCE ,
2627 type OperatorGateEvent ,
2728 type PermissionGateEvent ,
2829} from "./gate-events.js" ;
@@ -37,6 +38,7 @@ import {
3738 PERMISSION_ONCE_ID ,
3839 permissionBodyFromRequest ,
3940 permissionChoicesFromRequest ,
41+ type GateLifecycleHooks ,
4042 wireGates ,
4143} from "./gate-wire.js" ;
4244
@@ -61,6 +63,7 @@ type GateCtx = {
6163
6264type GateOpts = {
6365 readonly terminal ?: { readonly columns : number ; readonly rows : number } ;
66+ readonly hooks ?: Partial < GateLifecycleHooks > ;
6467} ;
6568
6669async function withGates (
@@ -71,7 +74,11 @@ async function withGates(
7174 await withAppShell (
7275 async ( shell , h ) => {
7376 const emitter = new EventEmitter ( ) ;
74- const disposeGates = wireGates ( emitter , shell ) ;
77+ const disposeGates = wireGates ( emitter , shell , {
78+ onGateOpened : ( ) => undefined ,
79+ onGateClosed : ( ) => undefined ,
80+ ...opts . hooks ,
81+ } ) ;
7582 try {
7683 await fn ( { h, shell, emitter, disposeGates } ) ;
7784 } finally {
@@ -484,6 +491,107 @@ describe("wireGates", () => {
484491 } ) ;
485492 } ) ;
486493
494+ test ( "marked primary asks notify admission and settlement exactly once" , async ( ) => {
495+ const admitted : string [ ] = [ ] ;
496+ const settled : string [ ] = [ ] ;
497+ await withGates (
498+ async ( { shell, emitter } ) => {
499+ emitOperator ( emitter , { source : PRIMARY_ASK_OPERATOR_SOURCE } ) ;
500+ expect ( admitted ) . toEqual ( [ "ask-1" ] ) ;
501+ acceptOverlaySelection ( shell ) ;
502+ expect ( settled ) . toEqual ( [ "ask-1" ] ) ;
503+ closeInsetOverlay ( shell ) ;
504+ expect ( settled ) . toEqual ( [ "ask-1" ] ) ;
505+ } ,
506+ {
507+ hooks : {
508+ onPrimaryOperatorAdmitted : ( event ) => admitted . push ( event . id ) ,
509+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
510+ } ,
511+ } ,
512+ ) ;
513+ } ) ;
514+
515+ test ( "re-emitted marked primary ask keeps one live gate and resolves once" , async ( ) => {
516+ const admitted : string [ ] = [ ] ;
517+ const settled : string [ ] = [ ] ;
518+ const resolved : unknown [ ] = [ ] ;
519+ await withGates (
520+ async ( { shell, emitter } ) => {
521+ const event = {
522+ id : "same-ask" ,
523+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
524+ resolve : ( result : unknown ) => resolved . push ( result ) ,
525+ } ;
526+ emitOperator ( emitter , event ) ;
527+ emitOperator ( emitter , event ) ;
528+
529+ expect ( admitted ) . toEqual ( [ "same-ask" ] ) ;
530+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
531+
532+ acceptOverlaySelection ( shell ) ;
533+
534+ expect ( settled ) . toEqual ( [ "same-ask" ] ) ;
535+ expect ( resolved ) . toEqual ( [ { kind : "option" , index : 0 } ] ) ;
536+ expect ( shell . overlayList ) . toBeNull ( ) ;
537+ } ,
538+ {
539+ hooks : {
540+ onPrimaryOperatorAdmitted : ( event ) => admitted . push ( event . id ) ,
541+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
542+ } ,
543+ } ,
544+ ) ;
545+ } ) ;
546+
547+ test ( "unmarked operator gates retain modal behavior without input-required hooks" , async ( ) => {
548+ let admitted = 0 ;
549+ let settled = 0 ;
550+ await withGates (
551+ async ( { shell, emitter } ) => {
552+ emitOperator ( emitter ) ;
553+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
554+ acceptOverlaySelection ( shell ) ;
555+ expect ( admitted ) . toBe ( 0 ) ;
556+ expect ( settled ) . toBe ( 0 ) ;
557+ } ,
558+ {
559+ hooks : {
560+ onPrimaryOperatorAdmitted : ( ) => admitted ++ ,
561+ onPrimaryOperatorSettled : ( ) => settled ++ ,
562+ } ,
563+ } ,
564+ ) ;
565+ } ) ;
566+
567+ test ( "a marked queued ask settles its own indicator on abort and disposal" , async ( ) => {
568+ const settled : string [ ] = [ ] ;
569+ await withGates (
570+ async ( { emitter, disposeGates } ) => {
571+ emitOperator ( emitter , {
572+ id : "open" ,
573+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
574+ } ) ;
575+ const controller = new AbortController ( ) ;
576+ emitOperator ( emitter , {
577+ id : "queued" ,
578+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
579+ signal : controller . signal ,
580+ } ) ;
581+ controller . abort ( ) ;
582+ expect ( settled ) . toEqual ( [ "queued" ] ) ;
583+ disposeGates ( ) ;
584+ expect ( settled ) . toEqual ( [ "queued" , "open" ] ) ;
585+ } ,
586+ {
587+ hooks : {
588+ onPrimaryOperatorAdmitted : ( ) => undefined ,
589+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
590+ } ,
591+ } ,
592+ ) ;
593+ } ) ;
594+
487595 test ( "sequential operator asks paint B's labels and id-scoped values, not A's" , async ( ) => {
488596 await withGates ( async ( { shell, emitter } ) => {
489597 const settledA = settleCapture ( ) ;
0 commit comments