@@ -4,6 +4,11 @@ import { runAuthoringRules, splitBySeverity } from './authoring-rules.js';
44import {
55 validateWidgetBindings ,
66 MARK_MIXING_CHART_TYPES ,
7+ METRIC_WIDGET_TYPES ,
8+ TABULAR_WIDGET_TYPES ,
9+ CHART_FAMILY_WIDGET_TYPES ,
10+ CHART_MEASURES_MISSING ,
11+ CHART_DIMENSIONS_MISSING ,
712 TABLE_COUNT_ONLY ,
813 WIDGET_DATASET_UNKNOWN ,
914 WIDGET_DIMENSION_UNKNOWN ,
@@ -1447,3 +1452,224 @@ describe('#14148 acceptance — both limbs gate `validate` AND `build`', () => {
14471452 } ) ;
14481453 }
14491454} ) ;
1455+
1456+ /**
1457+ * [#15462] The two empty-selection shapes. Both are read at the `@object-ui`
1458+ * revision this repo PINS (`.objectui-sha`), in
1459+ * `packages/plugin-dashboard/src/DatasetWidget.tsx`:
1460+ *
1461+ * - `:683` — `if (values.length === 0)` returns the authoring placeholder
1462+ * "Pick measures (values) for this dataset widget.", above every family
1463+ * branch, so no chart is drawn at all;
1464+ * - `:423` — `const isMetric = METRIC_TYPES.has(widgetType) ||
1465+ * dimensions.length === 0;`, so a dimensionless `bar` renders as a KPI
1466+ * number instead of the family the author declared.
1467+ *
1468+ * Warning tier for both: an empty selection is a state an author passes
1469+ * through, and this family's errors are reserved for bindings the analytics
1470+ * service cannot satisfy.
1471+ */
1472+ describe ( 'chart-measures-missing / chart-dimensions-missing (#15462)' , ( ) => {
1473+ const rules = ( findings : { rule : string } [ ] ) => findings . map ( ( f ) => f . rule ) ;
1474+
1475+ it ( '(1) warns when a chart-family widget selects no measures' , ( ) => {
1476+ const findings = validateWidgetBindings ( chartStack ( { values : [ ] , chartConfig : undefined } ) ) ;
1477+ expect ( findings ) . toHaveLength ( 1 ) ;
1478+ expect ( findings [ 0 ] . severity ) . toBe ( 'warning' ) ;
1479+ expect ( findings [ 0 ] . rule ) . toBe ( CHART_MEASURES_MISSING ) ;
1480+ expect ( findings [ 0 ] . where ) . toContain ( 'spend_by_category' ) ;
1481+ expect ( findings [ 0 ] . path ) . toBe ( 'dashboards[0].widgets[0]' ) ;
1482+ // The message names the PINNED consequence — the placeholder string the
1483+ // renderer returns — not an inferred one.
1484+ expect ( findings [ 0 ] . message ) . toContain ( 'Pick measures (values) for this dataset widget.' ) ;
1485+ expect ( findings [ 0 ] . message ) . toContain ( 'no chart is drawn at all' ) ;
1486+ expect ( findings [ 0 ] . hint ) . toContain ( 'declared measures: sum_amount, ticket_count' ) ;
1487+ expect ( findings [ 0 ] . hint ) . toContain ( `suppressWarnings: ['${ CHART_MEASURES_MISSING } ']` ) ;
1488+ } ) ;
1489+
1490+ it ( '(1) an absent `values` key reports the same shape as an empty array' , ( ) => {
1491+ const stack = chartStack ( { chartConfig : undefined } ) ;
1492+ delete ( stack as { dashboards : { widgets : Record < string , unknown > [ ] } [ ] } )
1493+ . dashboards [ 0 ] . widgets [ 0 ] . values ;
1494+ const findings = validateWidgetBindings ( stack ) ;
1495+ expect ( rules ( findings ) ) . toEqual ( [ CHART_MEASURES_MISSING ] ) ;
1496+ } ) ;
1497+
1498+ it ( '(2) warns when a chart-family widget selects no dimensions' , ( ) => {
1499+ const findings = validateWidgetBindings ( chartStack ( { dimensions : [ ] , chartConfig : undefined } ) ) ;
1500+ expect ( findings ) . toHaveLength ( 1 ) ;
1501+ expect ( findings [ 0 ] . severity ) . toBe ( 'warning' ) ;
1502+ expect ( findings [ 0 ] . rule ) . toBe ( CHART_DIMENSIONS_MISSING ) ;
1503+ expect ( findings [ 0 ] . path ) . toBe ( 'dashboards[0].widgets[0]' ) ;
1504+ // The pinned expression, quoted, and the consequence it produces.
1505+ expect ( findings [ 0 ] . message ) . toContain ( 'METRIC_TYPES.has(widgetType) || dimensions.length === 0' ) ;
1506+ expect ( findings [ 0 ] . message ) . toContain ( 'draws a single KPI number' ) ;
1507+ expect ( findings [ 0 ] . hint ) . toContain ( 'declared dimensions: category' ) ;
1508+ // The honest alternative repair: declare the family that actually renders.
1509+ expect ( findings [ 0 ] . hint ) . toContain ( "'metric' or 'kpi'" ) ;
1510+ expect ( findings [ 0 ] . hint ) . toContain ( `suppressWarnings: ['${ CHART_DIMENSIONS_MISSING } ']` ) ;
1511+ } ) ;
1512+
1513+ it ( 'each shape is suppressible per widget' , ( ) => {
1514+ expect ( validateWidgetBindings ( chartStack ( {
1515+ values : [ ] , chartConfig : undefined , suppressWarnings : [ CHART_MEASURES_MISSING ] ,
1516+ } ) ) ) . toHaveLength ( 0 ) ;
1517+ expect ( validateWidgetBindings ( chartStack ( {
1518+ dimensions : [ ] , chartConfig : undefined , suppressWarnings : [ CHART_DIMENSIONS_MISSING ] ,
1519+ } ) ) ) . toHaveLength ( 0 ) ;
1520+ } ) ;
1521+
1522+ it ( 'an unrelated suppressWarnings entry suppresses neither' , ( ) => {
1523+ expect ( rules ( validateWidgetBindings ( chartStack ( {
1524+ values : [ ] , chartConfig : undefined , suppressWarnings : [ CHART_DIMENSIONS_MISSING ] ,
1525+ } ) ) ) ) . toEqual ( [ CHART_MEASURES_MISSING ] ) ;
1526+ expect ( rules ( validateWidgetBindings ( chartStack ( {
1527+ dimensions : [ ] , chartConfig : undefined , suppressWarnings : [ CHART_MEASURES_MISSING ] ,
1528+ } ) ) ) ) . toEqual ( [ CHART_DIMENSIONS_MISSING ] ) ;
1529+ } ) ;
1530+
1531+ it ( 'a widget missing BOTH reports only the measures id — the pin returns first' , ( ) => {
1532+ // `values.length === 0` short-circuits at `:683`, ABOVE the `isMetric`
1533+ // branch, so this widget never renders the KPI number the other id
1534+ // describes. Reporting both would attribute two consequences to one widget.
1535+ const findings = validateWidgetBindings ( chartStack ( {
1536+ values : [ ] , dimensions : [ ] , chartConfig : undefined ,
1537+ } ) ) ;
1538+ expect ( rules ( findings ) ) . toEqual ( [ CHART_MEASURES_MISSING ] ) ;
1539+ // The dimension is still named, so one fix round closes both.
1540+ expect ( findings [ 0 ] . hint ) . toContain ( CHART_DIMENSIONS_MISSING ) ;
1541+ } ) ;
1542+
1543+ it ( 'a single-value or tabular family with no dimensions is NOT the dimensions finding' , ( ) => {
1544+ // The renderer routes these away from the chart branch on their TYPE
1545+ // (`METRIC_TYPES` / `isTable`), so a dimensionless one renders exactly what
1546+ // the author declared. `table`/`pivot` keep `table-count-only` as their own
1547+ // dimensionless rule.
1548+ for ( const type of [ 'metric' , 'kpi' , 'gauge' , 'solid-gauge' , 'bullet' , 'table' , 'pivot' ] ) {
1549+ const findings = validateWidgetBindings ( chartStack ( { type, dimensions : [ ] , chartConfig : undefined } ) ) ;
1550+ expect ( rules ( findings ) , `'${ type } ' must not report a chart-family finding` )
1551+ . not . toContain ( CHART_DIMENSIONS_MISSING ) ;
1552+ }
1553+ } ) ;
1554+
1555+ it ( 'a single-value or tabular family with no measures is NOT the measures finding' , ( ) => {
1556+ for ( const type of [ 'metric' , 'kpi' , 'gauge' , 'solid-gauge' , 'bullet' , 'table' , 'pivot' ] ) {
1557+ const findings = validateWidgetBindings ( chartStack ( { type, values : [ ] , chartConfig : undefined } ) ) ;
1558+ expect ( rules ( findings ) , `'${ type } ' must not report a chart-family finding` )
1559+ . not . toContain ( CHART_MEASURES_MISSING ) ;
1560+ }
1561+ } ) ;
1562+
1563+ it ( 'a chart-family widget that selects both is clean' , ( ) => {
1564+ for ( const type of CHART_FAMILY_WIDGET_TYPES ) {
1565+ const findings = validateWidgetBindings ( chartStack ( { type, chartConfig : undefined } ) ) ;
1566+ const mine = findings . filter ( ( f ) => f . rule === CHART_MEASURES_MISSING
1567+ || f . rule === CHART_DIMENSIONS_MISSING ) ;
1568+ expect ( mine , `'${ type } ' with one dimension and one measure must be clean` ) . toEqual ( [ ] ) ;
1569+ }
1570+ } ) ;
1571+
1572+ it ( 'every chart family reports, and no other family does' , ( ) => {
1573+ for ( const type of ChartTypeSchema . options as readonly string [ ] ) {
1574+ const noMeasures = rules ( validateWidgetBindings ( chartStack ( { type, values : [ ] , chartConfig : undefined } ) ) ) ;
1575+ const noDims = rules ( validateWidgetBindings ( chartStack ( { type, dimensions : [ ] , chartConfig : undefined } ) ) ) ;
1576+ if ( CHART_FAMILY_WIDGET_TYPES . has ( type ) ) {
1577+ expect ( noMeasures , `'${ type } ' selects no measures` ) . toContain ( CHART_MEASURES_MISSING ) ;
1578+ expect ( noDims , `'${ type } ' selects no dimensions` ) . toContain ( CHART_DIMENSIONS_MISSING ) ;
1579+ } else {
1580+ expect ( noMeasures , `'${ type } ' is not a chart family` ) . not . toContain ( CHART_MEASURES_MISSING ) ;
1581+ expect ( noDims , `'${ type } ' is not a chart family` ) . not . toContain ( CHART_DIMENSIONS_MISSING ) ;
1582+ }
1583+ }
1584+ } ) ;
1585+
1586+ it ( 'a widget type outside the taxonomy is judged by neither id' , ( ) => {
1587+ expect ( validateWidgetBindings ( chartStack ( { type : 'barr' , values : [ ] , dimensions : [ ] , chartConfig : undefined } ) ) )
1588+ . toEqual ( [ ] ) ;
1589+ } ) ;
1590+
1591+ it ( 'the exception sets mirror the PINNED renderer verbatim' , ( ) => {
1592+ // `DatasetWidget.tsx:343` `METRIC_TYPES` and `:424` `isTable`. Our copies
1593+ // drifting from the quoted lines reds here; the renderer's own set moving
1594+ // is caught by re-reading the pin when `.objectui-sha` bumps, which is the
1595+ // same contract `DATE_RANGE_DEFAULT_FIELD` carries in this file.
1596+ expect ( [ ...METRIC_WIDGET_TYPES ] ) . toEqual ( [ 'metric' , 'kpi' , 'gauge' , 'solid-gauge' , 'bullet' ] ) ;
1597+ expect ( [ ...TABULAR_WIDGET_TYPES ] ) . toEqual ( [ 'table' , 'pivot' ] ) ;
1598+ } ) ;
1599+
1600+ it ( 'every member of both exception sets is a declared chart type' , ( ) => {
1601+ // #14436's check for `MARK_MIXING_CHART_TYPES`, applied to the two sets the
1602+ // chart family is derived from: a member that is not a chart type at all is
1603+ // a typo or a retired family, and would silently WIDEN the family.
1604+ for ( const type of [ ...METRIC_WIDGET_TYPES , ...TABULAR_WIDGET_TYPES ] ) {
1605+ expect ( ChartTypeSchema . options , `'${ type } ' is not a declared chart type` ) . toContain ( type ) ;
1606+ }
1607+ } ) ;
1608+
1609+ it ( 'the chart family is the taxonomy minus those two sets' , ( ) => {
1610+ for ( const type of [ 'bar' , 'horizontal-bar' , 'column' , 'line' , 'area' , 'pie' , 'donut' ,
1611+ 'funnel' , 'scatter' , 'treemap' , 'sankey' , 'combo' , 'radar' ] ) {
1612+ expect ( CHART_FAMILY_WIDGET_TYPES . has ( type ) , `'${ type } ' should be a chart family` ) . toBe ( true ) ;
1613+ }
1614+ for ( const type of [ ...METRIC_WIDGET_TYPES , ...TABULAR_WIDGET_TYPES ] ) {
1615+ expect ( CHART_FAMILY_WIDGET_TYPES . has ( type ) , `'${ type } ' should NOT be a chart family` ) . toBe ( false ) ;
1616+ }
1617+ } ) ;
1618+
1619+ it ( 'the SHIPPED `system_overview` single-value tiles report nothing' , ( ) => {
1620+ // The other half of #15461's fixture: the Row 1/2 tiles of
1621+ // `packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts`
1622+ // select a measure and NO dimensions — which is exactly shape 2's input,
1623+ // and is correct here because they are `metric` widgets. Copied verbatim,
1624+ // for the same reason that fixture was: first-party metadata is where a
1625+ // mis-scoped warning-tier rule does its damage (ADR-0072 D1).
1626+ const findings = validateWidgetBindings ( {
1627+ datasets : [ {
1628+ name : 'sys_user_metrics' ,
1629+ label : 'User Metrics' ,
1630+ object : 'sys_user' ,
1631+ dimensions : [ { name : 'is_active' , label : 'Active' , field : 'is_active' , type : 'boolean' } ] ,
1632+ measures : [ { name : 'user_count' , label : 'Users' , aggregate : 'count' } ] ,
1633+ } ] ,
1634+ dashboards : [ {
1635+ name : 'system_overview' ,
1636+ label : 'System Overview' ,
1637+ widgets : [ {
1638+ id : 'widget_total_users' ,
1639+ dataset : 'sys_user_metrics' , values : [ 'user_count' ] ,
1640+ title : 'Total Users' ,
1641+ type : 'metric' ,
1642+ layout : { x : 0 , y : 0 , w : 3 , h : 2 } ,
1643+ } ] ,
1644+ } ] ,
1645+ } ) ;
1646+ expect ( findings ) . toEqual ( [ ] ) ;
1647+ } ) ;
1648+ } ) ;
1649+
1650+ /**
1651+ * [#15462] Tier, pinned end-to-end rather than inferred from the constant: both
1652+ * ids ride the advisory channel on `validate` AND `build`. Shape 1 was the one
1653+ * the card left open (`error` "looks right — the renderer draws nothing"), and
1654+ * it was ruled warning because an empty selection is a work-in-progress state a
1655+ * build must tolerate — erroring would gate the `sys_metadata` publish path on
1656+ * a half-authored widget. Nothing else in this file would notice a later flip.
1657+ */
1658+ describe ( '#15462 acceptance — both ids are advisory on `validate` and `build`' , ( ) => {
1659+ const noMeasures = chartStack ( { values : [ ] , chartConfig : undefined } ) ;
1660+ const noDims = chartStack ( { dimensions : [ ] , chartConfig : undefined } ) ;
1661+
1662+ for ( const command of [ 'validate' , 'build' ] as const ) {
1663+ it ( `chart-measures-missing advises (never gates) \`${ command } \`` , ( ) => {
1664+ const { errors, advisories } = splitBySeverity ( runAuthoringRules ( command , { normalized : noMeasures } ) ) ;
1665+ expect ( errors . map ( ( f ) => f . rule ) ) . not . toContain ( CHART_MEASURES_MISSING ) ;
1666+ expect ( advisories . map ( ( f ) => f . rule ) ) . toContain ( CHART_MEASURES_MISSING ) ;
1667+ } ) ;
1668+
1669+ it ( `chart-dimensions-missing advises (never gates) \`${ command } \`` , ( ) => {
1670+ const { errors, advisories } = splitBySeverity ( runAuthoringRules ( command , { normalized : noDims } ) ) ;
1671+ expect ( errors . map ( ( f ) => f . rule ) ) . not . toContain ( CHART_DIMENSIONS_MISSING ) ;
1672+ expect ( advisories . map ( ( f ) => f . rule ) ) . toContain ( CHART_DIMENSIONS_MISSING ) ;
1673+ } ) ;
1674+ }
1675+ } ) ;
0 commit comments