Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 74 additions & 6 deletions hydra-gates/scripts/lib/check_detail_page_discipline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 = {}


Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down
234 changes: 234 additions & 0 deletions hydra-gates/scripts/lib/test_check_detail_page_placement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Conduction <info@conduction.nl>
# 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()
Loading