@@ -1407,3 +1407,166 @@ describe('#3896 close-out — retired shortcut/bulkEnabled', () => {
14071407 expect ( message ) . toMatch ( / # 3 8 9 6 / ) ;
14081408 } ) ;
14091409} ) ;
1410+
1411+ // ---------------------------------------------------------------------------
1412+ // #5016 — per-option `visibleWhen` on an action param's option list
1413+ // ---------------------------------------------------------------------------
1414+
1415+ /**
1416+ * #4001 批 14 closed this option entry at `{ label, value }` and filed the
1417+ * capability question as #5016. #5016 answered it PER KEY, on measurement of
1418+ * what an action param's option list can actually reach in objectui:
1419+ *
1420+ * - `visibleWhen` has a reader on this exact path, so it is declared.
1421+ * - `color` / `default` / `icon` / `disabled` do not, so they stay rejected —
1422+ * with the guidance that says where each vocabulary IS real.
1423+ *
1424+ * Every assertion below goes through a REAL door — `getMetadataTypeSchema('action')`
1425+ * (what `MetadataManager.validate` / `GET /api/v1/meta` / the Studio form use)
1426+ * or `ObjectSchema.actions[]` — rather than through `ActionParamSchema`
1427+ * directly, because the defect #5016 records was not "the sub-schema strips it"
1428+ * but "the key never survives the door an author's metadata actually crosses".
1429+ */
1430+ describe ( '#5016 — action param option vocabulary' , ( ) => {
1431+ const gatedAction = {
1432+ name : 'escalate' ,
1433+ label : 'Escalate' ,
1434+ type : 'script' as const ,
1435+ target : 'escalate_handler' ,
1436+ params : [ {
1437+ name : 'severity' ,
1438+ label : 'Severity' ,
1439+ type : 'select' as const ,
1440+ options : [
1441+ { label : 'Normal' , value : 'normal' } ,
1442+ { label : 'Overload' , value : 'overload' , visibleWhen : "record.status == 'open'" } ,
1443+ ] ,
1444+ } ] ,
1445+ } ;
1446+
1447+ it ( 'SURVIVES the metadata door — declared AND delivered, not declared-then-stripped' , ( ) => {
1448+ const schema = getMetadataTypeSchema ( 'action' ) ;
1449+ expect ( schema , "the 'action' metadata type must resolve to a schema" ) . toBeDefined ( ) ;
1450+ const result = schema ! . safeParse ( gatedAction ) ;
1451+ expect ( result . success , JSON . stringify ( result . error ?. issues ) ) . toBe ( true ) ;
1452+
1453+ // The load-bearing half. Batch 14 measured this same payload coming back as
1454+ // `{"label":"Overload","value":"overload"}` — parsed clean, key gone before
1455+ // any renderer saw it. Asserting only `success` would still pass in that
1456+ // world, which is exactly the ADR-0078 shape this change exists to end.
1457+ const options = ( result . data as any ) . params [ 0 ] . options ;
1458+ expect ( options [ 1 ] ) . toMatchObject ( {
1459+ label : 'Overload' ,
1460+ value : 'overload' ,
1461+ // `ExpressionInputSchema` normalises the authored string into the wire
1462+ // envelope objectui's `evalFieldPredicate` accepts (`FieldRulePredicate =
1463+ // string | { dialect?, source }`).
1464+ visibleWhen : { dialect : 'cel' , source : "record.status == 'open'" } ,
1465+ } ) ;
1466+ // An option that declares no predicate stays predicate-free — `visibleWhen`
1467+ // is optional, not defaulted to an always-true expression.
1468+ expect ( options [ 0 ] . visibleWhen ) . toBeUndefined ( ) ;
1469+ } ) ;
1470+
1471+ it ( 'survives the other real door too — nested in `object.actions[]`' , ( ) => {
1472+ const result = ObjectSchema . safeParse ( {
1473+ name : 'crm_case' ,
1474+ label : 'Case' ,
1475+ fields : { status : { label : 'Status' , type : 'text' } } ,
1476+ actions : [ gatedAction ] ,
1477+ } ) ;
1478+ expect ( result . success , JSON . stringify ( result . error ?. issues ) ) . toBe ( true ) ;
1479+ expect ( ( result . data as any ) . actions [ 0 ] . params [ 0 ] . options [ 1 ] . visibleWhen )
1480+ . toEqual ( { dialect : 'cel' , source : "record.status == 'open'" } ) ;
1481+ } ) ;
1482+
1483+ it ( 'accepts the canonical `{ dialect, source }` envelope as authored' , ( ) => {
1484+ const result = getMetadataTypeSchema ( 'action' ) ! . safeParse ( {
1485+ ...gatedAction ,
1486+ params : [ {
1487+ name : 'severity' ,
1488+ type : 'select' as const ,
1489+ options : [ {
1490+ label : 'Overload' ,
1491+ value : 'overload' ,
1492+ visibleWhen : { dialect : 'cel' , source : "'admin' in current_user.positions" } ,
1493+ } ] ,
1494+ } ] ,
1495+ } ) ;
1496+ expect ( result . success , JSON . stringify ( result . error ?. issues ) ) . toBe ( true ) ;
1497+ } ) ;
1498+
1499+ it ( 'does NOT open the keys whose readers this surface cannot reach' , ( ) => {
1500+ // `color` / `default` are declared one layer down on `SelectOptionSchema`;
1501+ // `icon` / `disabled` are declared nowhere in the spec. Neither group has a
1502+ // consumer an action param's option list reaches — the dialog builds an
1503+ // INPUT from the list and discards it — so both stay rejected. Opening them
1504+ // for vocabulary symmetry would be the parses-clean-changes-nothing key.
1505+ for ( const key of [ 'color' , 'default' , 'icon' , 'disabled' ] ) {
1506+ const result = getMetadataTypeSchema ( 'action' ) ! . safeParse ( {
1507+ ...gatedAction ,
1508+ params : [ {
1509+ name : 'severity' ,
1510+ type : 'select' as const ,
1511+ options : [ { label : 'Overload' , value : 'overload' , [ key ] : key === 'disabled' || key === 'default' ? true : 'x' } ] ,
1512+ } ] ,
1513+ } ) ;
1514+ expect ( result . success , `\`${ key } \` must stay rejected on an action param option` ) . toBe ( false ) ;
1515+ }
1516+ } ) ;
1517+
1518+ it ( 'keeps each rejection pointing at where that vocabulary IS real' , ( ) => {
1519+ const messageFor = ( option : Record < string , unknown > ) : string => {
1520+ const r = getMetadataTypeSchema ( 'action' ) ! . safeParse ( {
1521+ ...gatedAction ,
1522+ params : [ { name : 'severity' , type : 'select' as const , options : [ option ] } ] ,
1523+ } ) ;
1524+ return JSON . stringify ( r . error ?. issues ?? [ ] ) ;
1525+ } ;
1526+
1527+ // `color`: real one layer down, on the STORED-value display path. The
1528+ // sentence must no longer defer to #5016 as an open question — it is
1529+ // decided — and must not promise the field-backed inheritance route, which
1530+ // `normaliseOptions` still drops (ledger finding 18).
1531+ const color = messageFor ( { label : 'A' , value : 'a' , color : 'red' } ) ;
1532+ expect ( color ) . toContain ( 'SelectOptionSchema' ) ;
1533+ expect ( color ) . not . toContain ( 'do not rely on it today' ) ;
1534+
1535+ // `default`: a wrong-LAYER key, not a missing capability. The prescription
1536+ // is the param's own `defaultValue`, one level up.
1537+ expect ( messageFor ( { label : 'A' , value : 'a' , default : true } ) ) . toContain ( 'defaultValue' ) ;
1538+
1539+ // `icon`: declared nowhere — claiming it lives on `SelectOptionSchema`
1540+ // would be the false-prescription class.
1541+ const icon = messageFor ( { label : 'A' , value : 'a' , icon : 'x' } ) ;
1542+ expect ( icon ) . toContain ( 'no option shape in the spec declares' ) ;
1543+ expect ( icon ) . not . toContain ( 'is a per-option key of a FIELD' ) ;
1544+ } ) ;
1545+
1546+ it ( 'points the two rival spellings at the newly declared key' , ( ) => {
1547+ for ( const alias of [ 'visible' , 'showWhen' ] ) {
1548+ const r = getMetadataTypeSchema ( 'action' ) ! . safeParse ( {
1549+ ...gatedAction ,
1550+ params : [ {
1551+ name : 'severity' ,
1552+ type : 'select' as const ,
1553+ options : [ { label : 'A' , value : 'a' , [ alias ] : "record.status == 'open'" } ] ,
1554+ } ] ,
1555+ } ) ;
1556+ expect ( r . success ) . toBe ( false ) ;
1557+ expect ( JSON . stringify ( r . error ?. issues ) ) . toContain ( `\`${ alias } \` → \`visibleWhen\`` ) ;
1558+ }
1559+ } ) ;
1560+
1561+ it ( 'leaves the PARAM-level canonical spelling alone — `visibleWhen` there still means `visible`' , ( ) => {
1562+ // The two surfaces have opposite canonical spellings on purpose (a param
1563+ // gates itself with `visible`; an option gates itself with `visibleWhen`),
1564+ // so opening the option key must not blur the one level up.
1565+ const r = getMetadataTypeSchema ( 'action' ) ! . safeParse ( {
1566+ ...gatedAction ,
1567+ params : [ { name : 'severity' , type : 'text' as const , visibleWhen : 'features.x == true' } ] ,
1568+ } ) ;
1569+ expect ( r . success ) . toBe ( false ) ;
1570+ expect ( JSON . stringify ( r . error ?. issues ) ) . toContain ( '`visibleWhen` → `visible`' ) ;
1571+ } ) ;
1572+ } ) ;
0 commit comments