Files
KSP_project/docs/superpowers/plans/2026-07-09-guanghan-topology-presentation-db-implementation.md
T

53 KiB

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:

    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:

    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:

.\.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:

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:

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:

-- 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:

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:

        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:

            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:

            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:

        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:

.\.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:

applied 006_presentation_styles.sql
Theme:
Layout:
Nodes inserted: 14
  • Step 7: Run the focused tests again

Run:

.\.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:

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:

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:

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:

                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:

            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:

                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:

        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:

        return {"layout": layout_info, "nodes": nodes} if layout_info else None
  • Step 4: Run focused tests

Run:

.\.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:

OK
  • Step 5: Run full API tests

Run:

.\.venv\Scripts\python.exe -m unittest tests.test_topology_api -v

Expected:

OK
  • Step 6: Commit API presentation response

Run:

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:

    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:

    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:

    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:

.\.venv\Scripts\python.exe -m unittest tests.test_guanghan_topology_demo -v

Expected:

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:

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:

  <div class="legend" id="legend" aria-label="图例"></div>
  • Step 2: Remove business color CSS classes

Remove these CSS rules:

  .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:

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:

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:

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:

function appendNodeText(group, node, className, text, y) {

to:

function appendNodeText(group, node, className, text, y, color) {

Add before group.appendChild(element);:

  applyTextStyle(element, color);
  • Step 7: Update createRadiator to use database style

Change the function signature:

function createRadiator(node, style) {

Use these defaults and DB params:

  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:

  group.appendChild(svgElement('rect', {
    x, y, width, height, rx: 3,
    fill: style.fillColor,
    stroke: style.strokeColor,
    'stroke-width': style.strokeWidth,
  }));

Render ribs:

  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:

  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:

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:

  const presentation = getPresentation();

Replace:

    const style = connectionClass(connection.type);
    const pair = [fromNode.nodeKey, toNode.nodeKey].sort();
    const key = `${pair[0]}|${pair[1]}|${style}`;

with:

    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:

      visualConnections.set(key, { fromNode, toNode, edgeStyle, connection });

Replace the render loop signature:

  visualConnections.forEach(({ fromNode, toNode, edgeStyle, connection }) => {

Build the line without style classes:

    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:

    if (node.decorations && node.decorations.radiator) {
      decorationLayer.appendChild(createRadiator(node));
    }

with:

    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:

      const nodeStyle = requireStyle(
        presentation.nodeStyles,
        node.styleKey,
        '节点样式',
      );

Replace direct rect append:

      group.appendChild(svgElement('rect', {
        class: node.styleKey,
        x: node.x,
        y: node.y,
        width: node.width,
        height: node.height,
        rx: 16,
      }));

with:

      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:

      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:

    renderLegend();

The call must happen after currentData is assigned.

  • Step 13: Run source tests

Run:

.\.venv\Scripts\python.exe -m unittest tests.test_guanghan_topology_demo -v

Expected:

OK
  • Step 14: Run existing E2E and update pressurized selectors

Run:

.\.venv\Scripts\python.exe -m unittest tests.test_guanghan_topology_e2e -v

Expected current failure:

FAIL: expected locator(".connection.pressurized").count() == 2

Update selectors in tests/test_guanghan_topology_e2e.py:

await page.locator('.connection[data-type="pressurized_passage"]').count()

and:

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:

.\.venv\Scripts\python.exe -m unittest tests.test_guanghan_topology_demo tests.test_guanghan_topology_e2e -v

Expected:

OK
  • Step 16: Commit frontend switch

Run:

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:

    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:

    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:

    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:

.\.venv\Scripts\python.exe -m unittest tests.test_topology_api tests.test_guanghan_topology_demo tests.test_guanghan_topology_e2e -v

Expected:

OK
  • Step 5: Restart the local service

Stop any existing topology API process:

Get-CimInstance Win32_Process |
  Where-Object { $_.CommandLine -match 'topology_api\.app' } |
  ForEach-Object { Stop-Process -Id $_.ProcessId -Force }

Start the service:

Start-Process -WindowStyle Hidden -FilePath ".\.venv\Scripts\python.exe" -ArgumentList "-m", "topology_api.app"

Confirm health:

(Invoke-WebRequest -UseBasicParsing http://localhost:8080/health).StatusCode

Expected:

200
  • Step 6: Capture required screenshots

Use Playwright or the in-app browser to capture these files:

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:

# 广寒基地拓扑图表现层数据库化验证报告

日期: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:

OK
  • Step 9: Commit final verification

Run:

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