1647 lines
53 KiB
Markdown
1647 lines
53 KiB
Markdown
# 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("结构直连</span>", self.html)
|
|
self.assertNotIn("加压走道</span>", self.html)
|
|
self.assertNotIn("燃料 / 供电</span>", self.html)
|
|
self.assertNotIn("大型折叠散热面板</span>", 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
|
|
<div class="legend" id="legend" aria-label="图例"></div>
|
|
```
|
|
|
|
- [ ] **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`
|