| """Streamlit 主应用装配逻辑的单元测试(任务 20)。 |
| |
| 覆盖需求 15.1 / 15.3 / 15.4 / 15.5 / 17.1 / 17.2 / 17.6 与 5.1 / 5.4: |
| |
| - :func:`build_services`:构建 Services 容器,可选依赖缺失时优雅降级而非崩溃(需求 6.5)。 |
| - :func:`discover_skills` / :func:`merge_skill_i18n`:发现 Skill 并把各 Skill 的 i18n |
| 资源并入 I18nService(需求 1.1 / 17.6)。 |
| - :func:`visible_skills_for_role` / :func:`can_view_audit`:按角色过滤可见 Skill 与 |
| 审计区可见性(需求 15.3 / 15.5)。 |
| - :func:`set_session_language`:写入 Services.lang 并联动 I18nService(需求 17.1/17.2)。 |
| - :func:`authenticate`:加盐哈希校验、角色归一;明文密码不入逻辑(需求 5.1/5.4)。 |
| - :func:`dispatch`:固定流水线分发并渲染 ReportSections(需求 2.2 / 15.1)。 |
| |
| 测试不依赖 Streamlit(仅测装配纯函数);导入路径由 tests/conftest.py 统一设置。 |
| ``app`` 以顶层模块导入(platform/ 目录在 sys.path 上)。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import inspect |
| import sys |
|
|
| import pytest |
|
|
| |
| import app |
| from kernel.registry import SkillRegistry |
| from kernel.services import Services |
| from kernel.skill_base import ( |
| ComputeResult, |
| ExtractedData, |
| InputKind, |
| PharmaSkill, |
| RawInput, |
| ReportSections, |
| SkillMeta, |
| ) |
| from services.auth_service import AuthService |
| from services.i18n_service import I18nService |
|
|
|
|
| |
| |
| |
|
|
| class _StubSkill(PharmaSkill): |
| """可配置 id / 顺序 / i18n 的桩 Skill,三层返回固定占位结果。""" |
|
|
| def __init__(self, skill_id: str, *, order: int = 100, i18n=None, kinds=None): |
| self.meta = SkillMeta( |
| id=skill_id, |
| display_name=f"{skill_id}-name", |
| description=f"{skill_id}-desc", |
| version="1.0.0", |
| input_kinds=set(kinds or {InputKind.TEXT}), |
| icon="🧪", |
| order=order, |
| ) |
| if i18n is not None: |
| self.i18n = i18n |
|
|
| def render_inputs(self, st_ctx): |
| return RawInput(goal="hello") |
|
|
| def extract(self, raw, svc): |
| return ExtractedData(payload={"goal": raw.goal}, method="passthrough") |
|
|
| def compute(self, data): |
| return ComputeResult(summary={"goal": (data.payload or {}).get("goal", "")}) |
|
|
| def explain(self, result, svc): |
| return ReportSections(sections={"answer": "ok"}, html="<p>ok</p>") |
|
|
|
|
| class _StubAudit: |
| """记录调用的桩审计服务。""" |
|
|
| def __init__(self): |
| self.analyses = [] |
| self.logins = [] |
|
|
| def record_analysis(self, user, skill_id, *, step="execute", **kw): |
| self.analyses.append((user, skill_id, step)) |
|
|
| def record_login(self, user, *, success=True, **kw): |
| self.logins.append((user, success)) |
|
|
|
|
| def _registry(*skills: PharmaSkill) -> SkillRegistry: |
| reg = SkillRegistry() |
| for s in skills: |
| reg.register(s) |
| return reg |
|
|
|
|
| |
| |
| |
|
|
| def test_build_services_populates_all_fields(): |
| """无密钥环境下也应成功构建容器,各字段非 None(LLM 仍可构建,仅无可用提供商)。""" |
| svc = app.build_services(env={}) |
| assert isinstance(svc, Services) |
| for field in ("i18n", "usage", "llm", "audit", "report", "chart", "file", "prompt_guard", "auth"): |
| assert getattr(svc, field) is not None, f"{field} 应被成功构建" |
| assert svc.lang == "zh" |
| |
| assert isinstance(getattr(svc, "_build_warnings"), list) |
|
|
|
|
| def test_build_services_degrades_when_a_service_factory_fails(monkeypatch): |
| """单个服务构建失败时降级为 None 并记入告警,不影响其它服务(需求 6.5)。""" |
| def _boom(): |
| raise RuntimeError("simulated missing dependency") |
|
|
| monkeypatch.setattr(app, "_build_report", _boom) |
| svc = app.build_services(env={}) |
| assert svc.report is None |
| assert any(name == "report" for name, _ in svc._build_warnings) |
| |
| assert svc.i18n is not None and svc.auth is not None |
|
|
|
|
| def test_build_services_passes_skill_ids_to_auth(): |
| """传入 skill_ids 后,AuthService.visible_skills 应据此返回(需求 15.5)。""" |
| svc = app.build_services(env={}, skill_ids=["stability", "compatibility"]) |
| visible = svc.auth.visible_skills("researcher") |
| assert visible == {"stability", "compatibility"} |
|
|
|
|
| |
| |
| |
|
|
| def test_discover_skills_finds_firstparty_skills(): |
| """扫描真实 skills 包应发现首发 Skill(需求 1.1 / 15.3:统一导航可达)。""" |
| reg = app.discover_skills("skills") |
| ids = {s.meta.id for s in reg.all()} |
| assert {"general_qa", "example_stub"} <= ids |
|
|
|
|
| def test_merge_skill_i18n_namespaces_unprefixed_catalog(): |
| """未加前缀的 Skill 目录以 namespace 合并;已加前缀的直接合并(需求 17.6)。""" |
| svc = Services(i18n=I18nService()) |
| unprefixed = _StubSkill("stab", i18n={"name": {"zh": "稳定", "en": "Stab"}}) |
| prefixed = _StubSkill( |
| "qa", i18n={"qa.title": {"zh": "问答", "en": "QA"}} |
| ) |
| reg = _registry(unprefixed, prefixed) |
|
|
| merged = app.merge_skill_i18n(svc, reg) |
| assert merged == 2 |
| |
| assert svc.i18n.has("stab.name") |
| assert svc.i18n.t("stab.name", "en") == "Stab" |
| |
| assert svc.i18n.has("qa.title") |
| assert not svc.i18n.has("qa.qa.title") |
|
|
|
|
| def test_merge_skill_i18n_safe_without_i18n(): |
| """i18n 不可用时安全返回 0,不抛异常。""" |
| svc = Services(i18n=None) |
| reg = _registry(_StubSkill("x", i18n={"name": {"zh": "X", "en": "X"}})) |
| assert app.merge_skill_i18n(svc, reg) == 0 |
|
|
|
|
| def test_bootstrap_wires_everything(): |
| """bootstrap 一次性发现 + 构建 + 合并,返回可用的 svc/registry。""" |
| svc, reg = app.bootstrap(env={}, skills_source="skills") |
| assert isinstance(svc, Services) and isinstance(reg, SkillRegistry) |
| ids = {s.meta.id for s in reg.all()} |
| assert "general_qa" in ids |
| |
| assert svc.i18n.has("general_qa.title") |
| |
| assert reg.svc is svc |
|
|
|
|
| |
| |
| |
|
|
| def test_visible_skills_for_role_filters_by_auth(): |
| """researcher 可见全部分析 Skill;auditor 只读不展示可执行 Skill(需求 15.5)。""" |
| reg = _registry( |
| _StubSkill("stability", order=10), |
| _StubSkill("compatibility", order=20), |
| ) |
| auth = AuthService(all_skill_ids=["stability", "compatibility"]) |
|
|
| researcher_view = app.visible_skills_for_role(reg, auth, "researcher") |
| assert {s.meta.id for s in researcher_view} == {"stability", "compatibility"} |
| |
| assert [s.meta.id for s in researcher_view] == ["stability", "compatibility"] |
|
|
| auditor_view = app.visible_skills_for_role(reg, auth, "auditor") |
| assert auditor_view == [] |
|
|
|
|
| def test_visible_skills_fallback_when_auth_missing(): |
| """auth 不可用时回退展示全部已注册 Skill(避免空界面)。""" |
| reg = _registry(_StubSkill("a"), _StubSkill("b")) |
| view = app.visible_skills_for_role(reg, None, "researcher") |
| assert {s.meta.id for s in view} == {"a", "b"} |
|
|
|
|
| def test_can_view_audit_role_isolation(): |
| """审计区仅 admin / auditor 可见,researcher 不可见(需求 15.5)。""" |
| auth = AuthService() |
| assert app.can_view_audit(auth, "admin") is True |
| assert app.can_view_audit(auth, "auditor") is True |
| assert app.can_view_audit(auth, "researcher") is False |
| |
| assert app.can_view_audit(None, "admin") is False |
|
|
|
|
| def test_skill_nav_label_uses_i18n_then_fallbacks(): |
| """导航标签优先取 i18n(nav.<id> / <id>.name),缺失回退 display_name(需求 17.4)。""" |
| i18n = I18nService() |
| i18n.merge({"nav.stability": {"zh": "稳定性预测", "en": "Stability"}}) |
| skill = _StubSkill("stability") |
| label = app.skill_nav_label(skill, i18n) |
| assert "稳定性预测" in label |
|
|
| |
| skill2 = _StubSkill("unknown_skill") |
| label2 = app.skill_nav_label(skill2, i18n) |
| assert "unknown_skill-name" in label2 |
|
|
|
|
| |
| |
| |
|
|
| def test_set_session_language_writes_services_lang(): |
| """设置语言应同时写入 Services.lang 与 I18nService.lang(需求 17.2)。""" |
| svc = Services(i18n=I18nService()) |
| assert app.set_session_language(svc, "en") == "en" |
| assert svc.lang == "en" |
| assert svc.i18n.lang == "en" |
| |
| assert app.set_session_language(svc, "zh") == "zh" |
| assert svc.lang == "zh" |
|
|
|
|
| def test_set_session_language_unknown_falls_back_to_default(): |
| """未知语言回退默认语言,不报错(需求 17.5)。""" |
| svc = Services(i18n=I18nService()) |
| assert app.set_session_language(svc, "fr") == "zh" |
| assert svc.lang == "zh" |
|
|
|
|
| def test_set_session_language_without_i18n(): |
| """i18n 不可用时仍能写入 Services.lang。""" |
| svc = Services(i18n=None) |
| assert app.set_session_language(svc, "en") == "en" |
| assert svc.lang == "en" |
|
|
|
|
| |
| |
| |
|
|
| def test_authenticate_success_with_salted_hash(): |
| """加盐哈希校验通过并归一角色(需求 5.4 / 15.5)。""" |
| auth = AuthService() |
| stored = auth.hash_password("s3cret") |
| users = {"admin": {"password_hash": stored, "role": "admin"}} |
|
|
| result = app.authenticate( |
| "admin", "s3cret", user_lookup=users.get, auth=auth |
| ) |
| assert result == {"user": "admin", "role": "admin"} |
|
|
|
|
| def test_authenticate_wrong_password_returns_none(): |
| auth = AuthService() |
| users = {"u": {"password_hash": auth.hash_password("right"), "role": "researcher"}} |
| assert app.authenticate("u", "wrong", user_lookup=users.get, auth=auth) is None |
|
|
|
|
| def test_authenticate_unknown_user_returns_none(): |
| auth = AuthService() |
| assert app.authenticate("ghost", "x", user_lookup=lambda e: None, auth=auth) is None |
|
|
|
|
| def test_authenticate_empty_credentials_returns_none(): |
| assert app.authenticate("", "", user_lookup=lambda e: {}, auth=AuthService()) is None |
|
|
|
|
| def test_authenticate_normalizes_legacy_role_to_researcher(): |
| """历史 'user' 角色归一为 researcher(最小权限默认)。""" |
| auth = AuthService() |
| users = {"u": {"password_hash": auth.hash_password("pw"), "role": "user"}} |
| result = app.authenticate("u", "pw", user_lookup=users.get, auth=auth) |
| assert result["role"] == "researcher" |
|
|
|
|
| |
| |
| |
|
|
| def test_dispatch_runs_pipeline_and_returns_sections(): |
| """dispatch 走固定流水线并返回 ReportSections,同时记录分析审计(需求 7.4)。""" |
| skill = _StubSkill("general_qa") |
| audit = _StubAudit() |
| svc = Services(audit=audit) |
| sections = app.dispatch(skill, RawInput(goal="hi"), svc, user="tester") |
|
|
| assert isinstance(sections, ReportSections) |
| assert sections.sections["answer"] == "ok" |
| |
| assert audit.analyses == [("tester", "general_qa", "execute")] |
|
|
|
|
| def test_dispatch_audit_failure_does_not_break(): |
| """审计记录失败不应中断分发(best-effort)。""" |
| class _BadAudit: |
| def record_analysis(self, *a, **k): |
| raise RuntimeError("audit down") |
|
|
| skill = _StubSkill("x") |
| svc = Services(audit=_BadAudit()) |
| sections = app.dispatch(skill, RawInput(goal="hi"), svc) |
| assert isinstance(sections, ReportSections) |
|
|
|
|
| |
| |
| |
|
|
| def test_dispatch_does_not_pass_svc_to_compute(): |
| """通过 Pipeline 调用时 compute 仅接收 data(无 svc)——签名层面保证。""" |
| sig = inspect.signature(_StubSkill.compute) |
| assert list(sig.parameters) == ["self", "data"] |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(pytest.main([__file__, "-v"])) |
|
|