fix(strategy-map): 战略地图三处 P1 缺陷(D1 完备度路由 422 / D2 连线删除不落库 / D3 窄屏拖拽失效)
D1 GET /api/cma/maps/completeness/objectives 恒 422:真抢占者是先注册的
objectives.router GET /{map_id}/objectives(map_id 被当 int 解析)。把
map_completeness / map_summary 两个字面路径路由器前移到 objectives 之前,
不动 prefix、不删参数化路由。新增同前缀族全量巡检用例。
D2 DELETE /{id}/connections 返回 200 不落库:canvas_data 为裸 Column(JSON),
就地/换列表赋值不被 ORM 判脏。模型改 MutableDict.as_mutable(JSON)(不改 DDL),
add_connection / delete_connection_by_key 均显式 flag_modified 双保险。
D3 窄视口 .kp-panel(fixed/z-1000) 遮画布,onTreeDragOver 用 elementFromPoint
取节点恒 null ⇒ 拖拽建连线失效。改为遍历画布内 [data-node-key] 做 rect 包含
判定、多点取中心最近者,不再回退 elementFromPoint。
测试:新增 backend/tests/test_map_route_shadowing_20261020.py(路由解析断言到
endpoint.__module__)与 test_map_connection_persistence_20261020.py(新 session
原生 SQL 复读 + 负测)。零生产写、零上线。
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""D2(2026-10-20 fullstack-20261020-strategy-map-3defects-fix)
|
||||
|
||||
缺陷:`DELETE /api/cma/maps/{id}/connections` 返回 200 但库里没变(POST 同样不可靠)。
|
||||
根因:`StrategicMap.canvas_data` 是裸 `Column(JSON)`,无变更跟踪;接口用
|
||||
「`m.canvas_data["connections"] = new_conns`」就地改,flush 时该列未被判脏 ⇒ 不写库。
|
||||
|
||||
判据铁律(任务 §1.2):**不能**用「响应体里 connections 变短了」当落库证据 ——
|
||||
必须新开 session 用原生 SQL 复读 `canvas_data`。
|
||||
"""
|
||||
import json
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.models import StrategicMap
|
||||
|
||||
|
||||
def _raw_canvas_data(map_id: int) -> dict:
|
||||
"""**新 session + 原生 SQL** 复读(不复用请求内 session)。"""
|
||||
from tests.conftest import TEST_SESSION_LOCAL
|
||||
|
||||
session = TEST_SESSION_LOCAL()
|
||||
try:
|
||||
row = session.execute(
|
||||
text("SELECT canvas_data FROM strategic_maps WHERE id = :mid"), {"mid": map_id}
|
||||
).first()
|
||||
assert row is not None, f"strategic_maps.id={map_id} 不存在"
|
||||
raw = row[0]
|
||||
finally:
|
||||
session.close()
|
||||
if isinstance(raw, (str, bytes)):
|
||||
return json.loads(raw)
|
||||
return raw or {}
|
||||
|
||||
|
||||
def _pairs(canvas: dict):
|
||||
return [(c.get("from"), c.get("to")) for c in (canvas.get("connections") or [])]
|
||||
|
||||
|
||||
def test_add_connection_persists_across_sessions(client, db):
|
||||
from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user
|
||||
|
||||
create_test_user(db)
|
||||
m = create_test_map(db, entity_id=1) # canvas_data={"connections": []}
|
||||
db.commit()
|
||||
token = get_token_for_user(client)
|
||||
h = auth_header(token)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/cma/maps/{m.id}/connections",
|
||||
json={"from": "learning-0", "to": "process-0"},
|
||||
headers=h,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert ("learning-0", "process-0") in _pairs(_raw_canvas_data(m.id)), (
|
||||
"POST /connections 返回 200 但新 session 原生 SQL 读不到该连线(未落库)"
|
||||
)
|
||||
|
||||
|
||||
def test_delete_connection_persists_across_sessions(client, db):
|
||||
from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user
|
||||
|
||||
create_test_user(db)
|
||||
m = create_test_map(
|
||||
db,
|
||||
entity_id=1,
|
||||
canvas_data={"connections": [
|
||||
{"from": "learning-0", "to": "process-0", "style": "solid", "effect": "positive", "label": ""},
|
||||
{"from": "process-0", "to": "customer-0", "style": "solid", "effect": "positive", "label": ""},
|
||||
]},
|
||||
)
|
||||
db.commit()
|
||||
token = get_token_for_user(client)
|
||||
h = auth_header(token)
|
||||
|
||||
resp = client.request(
|
||||
"DELETE",
|
||||
f"/api/cma/maps/{m.id}/connections",
|
||||
json={"from": "learning-0", "to": "process-0"},
|
||||
headers=h,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
persisted = _pairs(_raw_canvas_data(m.id))
|
||||
assert ("learning-0", "process-0") not in persisted, (
|
||||
f"DELETE 返回 200 但新 session 原生 SQL 仍读到已删连线:{persisted}"
|
||||
)
|
||||
assert ("process-0", "customer-0") in persisted, "无关连线被误删"
|
||||
|
||||
|
||||
def test_delete_missing_connection_still_404(client, db):
|
||||
"""负测:删除不存在的 from/to 仍 404(不得因标脏改动变成 200)。"""
|
||||
from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user
|
||||
|
||||
create_test_user(db)
|
||||
m = create_test_map(db, entity_id=1)
|
||||
db.commit()
|
||||
token = get_token_for_user(client)
|
||||
h = auth_header(token)
|
||||
|
||||
resp = client.request(
|
||||
"DELETE",
|
||||
f"/api/cma/maps/{m.id}/connections",
|
||||
json={"from": "learning-9", "to": "process-9"},
|
||||
headers=h,
|
||||
)
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
|
||||
def test_canvas_data_mutation_tracked_by_mutable_dict(db):
|
||||
"""模型层双保险:MutableDict 包装后,**不经 flag_modified** 的 dict 赋值也应落库。
|
||||
|
||||
说明:`MutableDict.as_mutable(JSON)` 返回的仍是 JSON 类型实例(跟踪靠 mapper 级
|
||||
attribute 事件 + coerce),因此这里用**行为断言**而不是断言列类型类名。
|
||||
"""
|
||||
m = StrategicMap(title="MutableDict 行为验证", entity_id=1, canvas_data={"connections": []})
|
||||
db.add(m)
|
||||
db.commit()
|
||||
mid = m.id
|
||||
db.expire_all()
|
||||
|
||||
loaded = db.query(StrategicMap).filter(StrategicMap.id == mid).first()
|
||||
# 就地改 dict(等价于接口里的 `m.canvas_data["connections"] = new_conns`)
|
||||
loaded.canvas_data["connections"] = [{"from": "learning-0", "to": "process-0"}]
|
||||
db.commit() # 不调用 flag_modified
|
||||
|
||||
assert ("learning-0", "process-0") in _pairs(_raw_canvas_data(mid)), (
|
||||
"canvas_data 未被 MutableDict 跟踪:dict 赋值后 commit 未落库"
|
||||
)
|
||||
Reference in New Issue
Block a user