Files
2026Technology-Competition/tests/test_server_service.py
T
lhl 520c9e25b0 feat(ui): 前端整改 —— RAG 入口产品化、放开 select、高级字段折叠、字段级红框
按"不考虑时间、考虑正确合理"原则逐块实施。

P0-B 放开 select
  - chat_state.js: shouldHideUploadSelect 始终 false; resolveUploadType 尊重用户选择
  - chat.html: 选项目时 select 不再隐藏 + 不再强改 file_type=requirements
  - 客户端 file_type↔扩展名校验(existing_system=.zip, requirements=.xlsx 等)
  - 项目已预置同类型时 confirm() 显式覆盖确认(不藏起入口)

P0-C 高级字段折叠 + 字段级红框
  - 项目抽屉主面板仅留 项目名/显示名;4 个服务器路径字段收进 details.advanced
  - saveDrawerProject 错误时按 ProjectConfigError label 关键字(模板/做成说明书/
    既有系统代码库/既有设计文档目录/项目名)给对应输入加 .invalid 3s 清除
  - 删除按钮 pf-delete 改用 hidden 而非 style.display

P0-A RAG 入口产品化
  - 后端 RagStore.count(scope) 线程安全读加锁
  - GenesisService.rag_stats(sid) 含 except 兜底(count 抛错返回 0)
  - GET /api/sessions/{sid}/rag-stats 端点
  - 前端顶栏:RAG 开关 + RAG 已索引 N 片段 状态徽标 + 开始影响调查按钮
  - 上传 existing_system 后自动 refreshRagStats 刷新徽标
  - 开关持久化到 localStorage(genesis_rag_enabled)

P1-D loadSession 不再隐式覆盖 draftProject
  - activeProject vs draftProject 分层;不一致时由 renderProjectMismatchHint
    提示用户主动"切到该项目"或"保留当前项目"
  - 保留 写入 sessionStorage 标记,避免每次 load 都提示

P1-F 删除抽屉项目 fallback 收敛
  - 先记 deleted 再清 drawerSelected,修复"先 null 后比较"恒假 bug
  - buildWelcome 唯一来源(chat_state.js 单点,前端内联重复移除)

P2-H 杂项
  - send() in-flight 锁防双击
  - input maxlength=2000
  - 启动恢复前校验项目存在 + loading 占位
  - select 关联 label for
  - esc 转义加 引号
  - sid 全程 encodeURIComponent
  - avatar 按 name hash 选色 + 中文首字(Array.from)+ aria-label 含名字

测试:619 passed / 99.01% 99.0% 达标(+8 覆盖 store.count/service.rag_stats/
rag-stats 端点/rag_stats 异常);Node chat_state 9 passed
2026-08-30 21:16:07 +08:00

