99 CHART_FAMILY_WIDGET_TYPES ,
1010 CHART_MEASURES_MISSING ,
1111 CHART_DIMENSIONS_MISSING ,
12+ WIDGET_MEASURES_MISSING ,
13+ NON_CHART_DATASET_WIDGET_TYPES ,
1214 TABLE_COUNT_ONLY ,
1315 WIDGET_DATASET_UNKNOWN ,
1416 WIDGET_DIMENSION_UNKNOWN ,
@@ -1604,11 +1606,15 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => {
16041606 }
16051607 } ) ;
16061608
1607- it ( 'a single-value or tabular family with no measures is NOT the measures finding' , ( ) => {
1609+ it ( '[#15508] a single-value or tabular family with no measures reports the FAMILY-NEUTRAL id' , ( ) => {
1610+ // Was "reports nothing" when this id was chart-family only (#15462). The
1611+ // pin's `values.length === 0` return stands above every family branch, so
1612+ // these degrade identically — they just do not degrade to a missing CHART,
1613+ // which is why the id they report is the other one.
16081614 for ( const type of [ 'metric' , 'kpi' , 'gauge' , 'solid-gauge' , 'bullet' , 'table' , 'pivot' ] ) {
16091615 const findings = validateWidgetBindings ( chartStack ( { type, values : [ ] , chartConfig : undefined } ) ) ;
1610- expect ( rules ( findings ) , `'${ type } ' must not report a chart-family finding` )
1611- . not . toContain ( CHART_MEASURES_MISSING ) ;
1616+ expect ( rules ( findings ) , `'${ type } ' selects no measures` ) . toContain ( WIDGET_MEASURES_MISSING ) ;
1617+ expect ( rules ( findings ) , `' ${ type } ' is not a chart` ) . not . toContain ( CHART_MEASURES_MISSING ) ;
16121618 }
16131619 } ) ;
16141620
@@ -1621,21 +1627,30 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => {
16211627 }
16221628 } ) ;
16231629
1624- it ( 'every chart family reports, and no other family does' , ( ) => {
1630+ it ( 'every declared family reports the MEASURES shape; only the chart family reports the dimensions one' , ( ) => {
1631+ // [#15508] The two shapes now have different populations, and this sweep is
1632+ // where that division is pinned across the WHOLE taxonomy rather than a
1633+ // sample of it: every declared type reports a measures id (the `:683`
1634+ // return is type-independent), and which of the two ids it is follows the
1635+ // chart-family line exactly.
16251636 for ( const type of ChartTypeSchema . options as readonly string [ ] ) {
16261637 const noMeasures = rules ( validateWidgetBindings ( chartStack ( { type, values : [ ] , chartConfig : undefined } ) ) ) ;
16271638 const noDims = rules ( validateWidgetBindings ( chartStack ( { type, dimensions : [ ] , chartConfig : undefined } ) ) ) ;
1639+ const measuresId = CHART_FAMILY_WIDGET_TYPES . has ( type )
1640+ ? CHART_MEASURES_MISSING : WIDGET_MEASURES_MISSING ;
1641+ const otherId = CHART_FAMILY_WIDGET_TYPES . has ( type )
1642+ ? WIDGET_MEASURES_MISSING : CHART_MEASURES_MISSING ;
1643+ expect ( noMeasures , `'${ type } ' selects no measures` ) . toContain ( measuresId ) ;
1644+ expect ( noMeasures , `'${ type } ' reports one measures id, not both` ) . not . toContain ( otherId ) ;
16281645 if ( CHART_FAMILY_WIDGET_TYPES . has ( type ) ) {
1629- expect ( noMeasures , `'${ type } ' selects no measures` ) . toContain ( CHART_MEASURES_MISSING ) ;
16301646 expect ( noDims , `'${ type } ' selects no dimensions` ) . toContain ( CHART_DIMENSIONS_MISSING ) ;
16311647 } else {
1632- expect ( noMeasures , `'${ type } ' is not a chart family` ) . not . toContain ( CHART_MEASURES_MISSING ) ;
16331648 expect ( noDims , `'${ type } ' is not a chart family` ) . not . toContain ( CHART_DIMENSIONS_MISSING ) ;
16341649 }
16351650 }
16361651 } ) ;
16371652
1638- it ( 'a widget type outside the taxonomy is judged by neither id ' , ( ) => {
1653+ it ( 'a widget type outside the taxonomy is judged by none of the three ids ' , ( ) => {
16391654 expect ( validateWidgetBindings ( chartStack ( { type : 'barr' , values : [ ] , dimensions : [ ] , chartConfig : undefined } ) ) )
16401655 . toEqual ( [ ] ) ;
16411656 } ) ;
@@ -1699,6 +1714,174 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => {
16991714 } ) ;
17001715} ) ;
17011716
1717+ /**
1718+ * [#15508] The same empty-selection SHAPE on every other declared family. The
1719+ * pinned `values.length === 0` return (`DatasetWidget.tsx:683` at
1720+ * `.objectui-sha` = `a472b0716`) is type-independent and stands above
1721+ * `isMetric` (`:423`), `isTable` (`:424`) and the chart branch alike, so a
1722+ * `metric`/`kpi`/`gauge`/`solid-gauge`/`bullet` or `table`/`pivot` widget with
1723+ * no measures renders the same authoring placeholder — the KPI number or the
1724+ * table is simply not drawn. `chart-measures-missing` keeps the chart family
1725+ * (its message, its id and its suppression are untouched here); this id takes
1726+ * the rest.
1727+ */
1728+ describe ( 'widget-measures-missing (#15508)' , ( ) => {
1729+ const rules = ( findings : { rule : string } [ ] ) => findings . map ( ( f ) => f . rule ) ;
1730+
1731+ it ( 'warns when a `metric` tile selects no measures, naming the KPI consequence' , ( ) => {
1732+ const findings = validateWidgetBindings ( chartStack ( { type : 'metric' , values : [ ] , chartConfig : undefined } ) ) ;
1733+ expect ( findings ) . toHaveLength ( 1 ) ;
1734+ expect ( findings [ 0 ] . severity ) . toBe ( 'warning' ) ;
1735+ expect ( findings [ 0 ] . rule ) . toBe ( WIDGET_MEASURES_MISSING ) ;
1736+ expect ( findings [ 0 ] . where ) . toContain ( 'spend_by_category' ) ;
1737+ expect ( findings [ 0 ] . path ) . toBe ( 'dashboards[0].widgets[0]' ) ;
1738+ // The PINNED placeholder string, quoted — not an inferred consequence.
1739+ expect ( findings [ 0 ] . message ) . toContain ( 'Pick measures (values) for this dataset widget.' ) ;
1740+ expect ( findings [ 0 ] . message ) . toContain ( 'the single KPI number this tile is for is not drawn at all' ) ;
1741+ // ...and NOT the chart family's consequence, which would be false here.
1742+ expect ( findings [ 0 ] . message ) . not . toContain ( 'no chart is drawn' ) ;
1743+ expect ( findings [ 0 ] . hint ) . toContain ( 'declared measures: sum_amount, ticket_count' ) ;
1744+ expect ( findings [ 0 ] . hint ) . toContain ( `suppressWarnings: ['${ WIDGET_MEASURES_MISSING } ']` ) ;
1745+ } ) ;
1746+
1747+ it ( 'warns when a `table` selects no measures, naming the TABLE consequence' , ( ) => {
1748+ const findings = validateWidgetBindings ( chartStack ( { type : 'table' , values : [ ] , chartConfig : undefined } ) ) ;
1749+ expect ( rules ( findings ) ) . toEqual ( [ WIDGET_MEASURES_MISSING ] ) ;
1750+ expect ( findings [ 0 ] . message ) . toContain ( 'no table is rendered at all' ) ;
1751+ expect ( findings [ 0 ] . message ) . not . toContain ( 'KPI number' ) ;
1752+ } ) ;
1753+
1754+ it ( 'the `bar` control is unchanged — the chart family keeps its own id and wording' , ( ) => {
1755+ const findings = validateWidgetBindings ( chartStack ( { type : 'bar' , values : [ ] , chartConfig : undefined } ) ) ;
1756+ expect ( rules ( findings ) ) . toEqual ( [ CHART_MEASURES_MISSING ] ) ;
1757+ expect ( findings [ 0 ] . message ) . toContain ( 'no chart is drawn at all' ) ;
1758+ } ) ;
1759+
1760+ it ( 'an absent `values` key reports the same shape as an empty array' , ( ) => {
1761+ const stack = chartStack ( { type : 'metric' , chartConfig : undefined } ) ;
1762+ delete ( stack as { dashboards : { widgets : Record < string , unknown > [ ] } [ ] } )
1763+ . dashboards [ 0 ] . widgets [ 0 ] . values ;
1764+ expect ( rules ( validateWidgetBindings ( stack ) ) ) . toEqual ( [ WIDGET_MEASURES_MISSING ] ) ;
1765+ } ) ;
1766+
1767+ it ( 'is suppressible per widget, and the two ids do not suppress each other' , ( ) => {
1768+ expect ( validateWidgetBindings ( chartStack ( {
1769+ type : 'metric' , values : [ ] , chartConfig : undefined ,
1770+ suppressWarnings : [ WIDGET_MEASURES_MISSING ] ,
1771+ } ) ) ) . toHaveLength ( 0 ) ;
1772+ // A board carrying the #15462 spelling keeps suppressing the CHART id — the
1773+ // reason that id was kept rather than renamed.
1774+ expect ( validateWidgetBindings ( chartStack ( {
1775+ values : [ ] , chartConfig : undefined , suppressWarnings : [ CHART_MEASURES_MISSING ] ,
1776+ } ) ) ) . toHaveLength ( 0 ) ;
1777+ // ...and does not reach across the family line in either direction.
1778+ expect ( rules ( validateWidgetBindings ( chartStack ( {
1779+ type : 'metric' , values : [ ] , chartConfig : undefined ,
1780+ suppressWarnings : [ CHART_MEASURES_MISSING ] ,
1781+ } ) ) ) ) . toEqual ( [ WIDGET_MEASURES_MISSING ] ) ;
1782+ expect ( rules ( validateWidgetBindings ( chartStack ( {
1783+ values : [ ] , chartConfig : undefined , suppressWarnings : [ WIDGET_MEASURES_MISSING ] ,
1784+ } ) ) ) ) . toEqual ( [ CHART_MEASURES_MISSING ] ) ;
1785+ } ) ;
1786+
1787+ it ( 'a `metric` with NO measures and NO dimensions reports exactly one finding' , ( ) => {
1788+ // The ordering proof. `values.length === 0` returns at `:683` above the
1789+ // `isMetric` test, so the dimensions id cannot also be true of this widget
1790+ // — and a dimensionless `metric` is not a defect at all (the shipped
1791+ // `system_overview` tiles are that shape). The hint therefore does NOT
1792+ // steer toward a dimension the way the chart-family hint does.
1793+ const findings = validateWidgetBindings ( chartStack ( {
1794+ type : 'metric' , values : [ ] , dimensions : [ ] , chartConfig : undefined ,
1795+ } ) ) ;
1796+ expect ( findings ) . toHaveLength ( 1 ) ;
1797+ expect ( findings [ 0 ] . rule ) . toBe ( WIDGET_MEASURES_MISSING ) ;
1798+ expect ( findings [ 0 ] . hint ) . not . toContain ( CHART_DIMENSIONS_MISSING ) ;
1799+ expect ( findings [ 0 ] . hint ) . toContain ( 'needs no `dimensions`' ) ;
1800+ } ) ;
1801+
1802+ it ( 'a `table` with NO measures and NO dimensions reports once — not also table-count-only' , ( ) => {
1803+ // Rule (e) `continue`s on `values.length === 0` before it resolves a
1804+ // measure, so the two never double-report the same widget. Without that
1805+ // ordering this input is exactly `table-count-only`'s other precondition.
1806+ const findings = validateWidgetBindings ( chartStack ( {
1807+ type : 'table' , values : [ ] , dimensions : [ ] , chartConfig : undefined ,
1808+ } ) ) ;
1809+ expect ( rules ( findings ) ) . toEqual ( [ WIDGET_MEASURES_MISSING ] ) ;
1810+ expect ( rules ( findings ) ) . not . toContain ( TABLE_COUNT_ONLY ) ;
1811+ } ) ;
1812+
1813+ it ( 'a non-chart family that DOES select a measure is clean' , ( ) => {
1814+ for ( const type of NON_CHART_DATASET_WIDGET_TYPES ) {
1815+ const findings = validateWidgetBindings ( chartStack ( { type, dimensions : [ ] , chartConfig : undefined } ) ) ;
1816+ const mine = findings . filter ( ( f ) => f . rule === WIDGET_MEASURES_MISSING ) ;
1817+ expect ( mine , `'${ type } ' selecting one measure must be clean` ) . toEqual ( [ ] ) ;
1818+ }
1819+ } ) ;
1820+
1821+ it ( 'the population is the taxonomy minus the chart family — a partition, derived' , ( ) => {
1822+ // Same discipline as `CHART_FAMILY_WIDGET_TYPES`: never hand-listed, so a
1823+ // family added to the taxonomy or to either exception set lands on exactly
1824+ // one side of the line without a second edit.
1825+ expect ( [ ...NON_CHART_DATASET_WIDGET_TYPES ] . sort ( ) )
1826+ . toEqual ( [ ...METRIC_WIDGET_TYPES , ...TABULAR_WIDGET_TYPES ] . sort ( ) ) ;
1827+ for ( const type of NON_CHART_DATASET_WIDGET_TYPES ) {
1828+ expect ( ChartTypeSchema . options , `'${ type } ' is not a declared chart type` ) . toContain ( type ) ;
1829+ expect ( CHART_FAMILY_WIDGET_TYPES . has ( type ) , `'${ type } ' cannot be both` ) . toBe ( false ) ;
1830+ }
1831+ const union = new Set ( [ ...CHART_FAMILY_WIDGET_TYPES , ...NON_CHART_DATASET_WIDGET_TYPES ] ) ;
1832+ expect ( [ ...union ] . sort ( ) ) . toEqual ( [ ...( ChartTypeSchema . options as readonly string [ ] ) ] . sort ( ) ) ;
1833+ } ) ;
1834+
1835+ it ( 'a measureless clone of a SHIPPED `system_overview` tile is reported' , ( ) => {
1836+ // The half #15462 could not see. `widget_total_users` is a `metric` bound
1837+ // to `sys_user_metrics`; drop its `values` and the board renders the
1838+ // authoring placeholder where the KPI belongs, which is the degradation
1839+ // this id exists to name. (The real tile selects `user_count` and stays
1840+ // clean — pinned in the #15462 block above.)
1841+ const findings = validateWidgetBindings ( {
1842+ datasets : [ {
1843+ name : 'sys_user_metrics' ,
1844+ label : 'User Metrics' ,
1845+ object : 'sys_user' ,
1846+ dimensions : [ { name : 'is_active' , label : 'Active' , field : 'is_active' , type : 'boolean' } ] ,
1847+ measures : [ { name : 'user_count' , label : 'Users' , aggregate : 'count' } ] ,
1848+ } ] ,
1849+ dashboards : [ {
1850+ name : 'system_overview' ,
1851+ label : 'System Overview' ,
1852+ widgets : [ {
1853+ id : 'widget_total_users' ,
1854+ dataset : 'sys_user_metrics' , values : [ ] ,
1855+ title : 'Total Users' ,
1856+ type : 'metric' ,
1857+ layout : { x : 0 , y : 0 , w : 3 , h : 2 } ,
1858+ } ] ,
1859+ } ] ,
1860+ } ) ;
1861+ expect ( rules ( findings ) ) . toEqual ( [ WIDGET_MEASURES_MISSING ] ) ;
1862+ expect ( findings [ 0 ] . hint ) . toContain ( 'declared measures: user_count' ) ;
1863+ } ) ;
1864+ } ) ;
1865+
1866+ /**
1867+ * [#15508] The tier the ruling carried over from #15462, pinned the same way:
1868+ * a half-authored `metric` or `table` must not gate a build.
1869+ */
1870+ describe ( '#15508 acceptance — the family-neutral id advises, never gates' , ( ) => {
1871+ const noMeasures = chartStack ( { type : 'metric' , values : [ ] , chartConfig : undefined } ) ;
1872+ const tableNoMeasures = chartStack ( { type : 'table' , values : [ ] , chartConfig : undefined } ) ;
1873+
1874+ for ( const command of [ 'validate' , 'build' ] as const ) {
1875+ it ( `widget-measures-missing advises (never gates) \`${ command } \`` , ( ) => {
1876+ for ( const stack of [ noMeasures , tableNoMeasures ] ) {
1877+ const { errors, advisories } = splitBySeverity ( runAuthoringRules ( command , { normalized : stack } ) ) ;
1878+ expect ( errors . map ( ( f ) => f . rule ) ) . not . toContain ( WIDGET_MEASURES_MISSING ) ;
1879+ expect ( advisories . map ( ( f ) => f . rule ) ) . toContain ( WIDGET_MEASURES_MISSING ) ;
1880+ }
1881+ } ) ;
1882+ }
1883+ } ) ;
1884+
17021885/**
17031886 * [#15462] Tier, pinned end-to-end rather than inferred from the constant: both
17041887 * ids ride the advisory channel on `validate` AND `build`. Shape 1 was the one
0 commit comments