fix(chat): 前端审计修复——预览契约改HTML/状态机统一/进度错误持久化/chat_state.js抽取

- app.py: /result/preview 返回渲染 HTML(HTMLResponse) 而非 {html} JSON;新增 /chat_state.js 静态路由;预览缺失返回 404(RESULT_NOT_FOUND)
- chat.html: currentProject 统一为 draftProject;新会话继承已选项目(§4.2);上传区显隐/类型映射/抽屉脏检测改用 GenesisState;loadSession 按 role 渲染 progress/error;加 h1 标题
- chat_state.js(新): 抽取 autoBindProject/shouldHideUploadSelect/resolveUploadType/computeDrawerSnapshot/isDrawerDirty/buildWelcome 纯函数(U+1 分隔符)
- agent.py: 新增 _persist_progress/_store_error,进度与错误以 role 入库(重载可见)
- 测试: test_chat_state.js(11 passed) + test_frontend_audit_fixes.py(5) + 修正 test_server_api 旧 JSON 契约断言;全量 pytest 568 passed / 99.06%
This commit is contained in:
lhl
2026-08-29 11:28:08 +08:00
parent c7897a15c1
commit be79624db5
9 changed files with 638 additions and 43 deletions
+51
View File
@@ -0,0 +1,51 @@
// 前端核心状态逻辑单测(Node 原生 test runnerUMD 模块可在 Node 下 require
// 对应方案 docs/frontend_audit_plan.md §7.1 / §4.4
const test = require('node:test');
const assert = require('node:assert/strict');
const S = require('../src/genesis/server/static/chat_state.js');
test('autoBindProject: 启动无项目且有项目时自动绑首项', () => {
assert.equal(S.autoBindProject(null, [{ name: 'A' }, { name: 'B' }]), 'A');
});
test('autoBindProject: 指向已删除项目时回退首项', () => {
assert.equal(S.autoBindProject('X', [{ name: 'A' }]), 'A');
});
test('autoBindProject: 已选有效项目保持不变', () => {
assert.equal(S.autoBindProject('A', [{ name: 'A' }]), 'A');
});
test('shouldHideUploadSelect: 未绑定任何项目时显示下拉', () => {
assert.equal(S.shouldHideUploadSelect(null, null), false);
});
test('shouldHideUploadSelect: 已绑定项目(draft 或 active)时隐藏下拉', () => {
assert.equal(S.shouldHideUploadSelect(null, 'A'), true);
assert.equal(S.shouldHideUploadSelect('A', null), true);
});
test('resolveUploadType: 绑定项目时强制 requirements', () => {
assert.equal(S.resolveUploadType(null, 'A', 'template'), 'requirements');
});
test('resolveUploadType: 未绑定项目时保留用户选择', () => {
assert.equal(S.resolveUploadType(null, null, 'template'), 'template');
});
test('computeDrawerSnapshot: 去空白并以单元分隔符连接', () => {
assert.equal(S.computeDrawerSnapshot([' a ', 'b']), 'a\u0001b');
});
test('isDrawerDirty: 快照不同为脏,相同为干净', () => {
assert.equal(S.isDrawerDirty('x', 'y'), true);
assert.equal(S.isDrawerDirty('x', 'x'), false);
});
test('buildWelcome: 无项目提示新建/选择项目', () => {
assert.match(S.buildWelcome(null), /请新建项目|选择项目/);
});
test('buildWelcome: 有项目提示由项目提供模板/规则/代码库', () => {
assert.match(S.buildWelcome('A'), /项目「A」/);
});
+94
View File
@@ -0,0 +1,94 @@
"""前端审计修复验证(docs/frontend_audit_plan.md §7.2/§7.3)。
覆盖:
- 预览契约返回 HTML(非 JSON)
- 进度消息持久化 role='progress'(重载会话可见)
- 错误消息持久化 role='error'
- /chat_state.js 静态路由可用
"""
from __future__ import annotations
import pytest
from pathlib import Path
from fastapi.testclient import TestClient
from genesis.chat.agent import ChatAgent
from genesis.server.app import create_app
from genesis.server.service import GenesisService
from genesis.server.store import ProjectsStore, SessionStore
_SAMPLE = Path(__file__).resolve().parents[1] / "sample"
@pytest.fixture
def client(tmp_path):
app = create_app(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine="fake",
)
return TestClient(app)
def _upload_core(client, sid):
files = [
("requirements", "requirements_newdev.xlsx", _SAMPLE / "requirements_newdev.xlsx"),
("template", "template_design_ja.docx", _SAMPLE / "template_design_ja.docx"),
("write_instruction", "rules_design_ja.docx", _SAMPLE / "rules_design_ja.docx"),
("rules", "rules_entry_ja.docx", _SAMPLE / "rules_entry_ja.docx"),
]
for ft, name, path in files:
r = client.post(f"/api/sessions/{sid}/files",
data={"file_type": ft},
files={"file": (name, path.read_bytes())})
assert r.status_code == 200, r.text
def test_result_preview_returns_html(client):
"""预览链接应返回渲染后的 HTML,而非 {'html': ...} JSON(§4.3)。"""
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
_upload_core(client, sid)
client.post(f"/api/chat/{sid}/messages", json={"content": "请生成概要设计书"})
r = client.get(f"/api/sessions/{sid}/result/preview")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert r.text.lstrip().lower().startswith("<!doctype html>")
def test_result_preview_missing_returns_404(client):
"""无结果文档时预览返回 404RESULT_NOT_FOUND)。"""
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
r = client.get(f"/api/sessions/{sid}/result/preview")
assert r.status_code == 404
assert r.json()["detail"]["code"] == "RESULT_NOT_FOUND"
def test_progress_messages_persisted(client):
"""生成全流程的进度条目应以 role='progress' 入库,重载会话可见(§4.6)。"""
sid = client.post("/api/sessions", json={"user_id": "u1"}).json()["session_id"]
_upload_core(client, sid)
client.post(f"/api/chat/{sid}/messages", json={"content": "请生成概要设计书"})
msgs = client.get(f"/api/chat/{sid}/messages").json()
assert any(m["role"] == "progress" for m in msgs)
def test_error_role_persisted(tmp_path):
"""错误回复以 role='error' 入库(§4.6 后端 _store_error)。"""
store = SessionStore(db_path=str(tmp_path / "s.db"))
projects = ProjectsStore(db_path=str(tmp_path / "s.db"))
service = GenesisService(store=store, data_root=str(tmp_path / "data"),
engine="fake", projects=projects)
agent = ChatAgent(service=service, fake=True, engine="fake")
sid = service.create_session("u1").session_id
agent._store_error(sid, "解析失败:boom", "generate")
msgs = service.store.list_messages(sid)
assert any(m["role"] == "error" for m in msgs)
def test_chat_state_js_route(client):
"""/chat_state.js 静态路由返回 JS(前端模块可加载,§4.5)。"""
r = client.get("/chat_state.js")
assert r.status_code == 200
assert "application/javascript" in r.headers["content-type"]
assert "GenesisState" in r.text or "autoBindProject" in r.text
+2 -1
View File
@@ -95,7 +95,8 @@ def test_full_flow_http(tmp_path):
prev = client.get(f"/api/sessions/{sid}/result/preview")
assert prev.status_code == 200
assert "html" in prev.json()
assert "text/html" in prev.headers["content-type"]
assert prev.text.lstrip().lower().startswith("<!doctype html>")
dl = client.get(f"/api/sessions/{sid}/result/download")
assert dl.status_code == 200