# Guanghan Topology Presentation DB Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Move the Guanghan engineering topology page from CSS/HTML-defined visual semantics to database-defined B1 presentation data for node styles, edge styles, decoration styles, and legends. **Architecture:** PostgreSQL remains the source of truth for topology facts and layout. New presentation tables store visual style mappings for the existing layout; the snapshot API returns a `layout.presentation` payload; the wiki demo page applies those styles at SVG element creation time while keeping the existing interaction shell. The current branch must be used directly; do not create or switch branches. **Tech Stack:** PostgreSQL, psycopg, Flask, vanilla HTML/CSS/JavaScript SVG, Python `unittest`, Playwright. --- ## File structure Create: - `topology_api/migrations/006_presentation_styles.sql`: creates theme, node style, edge style, decoration style, and legend tables; adds `diagram_layouts.theme_id`. - `docs/guanghan_topology_presentation_db_verification_2026-07-09.md`: final verification report with commands and screenshots. Modify: - `topology_api/seed/seed_layout.py`: seeds the default theme and current approved style values, then binds the default layout to the theme. - `topology_api/queries/snapshot_queries.py`: fetches presentation styles and embeds them under `layout.presentation`. - `data/wiki/guanghan_topology_demo.html`: renders nodes, edges, radiators, and legend items from `layout.presentation`. - `tests/test_topology_api.py`: adds API assertions for presentation payload and DB-driven style semantics. - `tests/test_guanghan_topology_demo.py`: adds source-level guardrails against reintroducing hardcoded business visual semantics. - `tests/test_guanghan_topology_e2e.py`: adds browser assertions for API-driven colors and legend rendering. Do not modify unrelated untracked wiki files, handover files, scripts, or `user_input/.../Timeline.md`. ## Existing behavior to preserve - `GET /guanghan_topology_demo.html?mission=GHC-05` still renders 14 visual nodes, 2 pressurized passages, and 4 radiators. - Explicit `at` still wins over `mission`. - If only `mission` exists, the snapshot uses the latest event of that mission. - If neither exists, the snapshot uses the latest recorded occurred state. - Future expansion markers remain source comments only; they are not visible on the page. - 加压走道 remains a green solid line in seeded production data; the key change is that the color comes from the database. - 散热器 remains non-clickable, non-modal, and non-overlapping. --- ### Task 1: Add failing API tests for presentation payload **Files:** - Modify: `tests/test_topology_api.py` - [ ] **Step 1: Add a test that requires `layout.presentation`** Append this test to `TopologyApiQueryTests` after `test_layout_contains_members_labels_and_radiators`: ```python def test_layout_includes_database_presentation_payload(self) -> None: response = self.client.get( "/api/v1/bases/guanghan/snapshot?mission=GHC-05" ) self.assertEqual(response.status_code, 200) layout = response.get_json()["layout"] presentation = layout["presentation"] self.assertEqual( presentation["theme"]["key"], "guanghan_dark_engineering_v1", ) self.assertIn("nodeStyles", presentation) self.assertIn("edgeStyles", presentation) self.assertIn("decorationStyles", presentation) self.assertIn("legendItems", presentation) node_style_keys = { node["styleKey"] for node in layout["nodes"] } self.assertLessEqual( node_style_keys, set(presentation["nodeStyles"].keys()), ) connection_types = { connection["type"] for connection in response.get_json()["connections"] } self.assertLessEqual( connection_types, set(presentation["edgeStyles"].keys()), ) self.assertEqual( presentation["edgeStyles"]["pressurized_passage"], { "displayName": "加压走道", "strokeColor": "#51e89c", "strokeWidth": 9.0, "dashArray": None, "lineCap": "square", "legendGroup": "pressurized", }, ) self.assertEqual( presentation["decorationStyles"]["radiator"]["label"], "散热器", ) self.assertEqual( [ item["key"] for item in presentation["legendItems"] ], ["structural", "pressurized", "utility", "radiator"], ) ``` - [ ] **Step 2: Add a test proving style changes are database-visible** Append this test to the same class. It commits one temporary style change so the API can read it through its own connection, then restores the original value in `finally`: ```python def test_presentation_styles_are_database_backed(self) -> None: from topology_api.db import get_writer_connection with get_writer_connection() as conn: conn.execute("SET search_path TO topology, public") with conn.cursor() as cur: cur.execute(""" SELECT stroke_color FROM diagram_edge_styles WHERE connection_type = 'pressurized_passage' """) original_color = cur.fetchone()["stroke_color"] cur.execute(""" UPDATE diagram_edge_styles SET stroke_color = '#00ffaa' WHERE connection_type = 'pressurized_passage' """) conn.commit() try: response = self.client.get( "/api/v1/bases/guanghan/snapshot?mission=GHC-05" ) self.assertEqual(response.status_code, 200) presentation = response.get_json()["layout"]["presentation"] self.assertEqual( presentation["edgeStyles"]["pressurized_passage"] ["strokeColor"], "#00ffaa", ) finally: with conn.cursor() as cur: cur.execute(""" UPDATE diagram_edge_styles SET stroke_color = %(original)s WHERE connection_type = 'pressurized_passage' """, {"original": original_color}) conn.commit() ``` - [ ] **Step 3: Run the focused API tests to verify they fail** Run: ```powershell .\.venv\Scripts\python.exe -m unittest tests.test_topology_api.TopologyApiQueryTests.test_layout_includes_database_presentation_payload tests.test_topology_api.TopologyApiQueryTests.test_presentation_styles_are_database_backed -v ``` Expected result: ```text ERROR: test_layout_includes_database_presentation_payload KeyError: 'presentation' ERROR: test_presentation_styles_are_database_backed psycopg.errors.UndefinedTable: relation "diagram_edge_styles" does not exist ``` - [ ] **Step 4: Commit failing tests** Run: ```powershell git add tests/test_topology_api.py git commit -m "test(topology): require database presentation payload" ``` --- ### Task 2: Add presentation schema and seed data **Files:** - Create: `topology_api/migrations/006_presentation_styles.sql` - Modify: `topology_api/seed/seed_layout.py` - Test: `tests/test_topology_api.py` - [ ] **Step 1: Create the migration** Create `topology_api/migrations/006_presentation_styles.sql` with this content: ```sql -- topology_api/migrations/006_presentation_styles.sql -- Database-driven presentation semantics for topology diagrams. CREATE TABLE IF NOT EXISTS topology.diagram_themes ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), base_id uuid NOT NULL REFERENCES topology.bases(id) ON DELETE CASCADE, theme_key text NOT NULL, name text NOT NULL, description text, tokens jsonb NOT NULL DEFAULT '{}'::jsonb, is_default boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT uq_diagram_themes_base_key UNIQUE (base_id, theme_key) ); CREATE UNIQUE INDEX IF NOT EXISTS uq_diagram_themes_default_per_base ON topology.diagram_themes(base_id) WHERE is_default; CREATE TABLE IF NOT EXISTS topology.diagram_node_styles ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), theme_id uuid NOT NULL REFERENCES topology.diagram_themes(id) ON DELETE CASCADE, style_key text NOT NULL, display_name text NOT NULL, fill_color text NOT NULL, stroke_color text NOT NULL, stroke_width numeric NOT NULL, corner_radius numeric NOT NULL DEFAULT 16, title_color text, meta_color text, hint_color text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT uq_diagram_node_styles_theme_key UNIQUE (theme_id, style_key), CONSTRAINT ck_diagram_node_stroke_width CHECK (stroke_width >= 0), CONSTRAINT ck_diagram_node_corner_radius CHECK (corner_radius >= 0) ); CREATE TABLE IF NOT EXISTS topology.diagram_edge_styles ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), theme_id uuid NOT NULL REFERENCES topology.diagram_themes(id) ON DELETE CASCADE, connection_type text NOT NULL, display_name text NOT NULL, stroke_color text NOT NULL, stroke_width numeric NOT NULL, dash_array text, line_cap text NOT NULL DEFAULT 'square', legend_group text, legend_order integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT uq_diagram_edge_styles_theme_type UNIQUE (theme_id, connection_type), CONSTRAINT ck_diagram_edge_stroke_width CHECK (stroke_width >= 0), CONSTRAINT ck_diagram_edge_line_cap CHECK (line_cap IN ('butt', 'round', 'square')), CONSTRAINT ck_diagram_edge_dash_array CHECK ( dash_array IS NULL OR dash_array ~ '^[0-9]+( [0-9]+)*$' ) ); CREATE TABLE IF NOT EXISTS topology.diagram_decoration_styles ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), theme_id uuid NOT NULL REFERENCES topology.diagram_themes(id) ON DELETE CASCADE, decoration_key text NOT NULL, display_name text NOT NULL, fill_color text NOT NULL, stroke_color text NOT NULL, stroke_width numeric NOT NULL, label text, label_color text, render_params jsonb NOT NULL DEFAULT '{}'::jsonb, legend_order integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT uq_diagram_decoration_styles_theme_key UNIQUE (theme_id, decoration_key), CONSTRAINT ck_diagram_decoration_stroke_width CHECK (stroke_width >= 0) ); CREATE TABLE IF NOT EXISTS topology.diagram_legend_items ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), theme_id uuid NOT NULL REFERENCES topology.diagram_themes(id) ON DELETE CASCADE, legend_key text NOT NULL, label text NOT NULL, item_type text NOT NULL, style_ref text NOT NULL, display_order integer NOT NULL DEFAULT 0, is_visible boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT uq_diagram_legend_items_theme_key UNIQUE (theme_id, legend_key), CONSTRAINT ck_diagram_legend_item_type CHECK (item_type IN ('edge', 'decoration', 'node')) ); ALTER TABLE topology.diagram_layouts ADD COLUMN IF NOT EXISTS theme_id uuid REFERENCES topology.diagram_themes(id); ``` - [ ] **Step 2: Add seed helpers and constants** Keep the existing imports in `topology_api/seed/seed_layout.py`. Add these constants near the top after imports: ```python THEME_KEY = "guanghan_dark_engineering_v1" THEME_TOKENS = { "textColor": "#eaf2ff", "mutedTextColor": "#91a3bd", "metaColor": "#a6b5c9", "hintColor": "#71849e", } NODE_STYLES = [ ("node-card", "普通舱段", "#10263a", "#52baff", 3, 16), ("core-card", "核心舱", "#3b250d", "#ffad42", 5, 16), ("power-card", "电力模块分支", "#2a2810", "#d5b642", 3, 16), ("power-node-card", "电力模块中心", "#393412", "#d5b642", 5, 16), ("vehicle-card", "车辆", "#291c34", "#c28cdf", 3, 16), ] EDGE_STYLES = [ ("structural_mount", "结构直连", "#8fa1b8", 6, None, "square", "structural", 10), ("pressurized_passage", "加压走道", "#51e89c", 9, None, "square", "pressurized", 20), ("fuel", "燃料 / 供电", "#b178e6", 4, "13 10", "square", "utility", 30), ("power", "燃料 / 供电", "#b178e6", 4, "13 10", "square", "utility", 30), ("cooling", "冷却", "#78d6e6", 4, "10 8", "square", "cooling", 40), ("docking", "对接口", "#c28cdf", 4, "10 8", "square", "docking", 50), ] DECORATION_STYLES = [ ( "radiator", "大型折叠散热面板", "#6b5814", "#f0d869", 2, "散热器", "#f4df7b", { "widthRatio": 0.55, "maxWidth": 130, "height": 16, "offsetXRatio": 0.55, "offsetY": -18, "ribCount": 3, }, 40, ), ] LEGEND_ITEMS = [ ("structural", "结构直连", "edge", "structural_mount", 10), ("pressurized", "加压走道", "edge", "pressurized_passage", 20), ("utility", "燃料 / 供电", "edge", "fuel", 30), ("radiator", "大型折叠散热面板", "decoration", "radiator", 40), ] ``` - [ ] **Step 3: Seed the theme before layout upsert** Inside `seed()`, after loading `base_id`, insert: ```python cur.execute(""" INSERT INTO diagram_themes (id, base_id, theme_key, name, description, tokens, is_default) VALUES ( gen_random_uuid(), %(base)s, %(theme_key)s, '广寒深色工程图', '广寒基地工程总图默认表现层主题', %(tokens)s, true ) ON CONFLICT (base_id, theme_key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, tokens = EXCLUDED.tokens, is_default = true, updated_at = now() RETURNING id """, { "base": base_id, "theme_key": THEME_KEY, "tokens": json.dumps(THEME_TOKENS), }) theme_id = cur.fetchone()["id"] print(f"Theme: {theme_id}") ``` - [ ] **Step 4: Bind the layout to the theme** Change the layout upsert SQL in `seed_layout.py` from: ```python INSERT INTO diagram_layouts (id, base_id, layout_key, name, canvas_width, canvas_height, is_default) VALUES (gen_random_uuid(), %(base)s, 'engineering_overview_v1', '工程总图 v1', 1500, 1900, true) ON CONFLICT (base_id, layout_key) DO UPDATE SET name = EXCLUDED.name, is_default = true ``` to: ```python INSERT INTO diagram_layouts (id, base_id, layout_key, name, canvas_width, canvas_height, is_default, theme_id) VALUES (gen_random_uuid(), %(base)s, 'engineering_overview_v1', '工程总图 v1', 1500, 1900, true, %(theme)s) ON CONFLICT (base_id, layout_key) DO UPDATE SET name = EXCLUDED.name, is_default = true, theme_id = EXCLUDED.theme_id ``` and pass `"theme": theme_id` in the parameter dict. - [ ] **Step 5: Replace theme-dependent style rows** After the layout `RETURNING id` block and before component mapping, insert: ```python cur.execute("DELETE FROM diagram_legend_items WHERE theme_id = %(theme)s", {"theme": theme_id}) cur.execute("DELETE FROM diagram_decoration_styles WHERE theme_id = %(theme)s", {"theme": theme_id}) cur.execute("DELETE FROM diagram_edge_styles WHERE theme_id = %(theme)s", {"theme": theme_id}) cur.execute("DELETE FROM diagram_node_styles WHERE theme_id = %(theme)s", {"theme": theme_id}) for style_key, display_name, fill, stroke, stroke_width, corner_radius in NODE_STYLES: cur.execute(""" INSERT INTO diagram_node_styles (theme_id, style_key, display_name, fill_color, stroke_color, stroke_width, corner_radius, title_color, meta_color, hint_color) VALUES (%(theme)s, %(style_key)s, %(display_name)s, %(fill)s, %(stroke)s, %(stroke_width)s, %(corner_radius)s, %(title_color)s, %(meta_color)s, %(hint_color)s) """, { "theme": theme_id, "style_key": style_key, "display_name": display_name, "fill": fill, "stroke": stroke, "stroke_width": stroke_width, "corner_radius": corner_radius, "title_color": THEME_TOKENS["textColor"], "meta_color": THEME_TOKENS["metaColor"], "hint_color": THEME_TOKENS["hintColor"], }) for ( connection_type, display_name, stroke, stroke_width, dash_array, line_cap, legend_group, legend_order, ) in EDGE_STYLES: cur.execute(""" INSERT INTO diagram_edge_styles (theme_id, connection_type, display_name, stroke_color, stroke_width, dash_array, line_cap, legend_group, legend_order) VALUES (%(theme)s, %(connection_type)s, %(display_name)s, %(stroke)s, %(stroke_width)s, %(dash_array)s, %(line_cap)s, %(legend_group)s, %(legend_order)s) """, { "theme": theme_id, "connection_type": connection_type, "display_name": display_name, "stroke": stroke, "stroke_width": stroke_width, "dash_array": dash_array, "line_cap": line_cap, "legend_group": legend_group, "legend_order": legend_order, }) for ( decoration_key, display_name, fill, stroke, stroke_width, label, label_color, render_params, legend_order, ) in DECORATION_STYLES: cur.execute(""" INSERT INTO diagram_decoration_styles (theme_id, decoration_key, display_name, fill_color, stroke_color, stroke_width, label, label_color, render_params, legend_order) VALUES (%(theme)s, %(decoration_key)s, %(display_name)s, %(fill)s, %(stroke)s, %(stroke_width)s, %(label)s, %(label_color)s, %(render_params)s, %(legend_order)s) """, { "theme": theme_id, "decoration_key": decoration_key, "display_name": display_name, "fill": fill, "stroke": stroke, "stroke_width": stroke_width, "label": label, "label_color": label_color, "render_params": json.dumps(render_params), "legend_order": legend_order, }) for legend_key, label, item_type, style_ref, display_order in LEGEND_ITEMS: cur.execute(""" INSERT INTO diagram_legend_items (theme_id, legend_key, label, item_type, style_ref, display_order, is_visible) VALUES (%(theme)s, %(legend_key)s, %(label)s, %(item_type)s, %(style_ref)s, %(display_order)s, true) """, { "theme": theme_id, "legend_key": legend_key, "label": label, "item_type": item_type, "style_ref": style_ref, "display_order": display_order, }) ``` - [ ] **Step 6: Apply the migration and seed** Run a transactional migration application from PowerShell: ```powershell .\.venv\Scripts\python.exe -c "from pathlib import Path; from topology_api.db import get_writer_connection; sql = Path('topology_api/migrations/006_presentation_styles.sql').read_text(encoding='utf-8'); conn = get_writer_connection(); conn.execute('SET search_path TO topology, public'); conn.execute(sql); conn.commit(); conn.close(); print('applied 006_presentation_styles.sql')" .\.venv\Scripts\python.exe -m topology_api.seed.seed_layout ``` Expected output includes: ```text applied 006_presentation_styles.sql Theme: Layout: Nodes inserted: 14 ``` - [ ] **Step 7: Run the focused tests again** Run: ```powershell .\.venv\Scripts\python.exe -m unittest tests.test_topology_api.TopologyApiQueryTests.test_layout_includes_database_presentation_payload tests.test_topology_api.TopologyApiQueryTests.test_presentation_styles_are_database_backed -v ``` Expected after this task: ```text ERROR: test_layout_includes_database_presentation_payload KeyError: 'presentation' ``` The table should now exist, so the second test may still fail because the API does not read the table yet. If the second test also reports missing `presentation`, that is acceptable at this stage. - [ ] **Step 8: Commit schema and seed** Run: ```powershell git add topology_api/migrations/006_presentation_styles.sql topology_api/seed/seed_layout.py git commit -m "feat(topology): seed diagram presentation styles" ``` --- ### Task 3: Return presentation data from the snapshot API **Files:** - Modify: `topology_api/queries/snapshot_queries.py` - Test: `tests/test_topology_api.py` - [ ] **Step 1: Add serialization helpers** In `topology_api/queries/snapshot_queries.py`, add these helpers after the import: ```python def _require_presentation(condition, message): if not condition: raise RuntimeError(f"presentation_config_error: {message}") ``` - [ ] **Step 2: Extend the layout query to include theme metadata** In `fetch_layout()`, extend the SQL `SELECT` and joins: ```sql COALESCE(layout_theme.id, default_theme.id) AS theme_id, COALESCE(layout_theme.theme_key, default_theme.theme_key) AS theme_key, COALESCE(layout_theme.name, default_theme.name) AS theme_name, COALESCE(layout_theme.tokens, default_theme.tokens) AS theme_tokens, ``` Add joins before `WHERE`: ```sql LEFT JOIN diagram_themes layout_theme ON layout_theme.id = dl.theme_id LEFT JOIN diagram_themes default_theme ON default_theme.base_id = dl.base_id AND default_theme.is_default = true ``` Set `layout_info` to include the theme: ```python layout_info = { "key": row["layout_key"], "name": row["layout_name"], "canvasWidth": float(row["canvas_width"]), "canvasHeight": float(row["canvas_height"]), "themeKey": row["theme_key"], } theme_info = { "id": row["theme_id"], "key": row["theme_key"], "name": row["theme_name"], "tokens": row["theme_tokens"] or {}, } ``` Initialize `theme_info = None` before the loop. - [ ] **Step 3: Query presentation tables after nodes are loaded** At the end of `fetch_layout()`, before returning, add: ```python if layout_info is None: return None _require_presentation(theme_info and theme_info["id"], "default theme missing") theme_id = theme_info["id"] cur.execute(""" SELECT style_key, display_name, fill_color, stroke_color, stroke_width, corner_radius, title_color, meta_color, hint_color FROM diagram_node_styles WHERE theme_id = %(theme)s ORDER BY style_key """, {"theme": theme_id}) node_styles = { row["style_key"]: { "displayName": row["display_name"], "fillColor": row["fill_color"], "strokeColor": row["stroke_color"], "strokeWidth": float(row["stroke_width"]), "cornerRadius": float(row["corner_radius"]), "titleColor": row["title_color"], "metaColor": row["meta_color"], "hintColor": row["hint_color"], } for row in cur.fetchall() } cur.execute(""" SELECT connection_type, display_name, stroke_color, stroke_width, dash_array, line_cap, legend_group FROM diagram_edge_styles WHERE theme_id = %(theme)s ORDER BY connection_type """, {"theme": theme_id}) edge_styles = { row["connection_type"]: { "displayName": row["display_name"], "strokeColor": row["stroke_color"], "strokeWidth": float(row["stroke_width"]), "dashArray": row["dash_array"], "lineCap": row["line_cap"], "legendGroup": row["legend_group"], } for row in cur.fetchall() } cur.execute(""" SELECT decoration_key, display_name, fill_color, stroke_color, stroke_width, label, label_color, render_params FROM diagram_decoration_styles WHERE theme_id = %(theme)s ORDER BY decoration_key """, {"theme": theme_id}) decoration_styles = { row["decoration_key"]: { "displayName": row["display_name"], "fillColor": row["fill_color"], "strokeColor": row["stroke_color"], "strokeWidth": float(row["stroke_width"]), "label": row["label"], "labelColor": row["label_color"], "renderParams": row["render_params"] or {}, } for row in cur.fetchall() } cur.execute(""" SELECT legend_key, label, item_type, style_ref, display_order FROM diagram_legend_items WHERE theme_id = %(theme)s AND is_visible = true ORDER BY display_order, legend_key """, {"theme": theme_id}) legend_items = [ { "key": row["legend_key"], "label": row["label"], "itemType": row["item_type"], "styleRef": row["style_ref"], "displayOrder": row["display_order"], } for row in cur.fetchall() ] missing_node_styles = { node["styleKey"] for node in nodes } - set(node_styles) _require_presentation( not missing_node_styles, f"missing node styles: {sorted(missing_node_styles)}", ) return { "layout": layout_info, "nodes": nodes, "presentation": { "theme": { "key": theme_info["key"], "name": theme_info["name"], "tokens": theme_info["tokens"], }, "nodeStyles": node_styles, "edgeStyles": edge_styles, "decorationStyles": decoration_styles, "legendItems": legend_items, }, } ``` Remove or replace the existing final line: ```python return {"layout": layout_info, "nodes": nodes} if layout_info else None ``` - [ ] **Step 4: Run focused tests** Run: ```powershell .\.venv\Scripts\python.exe -m unittest tests.test_topology_api.TopologyApiQueryTests.test_layout_includes_database_presentation_payload tests.test_topology_api.TopologyApiQueryTests.test_presentation_styles_are_database_backed -v ``` Expected: ```text OK ``` - [ ] **Step 5: Run full API tests** Run: ```powershell .\.venv\Scripts\python.exe -m unittest tests.test_topology_api -v ``` Expected: ```text OK ``` - [ ] **Step 6: Commit API presentation response** Run: ```powershell git add topology_api/queries/snapshot_queries.py tests/test_topology_api.py git commit -m "feat(topology): return diagram presentation styles" ``` --- ### Task 4: Add frontend source guardrails **Files:** - Modify: `tests/test_guanghan_topology_demo.py` - Test target: `data/wiki/guanghan_topology_demo.html` - [ ] **Step 1: Add a source test for presentation usage** Append to `GuanghanTopologySourceTests`: ```python def test_page_uses_database_presentation_payload(self) -> None: self.assertIn("currentData.layout.presentation", self.html) self.assertIn("nodeStyles", self.html) self.assertIn("edgeStyles", self.html) self.assertIn("decorationStyles", self.html) self.assertIn("legendItems", self.html) ``` - [ ] **Step 2: Add a source test against hardcoded business colors** Append: ```python def test_business_visual_semantics_are_not_css_hardcoded(self) -> None: forbidden = [ ".connection.structural", ".connection.pressurized", ".connection.utility", ".node-card { fill:", ".core-card { fill:", ".power-card { fill:", ".power-node-card { fill:", ".vehicle-card { fill:", "function connectionClass", ] for token in forbidden: self.assertNotIn(token, self.html) ``` - [ ] **Step 3: Add a source test against hardcoded legend items** Append: ```python def test_legend_is_rendered_from_presentation_payload(self) -> None: self.assertIn('id="legend"', self.html) self.assertNotIn("结构直连", self.html) self.assertNotIn("加压走道", self.html) self.assertNotIn("燃料 / 供电", self.html) self.assertNotIn("大型折叠散热面板", self.html) self.assertIn("renderLegend", self.html) ``` - [ ] **Step 4: Run source tests to verify failure** Run: ```powershell .\.venv\Scripts\python.exe -m unittest tests.test_guanghan_topology_demo -v ``` Expected: ```text FAIL: test_page_uses_database_presentation_payload FAIL: test_business_visual_semantics_are_not_css_hardcoded FAIL: test_legend_is_rendered_from_presentation_payload ``` - [ ] **Step 5: Commit failing source guardrails** Run: ```powershell git add tests/test_guanghan_topology_demo.py git commit -m "test(topology): require database-driven visual semantics" ``` --- ### Task 5: Switch the demo page to presentation-driven rendering **Files:** - Modify: `data/wiki/guanghan_topology_demo.html` - Test: `tests/test_guanghan_topology_demo.py` - Test: `tests/test_guanghan_topology_e2e.py` - [ ] **Step 1: Replace hardcoded legend markup with an empty container** Change the legend block from hardcoded items to: ```html
``` - [ ] **Step 2: Remove business color CSS classes** Remove these CSS rules: ```css .legend-line.structural { border-color: var(--structural); } .legend-line.pressurized { border-color: var(--pressurized); } .legend-line.utility { border-color: var(--utility); border-top-style: dashed; } .connection.structural { stroke: var(--structural); stroke-width: 6; } .connection.pressurized { stroke: var(--pressurized); stroke-width: 9; } .connection.utility { stroke: var(--utility); stroke-width: 4; stroke-dasharray: 13 10; } .node-card { fill: #10263a; stroke: var(--habitat); stroke-width: 3; } .core-card { fill: #3b250d; stroke: var(--core); stroke-width: 5; } .power-card { fill: #2a2810; stroke: var(--power); stroke-width: 3; } .power-node-card { fill: #393412; stroke: var(--power); stroke-width: 5; } .vehicle-card { fill: #291c34; stroke: var(--vehicle); stroke-width: 3; } ``` Keep generic rules such as `.connection`, `.topology-node`, `.node-title`, `.node-meta`, `.node-hint`, `.radiator`, `.legend-line`, and `.legend-chip`. - [ ] **Step 3: Add presentation accessor helpers** Add these JS helpers near existing rendering helpers: ```javascript function getPresentation() { const presentation = currentData?.layout?.presentation; if (!presentation) throw new Error('拓扑表现层资料未记录'); return presentation; } function requireStyle(collection, key, label) { const style = collection?.[key]; if (!style) throw new Error(`${label} 未记录: ${key}`); return style; } function setOptionalAttr(element, name, value) { if (value !== null && value !== undefined && value !== '') { element.setAttribute(name, value); } } ``` - [ ] **Step 4: Remove `connectionClass`** Delete the existing function: ```javascript function connectionClass(type) { if (type === 'pressurized_passage') return 'pressurized'; if (type === 'fuel' || type === 'power') return 'utility'; return 'structural'; } ``` If the function body currently differs, remove the whole `function connectionClass(...)` block. - [ ] **Step 5: Add style application helpers** Add: ```javascript function applyNodeStyle(rect, nodeStyle) { rect.setAttribute('fill', nodeStyle.fillColor); rect.setAttribute('stroke', nodeStyle.strokeColor); rect.setAttribute('stroke-width', nodeStyle.strokeWidth); rect.setAttribute('rx', nodeStyle.cornerRadius); } function applyTextStyle(textElement, color) { if (color) textElement.setAttribute('fill', color); } function applyEdgeStyle(line, edgeStyle) { line.setAttribute('stroke', edgeStyle.strokeColor); line.setAttribute('stroke-width', edgeStyle.strokeWidth); setOptionalAttr(line, 'stroke-dasharray', edgeStyle.dashArray); setOptionalAttr(line, 'stroke-linecap', edgeStyle.lineCap); } ``` - [ ] **Step 6: Update `appendNodeText` to accept a color** Change: ```javascript function appendNodeText(group, node, className, text, y) { ``` to: ```javascript function appendNodeText(group, node, className, text, y, color) { ``` Add before `group.appendChild(element);`: ```javascript applyTextStyle(element, color); ``` - [ ] **Step 7: Update `createRadiator` to use database style** Change the function signature: ```javascript function createRadiator(node, style) { ``` Use these defaults and DB params: ```javascript const params = style.renderParams || {}; const widthRatio = Number(params.widthRatio ?? 0.55); const maxWidth = Number(params.maxWidth ?? 130); const height = Number(params.height ?? 16); const offsetXRatio = Number(params.offsetXRatio ?? 0.55); const offsetY = Number(params.offsetY ?? -18); const ribCount = Number(params.ribCount ?? 3); const width = Math.min(maxWidth, node.width * widthRatio); const x = node.x + node.width * offsetXRatio; const y = node.y + offsetY; ``` Create the rectangle with DB style: ```javascript group.appendChild(svgElement('rect', { x, y, width, height, rx: 3, fill: style.fillColor, stroke: style.strokeColor, 'stroke-width': style.strokeWidth, })); ``` Render ribs: ```javascript for (let index = 1; index <= ribCount; index += 1) { const lineX = x + width * index / (ribCount + 1); group.appendChild(svgElement('line', { x1: lineX, y1: y + 1, x2: lineX, y2: y + height - 1, stroke: style.labelColor || style.strokeColor, 'stroke-width': 1.5, })); } ``` Use DB label: ```javascript if (style.label) { const label = svgElement('text', { class: 'radiator-label', x: x + width / 2, y: y - 8, fill: style.labelColor || style.strokeColor, }); label.textContent = style.label; group.appendChild(label); } ``` - [ ] **Step 8: Add dynamic legend rendering** Add this function: ```javascript function renderLegend() { const legend = document.getElementById('legend'); const presentation = getPresentation(); const items = [...(presentation.legendItems || [])] .sort((left, right) => left.displayOrder - right.displayOrder); const elements = items.map(item => { const wrapper = document.createElement('span'); wrapper.className = 'legend-item'; if (item.itemType === 'edge') { const style = requireStyle( presentation.edgeStyles, item.styleRef, '连接样式', ); const sample = document.createElement('i'); sample.className = 'legend-line'; sample.style.borderColor = style.strokeColor; sample.style.borderTopWidth = `${Math.max(2, Number(style.strokeWidth || 4) / 2)}px`; if (style.dashArray) sample.style.borderTopStyle = 'dashed'; wrapper.appendChild(sample); } else if (item.itemType === 'decoration') { const style = requireStyle( presentation.decorationStyles, item.styleRef, '装饰样式', ); const sample = document.createElement('i'); sample.className = 'legend-chip'; sample.style.background = style.fillColor; sample.style.borderColor = style.strokeColor; wrapper.appendChild(sample); } else { const style = requireStyle( presentation.nodeStyles, item.styleRef, '节点样式', ); const sample = document.createElement('i'); sample.className = 'legend-chip'; sample.style.background = style.fillColor; sample.style.borderColor = style.strokeColor; wrapper.appendChild(sample); } wrapper.append(document.createTextNode(item.label)); return wrapper; }); legend.replaceChildren(...elements); } ``` - [ ] **Step 9: Update edge rendering to use DB styles** Inside `renderTopology()`, create: ```javascript const presentation = getPresentation(); ``` Replace: ```javascript const style = connectionClass(connection.type); const pair = [fromNode.nodeKey, toNode.nodeKey].sort(); const key = `${pair[0]}|${pair[1]}|${style}`; ``` with: ```javascript const edgeStyle = requireStyle( presentation.edgeStyles, connection.type, '连接样式', ); const pair = [fromNode.nodeKey, toNode.nodeKey].sort(); const key = `${pair[0]}|${pair[1]}|${connection.type}`; ``` Store `edgeStyle` in the visual connection: ```javascript visualConnections.set(key, { fromNode, toNode, edgeStyle, connection }); ``` Replace the render loop signature: ```javascript visualConnections.forEach(({ fromNode, toNode, edgeStyle, connection }) => { ``` Build the line without style classes: ```javascript const line = svgElement('line', { class: 'connection', x1: start.x, y1: start.y, x2: end.x, y2: end.y, 'data-type': connection.type, 'data-from': fromNode.nodeKey, 'data-to': toNode.nodeKey, }); applyEdgeStyle(line, edgeStyle); connectionLayer.appendChild(line); ``` - [ ] **Step 10: Update decoration rendering** Replace: ```javascript if (node.decorations && node.decorations.radiator) { decorationLayer.appendChild(createRadiator(node)); } ``` with: ```javascript if (node.decorations && node.decorations.radiator) { const radiatorStyle = requireStyle( presentation.decorationStyles, 'radiator', '装饰样式', ); decorationLayer.appendChild(createRadiator(node, radiatorStyle)); } ``` - [ ] **Step 11: Update node rectangle and text rendering** Before creating the group for each node: ```javascript const nodeStyle = requireStyle( presentation.nodeStyles, node.styleKey, '节点样式', ); ``` Replace direct `rect` append: ```javascript group.appendChild(svgElement('rect', { class: node.styleKey, x: node.x, y: node.y, width: node.width, height: node.height, rx: 16, })); ``` with: ```javascript const rect = svgElement('rect', { class: 'node-shape', x: node.x, y: node.y, width: node.width, height: node.height, }); applyNodeStyle(rect, nodeStyle); group.appendChild(rect); ``` Update text calls: ```javascript appendNodeText( group, node, 'node-title', title, node.y + node.height * (hasHint ? 0.34 : 0.42), nodeStyle.titleColor || presentation.theme.tokens.textColor, ); appendNodeText( group, node, 'node-meta', node.displayMeta, node.y + node.height * (hasHint ? 0.60 : 0.72), nodeStyle.metaColor || presentation.theme.tokens.metaColor, ); appendNodeText( group, node, 'node-hint', node.displayHint, node.y + node.height * 0.82, nodeStyle.hintColor || presentation.theme.tokens.hintColor, ); ``` - [ ] **Step 12: Call `renderLegend()` after data load** In the successful load path, immediately before or after `renderTopology()`, call: ```javascript renderLegend(); ``` The call must happen after `currentData` is assigned. - [ ] **Step 13: Run source tests** Run: ```powershell .\.venv\Scripts\python.exe -m unittest tests.test_guanghan_topology_demo -v ``` Expected: ```text OK ``` - [ ] **Step 14: Run existing E2E and update pressurized selectors** Run: ```powershell .\.venv\Scripts\python.exe -m unittest tests.test_guanghan_topology_e2e -v ``` Expected current failure: ```text FAIL: expected locator(".connection.pressurized").count() == 2 ``` Update selectors in `tests/test_guanghan_topology_e2e.py`: ```python await page.locator('.connection[data-type="pressurized_passage"]').count() ``` and: ```python strokes = await page.locator( '.connection[data-type="pressurized_passage"]' ).evaluate_all( "(els) => els.map((el) => getComputedStyle(el).stroke)" ) ``` - [ ] **Step 15: Run source and E2E tests** Run: ```powershell .\.venv\Scripts\python.exe -m unittest tests.test_guanghan_topology_demo tests.test_guanghan_topology_e2e -v ``` Expected: ```text OK ``` - [ ] **Step 16: Commit frontend switch** Run: ```powershell git add data/wiki/guanghan_topology_demo.html tests/test_guanghan_topology_demo.py tests/test_guanghan_topology_e2e.py git commit -m "feat(topology): render styles from presentation payload" ``` --- ### Task 6: Add browser tests for DB style mutation and visual verification **Files:** - Modify: `tests/test_guanghan_topology_e2e.py` - Create: `docs/guanghan_topology_presentation_db_verification_2026-07-09.md` - Add screenshots under: `demo/topology_presentation_db/` - [ ] **Step 1: Add a test for database-driven edge color** Add this method to `GuanghanTopologyE2ETests`: ```python def test_database_edge_style_changes_browser_rendering(self) -> None: async def scenario(base_url: str) -> None: from topology_api.db import get_writer_connection with get_writer_connection() as conn: conn.execute("SET search_path TO topology, public") with conn.cursor() as cur: cur.execute(""" SELECT stroke_color FROM diagram_edge_styles WHERE connection_type = 'pressurized_passage' """) original_color = cur.fetchone()["stroke_color"] cur.execute(""" UPDATE diagram_edge_styles SET stroke_color = '#00ffaa' WHERE connection_type = 'pressurized_passage' """) conn.commit() try: async with async_playwright() as pw: browser = await pw.chromium.launch(headless=True) page = await browser.new_page( viewport={"width": 1440, "height": 1000} ) await page.goto( f"{base_url}/guanghan_topology_demo.html" "?mission=GHC-05", wait_until="networkidle", ) await page.wait_for_function( """() => document.querySelectorAll( '.connection[data-type="pressurized_passage"]' ).length === 2""" ) strokes = await page.locator( '.connection[data-type="pressurized_passage"]' ).evaluate_all( "(els) => els.map((el) => getComputedStyle(el).stroke)" ) self.assertEqual( strokes, ["rgb(0, 255, 170)", "rgb(0, 255, 170)"], ) await browser.close() finally: with conn.cursor() as cur: cur.execute(""" UPDATE diagram_edge_styles SET stroke_color = %(original)s WHERE connection_type = 'pressurized_passage' """, {"original": original_color}) conn.commit() with topology_server() as base_url: asyncio.run(scenario(base_url)) ``` - [ ] **Step 2: Add a test for database-driven node color** Add: ```python def test_database_node_style_changes_browser_rendering(self) -> None: async def scenario(base_url: str) -> None: from topology_api.db import get_writer_connection with get_writer_connection() as conn: conn.execute("SET search_path TO topology, public") with conn.cursor() as cur: cur.execute(""" SELECT fill_color FROM diagram_node_styles WHERE style_key = 'core-card' """) original_fill = cur.fetchone()["fill_color"] cur.execute(""" UPDATE diagram_node_styles SET fill_color = '#123456' WHERE style_key = 'core-card' """) conn.commit() try: async with async_playwright() as pw: browser = await pw.chromium.launch(headless=True) page = await browser.new_page( viewport={"width": 1440, "height": 1000} ) await page.goto( f"{base_url}/guanghan_topology_demo.html" "?mission=GHC-05", wait_until="networkidle", ) core_fill = await page.locator( '.topology-node[data-node-key="core_cabin"] rect' ).evaluate( "(el) => getComputedStyle(el).fill" ) self.assertEqual(core_fill, "rgb(18, 52, 86)") await browser.close() finally: with conn.cursor() as cur: cur.execute(""" UPDATE diagram_node_styles SET fill_color = %(original)s WHERE style_key = 'core-card' """, {"original": original_fill}) conn.commit() with topology_server() as base_url: asyncio.run(scenario(base_url)) ``` - [ ] **Step 3: Add a test that legend comes from presentation** Add: ```python def test_legend_renders_from_database_presentation(self) -> None: async def scenario(base_url: str) -> None: async with async_playwright() as pw: browser = await pw.chromium.launch(headless=True) page = await browser.new_page( viewport={"width": 1440, "height": 1000} ) await page.goto( f"{base_url}/guanghan_topology_demo.html?mission=GHC-05", wait_until="networkidle", ) labels = await page.locator("#legend .legend-item").evaluate_all( "(items) => items.map(item => item.textContent.trim())" ) self.assertEqual( labels, ["结构直连", "加压走道", "燃料 / 供电", "大型折叠散热面板"], ) await browser.close() with topology_server() as base_url: asyncio.run(scenario(base_url)) ``` - [ ] **Step 4: Run full automated tests** Run: ```powershell .\.venv\Scripts\python.exe -m unittest tests.test_topology_api tests.test_guanghan_topology_demo tests.test_guanghan_topology_e2e -v ``` Expected: ```text OK ``` - [ ] **Step 5: Restart the local service** Stop any existing topology API process: ```powershell Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'topology_api\.app' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force } ``` Start the service: ```powershell Start-Process -WindowStyle Hidden -FilePath ".\.venv\Scripts\python.exe" -ArgumentList "-m", "topology_api.app" ``` Confirm health: ```powershell (Invoke-WebRequest -UseBasicParsing http://localhost:8080/health).StatusCode ``` Expected: ```text 200 ``` - [ ] **Step 6: Capture required screenshots** Use Playwright or the in-app browser to capture these files: ```text demo/topology_presentation_db/ghc05_top.png demo/topology_presentation_db/ghc05_mid.png demo/topology_presentation_db/ghc05_bottom.png demo/topology_presentation_db/ghc01_snapshot.png demo/topology_presentation_db/legend.png demo/topology_presentation_db/db_edge_color_override.png demo/topology_presentation_db/db_node_color_override.png ``` Required visual checks: - `ghc05_top.png`: core cross remains correct; Wugang-greenhouse pressurized line is visible. - `ghc05_mid.png`: habitat #1 to power north pressurized line is visible; radiators do not cover corridors. - `ghc05_bottom.png`: Binglun vehicles remain west/south/east of the power module. - `ghc01_snapshot.png`: early historical snapshot still shows only the expected early modules. - `legend.png`: legend order and labels come from database. - `db_edge_color_override.png`: temporary pressurized edge color override is visible. - `db_node_color_override.png`: temporary core node fill override is visible. - [ ] **Step 7: Write the verification report** Create `docs/guanghan_topology_presentation_db_verification_2026-07-09.md`: ```markdown # 广寒基地拓扑图表现层数据库化验证报告 日期:2026-07-09 ## 结论 页面已经切换为 B1 表现层数据库驱动:节点样式、连接样式、散热器样式和图例来自 `layout.presentation`。 ## 自动化测试 命令: ```powershell .\.venv\Scripts\python.exe -m unittest tests.test_topology_api tests.test_guanghan_topology_demo tests.test_guanghan_topology_e2e -v ``` 结果:通过。 ## 服务验证 `http://localhost:8080/health` 返回 200。 ## 截图 - `demo/topology_presentation_db/ghc05_top.png` - `demo/topology_presentation_db/ghc05_mid.png` - `demo/topology_presentation_db/ghc05_bottom.png` - `demo/topology_presentation_db/ghc01_snapshot.png` - `demo/topology_presentation_db/legend.png` - `demo/topology_presentation_db/db_edge_color_override.png` - `demo/topology_presentation_db/db_node_color_override.png` ## 人工核查 - GHC-05 空间结构保持不变。 - 两条加压走道仍为数据库默认绿色实线。 - 散热器不覆盖加压走道和模块主体。 - 图例由 API 返回并渲染。 - 临时修改数据库连接线颜色后,浏览器渲染随之变化。 - 临时修改数据库核心舱填充色后,浏览器渲染随之变化。 ``` - [ ] **Step 8: Run final checks** Run: ```powershell git diff --check .\.venv\Scripts\python.exe -m unittest tests.test_topology_api tests.test_guanghan_topology_demo tests.test_guanghan_topology_e2e -v ``` Expected: ```text OK ``` - [ ] **Step 9: Commit final verification** Run: ```powershell git add tests/test_guanghan_topology_e2e.py docs/guanghan_topology_presentation_db_verification_2026-07-09.md demo/topology_presentation_db git commit -m "test(topology): verify database-driven presentation rendering" ``` --- ## Final delivery checklist Before reporting completion: - [ ] `git status --short` reviewed; unrelated user files remain untouched. - [ ] `git diff --check` passes. - [ ] Full unit/E2E command passes. - [ ] `http://localhost:8080/health` returns 200 after service restart. - [ ] GHC-05 screenshots reviewed manually. - [ ] Verification report committed. - [ ] Final response includes current branch, commit list, tests run, screenshot paths, and page URL. ## Expected commit sequence 1. `test(topology): require database presentation payload` 2. `feat(topology): seed diagram presentation styles` 3. `feat(topology): return diagram presentation styles` 4. `test(topology): require database-driven visual semantics` 5. `feat(topology): render styles from presentation payload` 6. `test(topology): verify database-driven presentation rendering`