@@ -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" ;
@@ -36,6 +37,7 @@ import {
3637 PERMISSION_ONCE_ID ,
3738 permissionBodyFromRequest ,
3839 permissionChoicesFromRequest ,
40+ type GateLifecycleHooks ,
3941 wireGates ,
4042} from "./gate-wire.js" ;
4143
@@ -60,6 +62,7 @@ type GateCtx = {
6062
6163type GateOpts = {
6264 readonly terminal ?: { readonly columns : number ; readonly rows : number } ;
65+ readonly hooks ?: Partial < GateLifecycleHooks > ;
6366} ;
6467
6568async function withGates (
@@ -70,7 +73,11 @@ async function withGates(
7073 await withAppShell (
7174 async ( shell , h ) => {
7275 const emitter = new EventEmitter ( ) ;
73- const disposeGates = wireGates ( emitter , shell ) ;
76+ const disposeGates = wireGates ( emitter , shell , {
77+ onGateOpened : ( ) => undefined ,
78+ onGateClosed : ( ) => undefined ,
79+ ...opts . hooks ,
80+ } ) ;
7481 try {
7582 await fn ( { h, shell, emitter, disposeGates } ) ;
7683 } finally {
@@ -463,6 +470,107 @@ describe("wireGates", () => {
463470 } ) ;
464471 } ) ;
465472
473+ test ( "marked primary asks notify admission and settlement exactly once" , async ( ) => {
474+ const admitted : string [ ] = [ ] ;
475+ const settled : string [ ] = [ ] ;
476+ await withGates (
477+ async ( { shell, emitter } ) => {
478+ emitOperator ( emitter , { source : PRIMARY_ASK_OPERATOR_SOURCE } ) ;
479+ expect ( admitted ) . toEqual ( [ "ask-1" ] ) ;
480+ acceptOverlaySelection ( shell ) ;
481+ expect ( settled ) . toEqual ( [ "ask-1" ] ) ;
482+ closeInsetOverlay ( shell ) ;
483+ expect ( settled ) . toEqual ( [ "ask-1" ] ) ;
484+ } ,
485+ {
486+ hooks : {
487+ onPrimaryOperatorAdmitted : ( event ) => admitted . push ( event . id ) ,
488+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
489+ } ,
490+ } ,
491+ ) ;
492+ } ) ;
493+
494+ test ( "re-emitted marked primary ask keeps one live gate and resolves once" , async ( ) => {
495+ const admitted : string [ ] = [ ] ;
496+ const settled : string [ ] = [ ] ;
497+ const resolved : unknown [ ] = [ ] ;
498+ await withGates (
499+ async ( { shell, emitter } ) => {
500+ const event = {
501+ id : "same-ask" ,
502+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
503+ resolve : ( result : unknown ) => resolved . push ( result ) ,
504+ } ;
505+ emitOperator ( emitter , event ) ;
506+ emitOperator ( emitter , event ) ;
507+
508+ expect ( admitted ) . toEqual ( [ "same-ask" ] ) ;
509+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
510+
511+ acceptOverlaySelection ( shell ) ;
512+
513+ expect ( settled ) . toEqual ( [ "same-ask" ] ) ;
514+ expect ( resolved ) . toEqual ( [ { kind : "option" , index : 0 } ] ) ;
515+ expect ( shell . overlayList ) . toBeNull ( ) ;
516+ } ,
517+ {
518+ hooks : {
519+ onPrimaryOperatorAdmitted : ( event ) => admitted . push ( event . id ) ,
520+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
521+ } ,
522+ } ,
523+ ) ;
524+ } ) ;
525+
526+ test ( "unmarked operator gates retain modal behavior without input-required hooks" , async ( ) => {
527+ let admitted = 0 ;
528+ let settled = 0 ;
529+ await withGates (
530+ async ( { shell, emitter } ) => {
531+ emitOperator ( emitter ) ;
532+ expect ( shell . overlayKind ) . toBe ( "operator" ) ;
533+ acceptOverlaySelection ( shell ) ;
534+ expect ( admitted ) . toBe ( 0 ) ;
535+ expect ( settled ) . toBe ( 0 ) ;
536+ } ,
537+ {
538+ hooks : {
539+ onPrimaryOperatorAdmitted : ( ) => admitted ++ ,
540+ onPrimaryOperatorSettled : ( ) => settled ++ ,
541+ } ,
542+ } ,
543+ ) ;
544+ } ) ;
545+
546+ test ( "a marked queued ask settles its own indicator on abort and disposal" , async ( ) => {
547+ const settled : string [ ] = [ ] ;
548+ await withGates (
549+ async ( { emitter, disposeGates } ) => {
550+ emitOperator ( emitter , {
551+ id : "open" ,
552+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
553+ } ) ;
554+ const controller = new AbortController ( ) ;
555+ emitOperator ( emitter , {
556+ id : "queued" ,
557+ source : PRIMARY_ASK_OPERATOR_SOURCE ,
558+ signal : controller . signal ,
559+ } ) ;
560+ controller . abort ( ) ;
561+ expect ( settled ) . toEqual ( [ "queued" ] ) ;
562+ disposeGates ( ) ;
563+ expect ( settled ) . toEqual ( [ "queued" , "open" ] ) ;
564+ } ,
565+ {
566+ hooks : {
567+ onPrimaryOperatorAdmitted : ( ) => undefined ,
568+ onPrimaryOperatorSettled : ( event ) => settled . push ( event . id ) ,
569+ } ,
570+ } ,
571+ ) ;
572+ } ) ;
573+
466574 test ( "sequential operator asks paint B's labels and id-scoped values, not A's" , async ( ) => {
467575 await withGates ( async ( { shell, emitter } ) => {
468576 const settledA = settleCapture ( ) ;
0 commit comments