diff --git a/docs/guanghan_topology_presentation_db_verification_2026-07-09.md b/docs/guanghan_topology_presentation_db_verification_2026-07-09.md new file mode 100644 index 0000000..705d22b --- /dev/null +++ b/docs/guanghan_topology_presentation_db_verification_2026-07-09.md @@ -0,0 +1,46 @@ +# 广寒基地拓扑图表现层数据库化验证报告 + +日期: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 +``` + +结果:**27 tests OK** + +- API 测试:14 passed(含 presentation payload 验证、DB 风格变更反映) +- 源码测试:8 passed(含硬编码 CSS 清除、legend 动态渲染) +- E2E 测试:5 passed(含 DB 风格变更浏览器反映、历史快照、legend 验证) + +## 服务验证 + +`http://localhost:8080/health` 返回 200。 + +## 人工核查 + +- GHC-05 空间结构保持不变。 +- 两条加压走道仍为数据库默认绿色实线(`#51e89c`)。 +- 散热器不覆盖加压走道和模块主体。 +- 图例由 API 返回并渲染(`renderLegend()` 从 `layout.presentation.legendItems` 动态生成)。 +- 临时修改数据库连接线颜色后,浏览器渲染随之变化(E2E 验证 `#00ffaa`)。 +- 临时修改数据库核心舱填充色后,浏览器渲染随之变化(E2E 验证 `#123456`)。 + +## 数据库表 + +新增 5 张表现层表: + +| 表 | 用途 | +|----|------| +| `diagram_themes` | 主题元数据(默认 `guanghan_dark_engineering_v1`) | +| `diagram_node_styles` | 节点样式(5 种:node-card, core-card, power-card, power-node-card, vehicle-card) | +| `diagram_edge_styles` | 连接样式(6 种:structural_mount, pressurized_passage, fuel, power, cooling, docking) | +| `diagram_decoration_styles` | 装饰样式(1 种:radiator) | +| `diagram_legend_items` | 图例项(4 项:结构直连, 加压走道, 燃料/供电, 大型折叠散热面板) | diff --git a/tests/test_guanghan_topology_e2e.py b/tests/test_guanghan_topology_e2e.py index 820f022..c52c80d 100644 --- a/tests/test_guanghan_topology_e2e.py +++ b/tests/test_guanghan_topology_e2e.py @@ -81,11 +81,11 @@ class GuanghanTopologyE2ETests(unittest.TestCase): await page.locator(".topology-node").count(), 14 ) self.assertEqual( - await page.locator(".connection.pressurized").count(), 2 + await page.locator('.connection[data-type="pressurized_passage"]').count(), 2 ) self.assertEqual(await page.locator(".radiator").count(), 4) strokes = await page.locator( - ".connection.pressurized" + '.connection[data-type="pressurized_passage"]' ).evaluate_all( "(els) => els.map((el) => getComputedStyle(el).stroke)" ) @@ -220,5 +220,135 @@ class GuanghanTopologyE2ETests(unittest.TestCase): asyncio.run(scenario(base_url)) + 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)) + + 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)) + + 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)) + + if __name__ == "__main__": unittest.main()