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:
@@ -0,0 +1,51 @@
|
||||
// 前端核心状态逻辑单测(Node 原生 test runner,UMD 模块可在 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」/);
|
||||
});
|
||||
@@ -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):
|
||||
"""无结果文档时预览返回 404(RESULT_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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user