371 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""S3:会话化服务层测试(server/service.py)。
用真实样本文件 + FakeEngine 验证闭环:上传 → 解析 → 确认 → 影响 → 确认 → 生成 → QA。
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import asyncio
import pytest
from genesis.server.store import SessionStore, ProjectsStore
from genesis.server.service import GenesisService, ServiceStepError, FileTypeError
from genesis.rag.embeddings import FakeEmbedder
from genesis.rag.impact_rag import ImpactRAG
from genesis.rag.store import RagStore
_SAMPLE = Path(__file__).resolve().parents[1] / "sample"
class FakeEngine:
def chat_structured(self, *, session_id, prompt, variables, schema, retry_count=2):
title = variables.get("title", "x")
# 日文样本(章节标题含假名)→ 返回日文正文,满足语言一致性强制
return SimpleNamespace(
data={
"title": title,
"blocks": [
{"type": "paragraph",
"text": "本機能はFakeLLMにより生成された十分な説明内容であり、書込規則を満たす。"},
],
},
status="ok",
)
@pytest.fixture
def svc(tmp_path):
return GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=FakeEngine(),
)
def _upload_core(svc, session_id):
svc.upload_file(session_id, "requirements", "requirements_newdev.xlsx",
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
svc.upload_file(session_id, "template", "template_design_ja.docx",
(_SAMPLE / "template_design_ja.docx").read_bytes())
svc.upload_file(session_id, "write_instruction", "rules_design_ja.docx",
(_SAMPLE / "rules_design_ja.docx").read_bytes())
svc.upload_file(session_id, "rules", "rules_entry_ja.docx",
(_SAMPLE / "rules_entry_ja.docx").read_bytes())
def test_create_and_upload_files(svc):
s = svc.create_session("u1")
_upload_core(svc, s.session_id)
got = svc.get_session(s.session_id)
assert set(got.files.keys()) == {"requirements", "template", "write_instruction", "rules"}
assert got.files["requirements"]["name"] == "requirements_newdev.xlsx"
def test_create_session_name_and_project(tmp_path):
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=FakeEngine(),
)
s = svc.create_session("u1", name="我的会话", project="projA")
got = svc.get_session(s.session_id)
assert got.name == "我的会话"
assert got.project == "projA"
def test_upload_requirements_auto_names_session(tmp_path):
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=FakeEngine(),
)
s = svc.create_session("u1") # 默认「新会话」
svc.upload_file(s.session_id, "requirements", "要件定义_v2.xlsx", b"PK\x03\x04")
assert svc.get_session(s.session_id).name == "要件定义_v2"
def test_has_file_falls_back_to_project_config(tmp_path):
store = SessionStore(db_path=str(tmp_path / "s.db"))
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
projects.upsert(name="projA", display_name="P", template=str(_SAMPLE / "template_design_ja.docx"),
write_instruction="", rules=[], existing_system_code_dir="", design_docs_dir="")
svc = GenesisService(
store=store, data_root=str(tmp_path / "data"), engine=FakeEngine(), projects=projects,
)
s = svc.create_session("u1", project="projA")
svc.upload_file(s.session_id, "requirements", "requirements_newdev.xlsx",
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
rec = svc.get_session(s.session_id)
# requirements 仅用户上传;template 来自项目配置
assert svc.has_file(rec, "requirements") is True
assert svc.has_file(rec, "template") is True
assert svc.has_file(rec, "write_instruction") is False
assert svc._eff_path(rec, "template") == str(_SAMPLE / "template_design_ja.docx")
def test_rebuild_source_merges_project_config(tmp_path):
store = SessionStore(db_path=str(tmp_path / "s.db"))
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
projects.upsert(name="projA", display_name="P", template=str(_SAMPLE / "template_design_ja.docx"),
write_instruction=str(_SAMPLE / "rules_design_ja.docx"),
rules=[str(_SAMPLE / "rules_entry_ja.docx")],
existing_system_code_dir="", design_docs_dir="")
svc = GenesisService(
store=store, data_root=str(tmp_path / "data"), engine=FakeEngine(), projects=projects,
)
s = svc.create_session("u1", project="projA")
svc.upload_file(s.session_id, "requirements", "requirements_newdev.xlsx",
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
ss = svc._rebuild_source(svc.get_session(s.session_id))
assert ss.template is not None
assert len(ss.rule_docs) >= 2 # write_instruction + rules 合并
def test_rebuild_source_enumerates_design_docs_dir(tmp_path):
from docx import Document
store = SessionStore(db_path=str(tmp_path / "s.db"))
projects = ProjectsStore(db_path=str(tmp_path / "p.db"))
dd = tmp_path / "design"
dd.mkdir()
d = Document()
d.add_paragraph("设计文档:订单管理整体方案")
d.save(dd / "d1.docx")
projects.upsert(name="projA", display_name="P", template="", write_instruction="",
rules=[], existing_system_code_dir="", design_docs_dir=str(dd))
svc = GenesisService(
store=store, data_root=str(tmp_path / "data"), engine=FakeEngine(), projects=projects,
)
s = svc.create_session("u1", project="projA")
svc.upload_file(s.session_id, "requirements", "requirements_newdev.xlsx",
(_SAMPLE / "requirements_newdev.xlsx").read_bytes())
ss = svc._rebuild_source(svc.get_session(s.session_id))
assert len(ss.design_docs) == 1
def test_has_file_without_projects_returns_false(svc):
s = svc.create_session("u1")
rec = svc.get_session(s.session_id)
assert svc.has_file(rec, "template") is False
assert svc._eff_path(rec, "template") is None
def test_upload_requirements_does_not_override_named_session(tmp_path):
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"), engine=FakeEngine(),
)
s = svc.create_session("u1", name="已命名会话")
svc.upload_file(s.session_id, "requirements", "other_name.xlsx", b"PK\x03\x04")
assert svc.get_session(s.session_id).name == "已命名会话"
def test_upload_invalid_type_rejected(svc):
s = svc.create_session("u1")
with pytest.raises(FileTypeError):
svc.upload_file(s.session_id, "bogus", "x.txt", b"x")
def test_parse_requires_files(svc):
s = svc.create_session("u1")
with pytest.raises(ServiceStepError):
svc.run_parse(s.session_id)
def test_full_flow_fake(tmp_path):
"""完整闭环:解析 → 确认 → 影响 → 确认 → 生成 → QA → 结果路径就绪。"""
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=FakeEngine(),
)
s = svc.create_session("u1")
_upload_core(svc, s.session_id)
svc.run_parse(s.session_id)
assert svc.get_session(s.session_id).status == "awaiting_parse_confirm"
assert "機能" in svc.get_session(s.session_id).structured_summary or "tables" in svc.get_session(s.session_id).structured_summary
svc.confirm_parse(s.session_id)
assert svc.get_session(s.session_id).status == "writing" # 无既有系统 → 影响跳过 → writing
svc.run_generate(s.session_id, output_language="auto")
got = svc.get_session(s.session_id)
assert got.status == "writing"
assert got.result_path and Path(got.result_path).is_file()
# 预览可读
html = svc.result_preview(s.session_id)
assert "<html" in html or "概要" in html or len(html) > 0
def test_generate_requires_confirmed_parse(svc):
s = svc.create_session("u1")
_upload_core(svc, s.session_id)
with pytest.raises(ServiceStepError):
svc.run_generate(s.session_id) # 尚未解析确认
def test_run_qa_after_generate(tmp_path):
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=FakeEngine(),
)
s = svc.create_session("u1")
_upload_core(svc, s.session_id)
svc.run_parse(s.session_id)
svc.confirm_parse(s.session_id)
svc.run_generate(s.session_id)
svc.run_qa(s.session_id)
got = svc.get_session(s.session_id)
assert got.qa_summary # QA 报告 JSON 已持久化
assert "language" in got.qa_summary or "overall_score" in got.qa_summary
def _make_existing_zip(tmp_path) -> bytes:
"""构造含 1 个 Java 文件的 zip(既有系统样本)。"""
import io
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("demo/OrderController.java",
"package demo;\n"
"@RestController public class OrderController {\n"
" @GetMapping public String list() { return \"ok\"; }\n}\n")
return buf.getvalue()
def test_existing_system_zip_triggers_impact_flow(tmp_path):
"""既有系统 zip 上传 → 影响调查全流程(run_impact/confirm_impact)。"""
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=FakeEngine(),
)
s = svc.create_session("u1")
_upload_core(svc, s.session_id)
svc.upload_file(s.session_id, "existing_system", "existing.zip", _make_existing_zip(tmp_path))
svc.run_parse(s.session_id)
svc.confirm_parse(s.session_id)
assert svc.get_session(s.session_id).status == "impact_running"
asyncio.run(svc.run_impact(s.session_id))
got = svc.get_session(s.session_id)
assert got.status == "awaiting_impact_confirm"
assert got.impact_summary
assert got.impact_report_path and Path(got.impact_report_path).exists()
svc.confirm_impact(s.session_id)
assert svc.get_session(s.session_id).status == "writing"
# 从 awaiting_impact_confirm 也能生成(兼容)
svc.run_generate(s.session_id)
assert svc.get_session(s.session_id).result_path
def test_impact_state_guard(svc):
s = svc.create_session("u1")
_upload_core(svc, s.session_id)
svc.run_parse(s.session_id)
svc.confirm_parse(s.session_id)
# 无既有系统 → writingstart-impact 应报错
with pytest.raises(ServiceStepError):
asyncio.run(svc.run_impact(s.session_id))
def test_qa_requires_generated(svc):
s = svc.create_session("u1")
_upload_core(svc, s.session_id)
svc.run_parse(s.session_id)
svc.confirm_parse(s.session_id)
with pytest.raises(ServiceStepError):
svc.run_qa(s.session_id) # 尚未生成
def test_preview_missing_result_raises(svc):
s = svc.create_session("u1")
with pytest.raises(ServiceStepError):
svc.result_preview(s.session_id)
def test_preview_with_table(tmp_path):
"""预览含表格的 docx → 覆盖表格渲染分支(service 241-244)。"""
from docx import Document
from docx.table import Table
from genesis.server.store import SessionStore
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=FakeEngine(),
)
s = svc.create_session("u1")
doc_path = tmp_path / "data" / s.session_id / "with_table.docx"
doc_path.parent.mkdir(parents=True, exist_ok=True)
doc = Document()
doc.add_paragraph("概要段落")
t = doc.add_table(rows=1, cols=2)
t.rows[0].cells[0].text = "列A"
t.rows[0].cells[1].text = "列B"
doc.save(str(doc_path))
svc.store.update_session(s.session_id, result_path=str(doc_path))
html_out = svc.result_preview(s.session_id)
assert "<table" in html_out
assert "列A" in html_out
def test_confirm_parse_wrong_state_raises(svc):
s = svc.create_session("u1")
_upload_core(svc, s.session_id)
# uploading 态直接确认解析 → 报错(service 139
with pytest.raises(ServiceStepError):
svc.confirm_parse(s.session_id)
def test_confirm_impact_wrong_state_raises(tmp_path):
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=FakeEngine(),
)
s = svc.create_session("u1")
_upload_core(svc, s.session_id)
svc.run_parse(s.session_id)
svc.confirm_parse(s.session_id)
# writing 态直接确认影响 → 报错(service 167
with pytest.raises(ServiceStepError):
svc.confirm_impact(s.session_id)
def test_rag_stats_without_rag_returns_disabled_zero(tmp_path):
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=FakeEngine(),
rag=None,
)
s = svc.create_session("u1")
stats = svc.rag_stats(s.session_id)
assert stats == {"session_id": s.session_id, "rag_enabled": False, "chunks": 0}
def test_rag_stats_with_rag_reflects_indexed_chunks(tmp_path):
svc = GenesisService(
store=SessionStore(db_path=str(tmp_path / "s.db")),
data_root=str(tmp_path / "data"),
engine=FakeEngine(),
rag=ImpactRAG(RagStore(str(tmp_path / "rag.db")), FakeEmbedder()),
use_rag=False,
)
s = svc.create_session("u1")
# 空 scope → 0 片段
assert svc.rag_stats(s.session_id)["chunks"] == 0
# 索引 2 个片段
svc.rag.index(s.session_id, [("f.java", "a"), ("f2.java", "b")])
stats = svc.rag_stats(s.session_id)
assert stats["rag_enabled"] is True
assert stats["chunks"] == 2
assert stats["session_id"] == s.session_id