From a7bf12d10fe8fea1d336cea0d4344748d7c885bc Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 12 Oct 2026 16:52:11 +0200 Subject: [PATCH] fix(gate-55): the side column and a sections widget place their widgets Rule 8 read a widget named by config.sideColumn, by a sections widget's content.sections[].widgetId or by a tab's widgetIds stack as unplaced. portaliq's development carried 18 such findings on five detail pages, all rendered. A widget with no placement still fails; a side column widget with a grid cell and a side column id that names no widget are reported. --- .../lib/check_detail_page_discipline.py | 80 +++++- .../lib/test_check_detail_page_placement.py | 234 ++++++++++++++++++ 2 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 hydra-gates/scripts/lib/test_check_detail_page_placement.py diff --git a/hydra-gates/scripts/lib/check_detail_page_discipline.py b/hydra-gates/scripts/lib/check_detail_page_discipline.py index f8fdd3183..ce0053106 100644 --- a/hydra-gates/scripts/lib/check_detail_page_discipline.py +++ b/hydra-gates/scripts/lib/check_detail_page_discipline.py @@ -474,10 +474,15 @@ def _contained_widget_ids(widgets): A container renders other widgets inside its own body, so its children are correctly absent from `layout` — a grid cell for one would render it twice. - Two shapes are read, because the catalog has two containers: + Three shapes are read, because the catalog has three containers: - - `tabs` -> content.tabs[] (entries are {widgetId} or a bare id) + - `tabs` -> content.tabs[] (entries are {widgetId} or a bare + id, or {widgetIds: [...]} for a + stack of cards in one tab) - `container` -> content.placements[] (entries carry a widgetId) + - `sections` -> content.sections[] (entries carry a widgetId, or an + inline `widget` that is not a + page widget and needs no cell) Unknown container types contribute nothing rather than raising: a gate that dies on a manifest it has not met yet is worse than one that under-reports. @@ -492,18 +497,44 @@ def _contained_widget_ids(widgets): content = widget.get("content") if not isinstance(content, dict): continue - for key in ("tabs", "placements"): + for key in ("tabs", "placements", "sections"): entries = content.get(key) if not isinstance(entries, list): continue for entry in entries: if isinstance(entry, str) and entry: contained.add(entry) - elif isinstance(entry, dict) and entry.get("widgetId"): - contained.add(entry["widgetId"]) + elif isinstance(entry, dict): + if isinstance(entry.get("widgetId"), str) and entry["widgetId"]: + contained.add(entry["widgetId"]) + stack = entry.get("widgetIds") + if isinstance(stack, list): + contained.update( + wid for wid in stack if isinstance(wid, str) and wid + ) return contained +def _side_column_widget_ids(cfg): + """ + Widget ids the page places in its side column (`config.sideColumn`). + + A detail page in the v2 shape has two places for a widget: the body grid + (`config.layout`) and a column of cards beside it (`config.sideColumn`). + CnDetailPage resolves a STRING entry against `config.widgets[].id`, so that + widget is placed, and a grid cell for it would render it a second time. + An OBJECT entry is an inline widget definition: it is not in + `config.widgets`, so it is no concern of the widgets <-> layout check. + + :param cfg: The page's config object. + :return: List of widget ids named by sideColumn, in order, duplicates kept. + """ + entries = cfg.get("sideColumn") + if not isinstance(entries, list): + return [] + return [entry for entry in entries if isinstance(entry, str) and entry] + + _APP_REGISTERS_CACHE = {} @@ -602,8 +633,36 @@ def check_page(path, page, page_ids, findings): # once loose on the grid. Rule 8 predates container widgets and read # every such child as unplaced. contained = _contained_widget_ids(widgets) - for wid in set(widget_ids): + # THE SIDE COLUMN IS A PLACEMENT TOO. `config.sideColumn` names page + # widgets by id and CnDetailPage renders them beside the body, so they + # are correctly absent from `layout`. Rule 8 predates the side column + # and read every such widget as unplaced (18 findings on portaliq's + # five detail pages, 2026-10-12, every one of them rendered). + side = _side_column_widget_ids(cfg) + side_count = Counter(side) + for wid in sorted(set(widget_ids)): n = lc.get(wid, 0) + if wid in side_count: + # Placed by the side column. Still worth saying: a second + # placement, which renders the widget twice. + if n: + findings.append( + f"{path}: page '{pid}' — widget '{wid}' is in " + f"config.sideColumn AND has {n} layout cell(s), so it " + f"renders twice (ADR-062 rule 8)" + ) + # NOT a finding: the same widget in the side column AND in a + # tab. pipelinq's ContactDetail and shillinq's CustomerDetail + # ship a History card beside the body and a History tab, one + # of the two out of sight at any time. Whether that is wanted + # is a design call, not an integrity defect. + if side_count[wid] > 1: + findings.append( + f"{path}: page '{pid}' — widget '{wid}' is named " + f"{side_count[wid]} times in config.sideColumn, so it " + f"renders {side_count[wid]} times (ADR-062 rule 8)" + ) + continue if wid in contained: # Placed by its container. The one thing still worth saying is # if it ALSO has a grid cell, which is the double-render. @@ -625,6 +684,15 @@ def check_page(path, page, page_ids, findings): f"{path}: page '{pid}' — layout cell references widgetId " f"'{lid}' with no matching config.widgets[].id" ) + # A side column entry that names no declared widget is dropped by + # CnDetailPage without a word: the card is simply not there. + for sid in sorted(set(side)): + if wc.get(sid, 0) == 0: + findings.append( + f"{path}: page '{pid}' — config.sideColumn names widget " + f"'{sid}' with no matching config.widgets[].id, so the " + f"card is never rendered" + ) # overlap on the 12-col grid. cells = [] for l in layout: diff --git a/hydra-gates/scripts/lib/test_check_detail_page_placement.py b/hydra-gates/scripts/lib/test_check_detail_page_placement.py new file mode 100644 index 000000000..e366824e7 --- /dev/null +++ b/hydra-gates/scripts/lib/test_check_detail_page_placement.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Conduction +# SPDX-License-Identifier: EUPL-1.2 +"""Tests for gate-55's widgets <-> layout check (ADR-062 rule 8). + +Rule 8 says every widget in ``config.widgets`` is placed exactly once. The +check used to know one placement, a ``config.layout`` grid cell, and later +learned two containers (``tabs``, ``container``). A detail page in the v2 +shape has more places than that: + + * ``config.sideColumn``: a column of cards beside the body. A string entry + is the id of a page widget, which CnDetailPage renders there. + * a ``sections`` widget: ``content.sections[].widgetId`` names the page + widget each section shows. + * a ``tabs`` entry with ``widgetIds``: a stack of cards in one tab. + +On 2026-10-12 portaliq's ``development`` carried 18 findings of the form +"widget 'x' maps to 0 layout cell(s)" on five detail pages. Every one of those +widgets was placed, in a side column or in a section, and rendered. + +THE CONTROL IS HALF OF THIS SUITE. A check that stops reporting placed +widgets must keep reporting an unplaced one, or the fix is only a silence. +So each new placement has a pass case, and the suite also pins that a widget +with no placement at all still fails, that a grid cell next to a side column +placement is reported as a double render, and that a side column entry naming no widget is reported. +""" + +from __future__ import annotations + +import importlib.util +import os +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +TARGET = os.path.join(HERE, "check_detail_page_discipline.py") + + +def _load(): + spec = importlib.util.spec_from_file_location("_dpd_placement", TARGET) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _cell(widget_id, y=0): + return {"widgetId": widget_id, "gridX": 0, "gridY": y, "gridWidth": 12, "gridHeight": 2} + + +def _page(widgets, layout, **config): + cfg = {"widgets": widgets, "layout": layout} + cfg.update(config) + return {"id": "ThingDetail", "type": "detail", "config": cfg} + + +def _findings(page): + found = [] + _load().check_page("src/manifest.json", page, {"ThingDetail"}, found) + return found + + +class SideColumnPlacementTest(unittest.TestCase): + + def test_a_widget_named_by_the_side_column_is_placed(self): + page = _page( + [{"id": "thing-data", "type": "data"}, {"id": "thing-history", "type": "audit-trail"}], + [_cell("thing-data")], + sideColumn=["thing-history"], + ) + self.assertEqual(_findings(page), []) + + def test_an_inline_side_column_card_needs_no_declared_widget(self): + page = _page( + [{"id": "thing-data", "type": "data"}], + [_cell("thing-data")], + sideColumn=[{"type": "audit-trail", "title": "History"}], + ) + self.assertEqual(_findings(page), []) + + def test_control_a_widget_with_no_placement_still_fails(self): + page = _page( + [ + {"id": "thing-data", "type": "data"}, + {"id": "thing-history", "type": "audit-trail"}, + {"id": "thing-lost", "type": "related"}, + ], + [_cell("thing-data")], + sideColumn=["thing-history"], + ) + found = _findings(page) + self.assertEqual(len(found), 1, found) + self.assertIn("widget 'thing-lost' maps to 0 layout cell(s)", found[0]) + + def test_control_without_a_side_column_the_old_finding_stands(self): + page = _page( + [{"id": "thing-data", "type": "data"}, {"id": "thing-history", "type": "audit-trail"}], + [_cell("thing-data")], + ) + found = _findings(page) + self.assertEqual(len(found), 1, found) + self.assertIn("widget 'thing-history' maps to 0 layout cell(s)", found[0]) + + def test_side_column_and_a_grid_cell_is_a_double_render(self): + page = _page( + [{"id": "thing-data", "type": "data"}, {"id": "thing-history", "type": "audit-trail"}], + [_cell("thing-data"), _cell("thing-history", 2)], + sideColumn=["thing-history"], + ) + found = _findings(page) + self.assertEqual(len(found), 1, found) + self.assertIn("config.sideColumn AND has 1 layout cell(s)", found[0]) + + def test_side_column_and_a_tab_is_left_to_the_designer(self): + # pipelinq ContactDetail and shillinq CustomerDetail ship exactly this: + # a History card beside the body and a History tab. + page = _page( + [ + {"id": "thing-data", "type": "data"}, + {"id": "thing-history", "type": "audit-trail"}, + { + "id": "thing-tabs", + "type": "tabs", + "content": {"tabs": [{"widgetId": "thing-data"}, {"widgetId": "thing-history"}]}, + }, + ], + [_cell("thing-tabs")], + sideColumn=["thing-history"], + ) + self.assertEqual(_findings(page), []) + + def test_the_same_id_twice_in_the_side_column_is_reported(self): + page = _page( + [{"id": "thing-data", "type": "data"}, {"id": "thing-history", "type": "audit-trail"}], + [_cell("thing-data")], + sideColumn=["thing-history", "thing-history"], + ) + found = _findings(page) + self.assertEqual(len(found), 1, found) + self.assertIn("named 2 times in config.sideColumn", found[0]) + + def test_a_side_column_id_that_names_no_widget_is_reported(self): + page = _page( + [{"id": "thing-data", "type": "data"}], + [_cell("thing-data")], + sideColumn=["thing-histroy"], + ) + found = _findings(page) + self.assertEqual(len(found), 1, found) + self.assertIn("config.sideColumn names widget 'thing-histroy'", found[0]) + + +class ContainerPlacementTest(unittest.TestCase): + + def _sections_page(self, sections, extra_layout=()): + return _page( + [ + {"id": "thing-settings", "type": "sections", "content": {"sections": sections}}, + {"id": "thing-details", "type": "ThingDetailsForm"}, + {"id": "thing-theme", "type": "ThingTheme"}, + ], + [_cell("thing-settings")] + list(extra_layout), + ) + + def test_a_widget_shown_by_a_section_is_placed(self): + page = self._sections_page( + [ + {"key": "details", "title": "Details", "widgetId": "thing-details"}, + {"key": "theme", "title": "Theme", "widgetId": "thing-theme"}, + ] + ) + self.assertEqual(_findings(page), []) + + def test_control_a_widget_no_section_names_still_fails(self): + page = self._sections_page([{"key": "details", "title": "Details", "widgetId": "thing-details"}]) + found = _findings(page) + self.assertEqual(len(found), 1, found) + self.assertIn("widget 'thing-theme' maps to 0 layout cell(s)", found[0]) + + def test_a_section_widget_with_a_grid_cell_is_a_double_render(self): + page = self._sections_page( + [ + {"key": "details", "widgetId": "thing-details"}, + {"key": "theme", "widgetId": "thing-theme"}, + ], + extra_layout=[_cell("thing-theme", 2)], + ) + found = _findings(page) + self.assertEqual(len(found), 1, found) + self.assertIn("held by a container widget AND has 1 layout cell(s)", found[0]) + + def test_a_section_with_an_inline_widget_contributes_nothing(self): + page = self._sections_page( + [ + {"key": "details", "widgetId": "thing-details"}, + {"key": "theme", "widgetId": "thing-theme"}, + {"key": "note", "widget": {"type": "text", "content": {"text": "x"}}}, + ] + ) + self.assertEqual(_findings(page), []) + + def test_a_tab_stack_places_every_widget_it_names(self): + page = _page( + [ + {"id": "thing-data", "type": "data"}, + {"id": "thing-history", "type": "audit-trail"}, + { + "id": "thing-tabs", + "type": "tabs", + "content": {"tabs": [{"label": "All", "widgetIds": ["thing-data", "thing-history"]}]}, + }, + ], + [_cell("thing-tabs")], + ) + self.assertEqual(_findings(page), []) + + def test_control_a_tab_stack_does_not_place_what_it_does_not_name(self): + page = _page( + [ + {"id": "thing-data", "type": "data"}, + {"id": "thing-history", "type": "audit-trail"}, + { + "id": "thing-tabs", + "type": "tabs", + "content": {"tabs": [{"label": "All", "widgetIds": ["thing-data"]}]}, + }, + ], + [_cell("thing-tabs")], + ) + found = _findings(page) + self.assertEqual(len(found), 1, found) + self.assertIn("widget 'thing-history' maps to 0 layout cell(s)", found[0]) + + +if __name__ == "__main__": + unittest.main()