#!/usr/bin/env python3 """Shared client-journey contract and result lifecycle helpers. The mobile/browser driver still owns the real UI interaction. This module owns the parts that must be identical for every client catalog: contract validation, preflight blocking, and result propagation. It deliberately returns the existing tester result vocabulary instead of introducing a second report format. """ from __future__ import annotations from typing import Any, Iterable, Mapping TEST_TYPES = ("journey", "integration") CLIENT_SUITES = ("client-smoke", "client-regression", "safety") INTEGRATION_SUITES = ("api-contract", "authorization", "build-contract") PLATFORMS = ("android", "ios") FAILURE_POLICIES = ("stop", "continue") RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run") def client_suite(case: Mapping[str, Any]) -> str: """Return the canonical client suite, accepting ``suite`` as a migration alias.""" value = case.get("client_suite") alias = case.get("suite") if value and alias and str(value) != str(alias): return "" return str(value or alias or "") def integration_suite(case: Mapping[str, Any]) -> str: """Return the integration suite, accepting ``suite`` during migration.""" value = case.get("integration_suite") alias = case.get("suite") if value and alias and str(value) != str(alias): return "" return str(value or alias or "") def test_type(case: Mapping[str, Any]) -> str: """Return an explicit type or infer one for an old catalog entry.""" declared = str(case.get("test_type") or "").strip().lower() if declared: return declared if client_suite(case) or case.get("journey") is not None: return "journey" if integration_suite(case): return "integration" return "" def suite(case: Mapping[str, Any]) -> str: """Return the report/selection suite for either test type.""" kind = test_type(case) return client_suite(case) if kind == "journey" else integration_suite(case) def normalize_platforms(value: Any) -> list[str]: if isinstance(value, str): values = [value] elif isinstance(value, (list, tuple)): values = list(value) else: return [] return [str(item).strip().lower() for item in values if str(item).strip()] def _non_empty_list(value: Any) -> bool: return isinstance(value, list) and bool(value) and all(str(item).strip() for item in value) def _has_account(fixture: Mapping[str, Any]) -> bool: account = fixture.get("account") if isinstance(account, Mapping): return bool(str(account.get("role") or account.get("username") or "").strip()) return bool(str(account or "").strip()) def contract_errors(case: Mapping[str, Any], sessions: Mapping[str, Any] | None = None) -> list[str]: """Validate a client journey without requiring app-specific field names. A case becomes a client journey when it declares ``client_suite`` (or the temporary ``suite`` alias). Legacy non-client cases keep the existing schema. Once opted in, all lifecycle inputs are explicit so a missing fixture/cleanup can stop the run before any case result is fabricated. """ kind = test_type(case) declared_kind = str(case.get("test_type") or "").strip().lower() if declared_kind and declared_kind not in TEST_TYPES: return [f"{case.get('id') or '?'}: test_type 必须是 journey 或 integration"] if not kind: return [] if kind == "integration": return integration_contract_errors(case) suite_name = client_suite(case) journey = case.get("journey") errors: list[str] = [] cid = str(case.get("id") or "?") where = f"{cid}: client journey" if case.get("client_suite") and case.get("suite") and str(case["client_suite"]) != str(case["suite"]): errors.append(f"{where}: client_suite 与 suite 不一致") if suite_name not in CLIENT_SUITES: errors.append(f"{where}: client_suite 必须是 {', '.join(CLIENT_SUITES)}") if not isinstance(journey, Mapping): errors.append(f"{where}: journey 必须是 mapping") journey = {} for key in ("goal", "entry_state", "final_state"): if not str(journey.get(key) or "").strip(): errors.append(f"{where}: journey.{key} 必填") if not _non_empty_list(journey.get("actions")): errors.append(f"{where}: journey.actions 必须是非空列表") if not _non_empty_list(journey.get("assertions")): errors.append(f"{where}: journey.assertions 必须是非空列表") policy = str(journey.get("failure_policy") or "") if policy not in FAILURE_POLICIES: errors.append(f"{where}: journey.failure_policy 必须是 stop 或 continue") fixture = case.get("fixture") if not isinstance(fixture, Mapping): errors.append(f"{where}: fixture 缺失,必须在 preflight 前声明") elif not _has_account(fixture): errors.append(f"{where}: fixture.account 缺失,必须声明测试账号或角色") cleanup = case.get("cleanup") if not isinstance(cleanup, Mapping): errors.append(f"{where}: cleanup 缺失,必须在 preflight 前声明") else: steps = cleanup.get("steps") command = cleanup.get("run") explicitly_noop = cleanup.get("required") is False and str(cleanup.get("reason") or "").strip() if not explicitly_noop and not _non_empty_list(steps) and not str(command or "").strip(): errors.append(f"{where}: cleanup 必须有非空 steps/run;只读用例也要显式声明 reason") platforms = normalize_platforms(case.get("platform")) if not platforms: errors.append(f"{where}: platform 必须声明 android/ios") elif any(platform not in PLATFORMS for platform in platforms): errors.append(f"{where}: platform 只能是 {', '.join(PLATFORMS)}") elif len(platforms) != len(set(platforms)): errors.append(f"{where}: platform 不能重复") driver = case.get("driver") if driver not in ("mobile", "browser"): errors.append(f"{where}: 客户端旅程 driver 必须是 mobile 或 browser") if driver and driver in ("mobile", "browser"): session = case.get("session") if not str(session or "").strip(): errors.append(f"{where}: session 必须声明可复用客户端会话") elif sessions is not None and session not in sessions: errors.append(f"{where}: session={session} 未在 catalog.sessions 声明") if suite_name == "safety" and str(case.get("tag") or "") == "smoke": errors.append(f"{where}: safety 不得标记为 smoke") return errors def integration_contract_errors(case: Mapping[str, Any]) -> list[str]: """Validate the non-client half of the tester catalog. Integration cases deliberately do not require a browser/mobile session. A ``how`` block is the legacy call declaration; newer catalogs may use ``request``/``input``/``call`` or an ``integration`` mapping as well. ``expected`` remains the common protocol/data assertion list. """ cid = str(case.get("id") or "?") where = f"{cid}: integration" errors: list[str] = [] value = case.get("integration_suite") alias = case.get("suite") if value and alias and str(value) != str(alias): errors.append(f"{where}: integration_suite 与 suite 不一致") suite_name = integration_suite(case) if suite_name not in INTEGRATION_SUITES: errors.append( f"{where}: integration_suite 必须是 {', '.join(INTEGRATION_SUITES)}" ) fixture = case.get("fixture") if not isinstance(fixture, Mapping) or not fixture: errors.append(f"{where}: fixture 缺失,必须在 preflight 前声明") declared_call = any( key in case and case.get(key) not in (None, "", [], {}) for key in ("request", "input", "call", "integration") ) how = case.get("how") if not declared_call and not isinstance(how, Mapping): errors.append(f"{where}: 必须声明 request/input/call/integration 或 how") # Integration writes need an explicit rollback/cleanup contract. Read-only # probes may state why cleanup is unnecessary, matching test-preflight. risk = str(case.get("risk") or "") cleanup = case.get("cleanup") cleanup_steps = isinstance(cleanup, Mapping) and ( bool(cleanup.get("steps")) or bool(str(cleanup.get("run") or "").strip()) ) cleanup_noop = isinstance(cleanup, Mapping) and cleanup.get("required") is False and bool( str(cleanup.get("reason") or "").strip() ) if risk != "readonly" and not (cleanup_steps or cleanup_noop): errors.append(f"{where}: 写入型 integration 必须声明 cleanup/rollback steps/run") elif cleanup is not None and not (cleanup_steps or cleanup_noop): errors.append(f"{where}: cleanup 必须有 steps/run 或 required=false + reason") forbidden = [] if case.get("client_suite") or case.get("journey") is not None: forbidden.append("client_suite/journey") if case.get("session") or case.get("platform"): forbidden.append("session/platform") if forbidden: errors.append(f"{where}: integration 不应声明 " + ", ".join(forbidden)) return errors def preflight(cases: Iterable[Mapping[str, Any]], sessions: Mapping[str, Any] | None = None) -> dict[str, Any]: """Return a contract preflight result; no per-case result is created on block.""" cases = list(cases) errors: list[str] = [] for case in cases: errors.extend(contract_errors(case, sessions=sessions)) return { "required": bool(errors or any(client_suite(case) for case in cases)), "status": "blocked" if errors else "ready", "failure_class": "contract" if errors else None, "errors": errors, } def classify_outcome( *, ok: bool, environment_ready: bool = True, phase: str = "journey", ) -> str: """Classify one observed phase using the tester's five result states. A valid test account is part of the client journey: a failed login is a product ``fail``. Only an unavailable environment is ``environment-fail``. """ if not environment_ready: return "environment-fail" if not ok: return "fail" return "pass" def propagate_results( case_ids: Iterable[str], observed: Mapping[str, str], *, skipped: Mapping[str, str] | None = None, continue_on_fail: bool = False, policies: Mapping[str, str] | None = None, blocked_by: Mapping[str, Iterable[str]] | None = None, ) -> list[dict[str, str]]: """Build ordered result rows and mark cases after a blocking failure not-run. ``skipped`` is intentional selection (risk/lifecycle/assumption); it is never converted to ``not-run``. A product or environment failure stops subsequent cases unless the caller explicitly opts into continuation. """ skipped = skipped or {} policies = policies or {} blocked_by = blocked_by or {} rows: list[dict[str, str]] = [] blocking_case = "" for raw_id in case_ids: case_id = str(raw_id) if case_id in skipped: rows.append({"id": case_id, "result": "skipped", "notes": skipped[case_id]}) continue if blocking_case: rows.append({ "id": case_id, "result": "not-run", "notes": "前置用例失败,未轮到执行", "blocked_by": ",".join(blocked_by.get(case_id) or (blocking_case,)), }) continue result = observed.get(case_id, "not-run") if result not in RESULTS: raise ValueError(f"未知结果: {case_id}={result}") rows.append({"id": case_id, "result": result}) policy = policies.get(case_id) if result in ("fail", "environment-fail") and not (continue_on_fail or policy == "continue"): blocking_case = case_id return rows