docs+test: 发布包对拍计划、pytest 双端 API 对拍与 run_elevator_parity 脚本

- docs/elevator-api-parity: 计划/报告模板/示例
- tools/elevator_api_parity: 端点目录、fixtures、对拍 client/compare、报告生成
- scripts/run_elevator_parity: JDK8 构建 + 单测/对拍(无服务时跳过对拍用例)

Made-with: Cursor

Former-commit-id: 3d54a40e1a7ae0b1724261d4f18910a6f415f853
This commit is contained in:
反编译工作区
2026-04-25 09:50:32 +08:00
parent 2cd9da61da
commit 038f846dad
24 changed files with 712 additions and 0 deletions
@@ -12,6 +12,7 @@
## 与仓库级文档的关系
- 全仓约定、走查、接口不变等说明见仓库根下 **`../../../docs/`**(相对本文件)。
- 发布包与**历史 JAR 接口对拍**(计划/报告)见聚合工程目录 **`../../docs/elevator-api-parity/PLAN.md`**、脚本 `../../scripts/run_elevator_parity.sh`
- 本目录专注 **本 service 模块内** 类职责与业务流程梳理,不替代对外 API 合同文档。
## 分册导航
@@ -0,0 +1,44 @@
# 新旧版本发布包:接口同构验证计划
## 1. 目标
- **新构建物**`cw-elevator-application-starter``mvn package` 产出的 `cw-elevator-application-2.0.0.jar`
- **历史基线**(约定路径,若不存在需单独拷贝):
`.../cw-elevator-application-V1.0.0.20211103/cw-elevator-application-V1.0.0.20211103.jar`
- **成功标准**:在**相同运行配置与依赖环境**下,对同一批 HTTP 请求,两实例返回的 **HTTP 状态码、JSON 结构、业务 `code``CloudwalkResult`** 一致;可约定忽略字段(时间戳、随机 `requestId` 等)在报告中注明。
## 2. 约束与先决条件
1. **双进程**:旧版默认端口 `18080`,新版 `18081`;均通过**同一套**外置或环境变量 `spring.config` 指向上游/库(与现网联调一致时才有可比性)。
2. **无历史 JAR**:本仓库中若不存在上述 `*.jar`,仅完成构建与**框架/离线用例**;等效性验证需在拿到 JAR 后执行 `scripts/run_elevator_parity.sh`
3. **健康检查**:优先 `GET /actuator/health`;若基线为 Spring Boot 1.5 且 `management` 基路径不同,由脚本 `tools/elevator_api_parity/parity/health.py` 自动探测回退。
## 3. 子任务分解
| 序号 | 任务 | 产出 |
|------|------|------|
| T1 | Maven 发布包构建 | `target/cw-elevator-application-2.0.0.jar` |
| T2 | 端点目录与用例体 | `tools/elevator_api_parity/api_catalog.json`、fixtures |
| T3 | 双端 HTTP 对拍与比较 | `parity/*` + `pytest` |
| T4 | 一键脚本与报告 | `scripts/run_elevator_parity.sh``report/parity-*.md` |
| T5 | 在联调环境实跑、签字发布 | 人工/运维 |
## 4. 接口范围(与源码 Controller 一致)
- `/elevator/person/*`(含 `/add/visitor` 等)
- `/elevator/passRule/*`
- `/elevator/device/*``/elevator/restructure/*`
- `/intelligent/acs/elevator/record/*``POST` 为主)
- 设备通道 `/device/v2/*``39201` 等,按业务场景启用)
具体路径与请求体以 `api_catalog.json` 为准,可按现网场景追加 `fixtures/*.json`
## 5. 非目标
- 不替代集成环境全链路压测、安全扫描。
- 不强制在一台空库机子上与线上一致(需**相同数据与上游**才可比)。
## 6. 与本文档配套脚本
-`maven-cw-elevator-application/tools/elevator_api_parity/README.md`
- 报告模板:`docs/elevator-api-parity/REPORT-TEMPLATE.md`(实跑时由 `generate_report.py` 自动填充为 `tools/elevator_api_parity/report/parity-*.md`)。
@@ -0,0 +1,28 @@
# 电梯应用 API 新旧版本对拍报告
- **报告生成时间**:(由脚本自动写入)
- **新版权益端 Base URL**
- **历史基线端 Base URL**
## 结论摘要
| 项 | 值 |
|----|-----|
| 总用例 | |
| 通过 | |
| 失败/跳过 | |
| 可上线建议 | **通过/不通过/条件通过**(人工确认) |
## 逐条用例
| 名称 | 方法+路径 | HTTP(旧) | HTTP(新) | JSON 等值 | 备注 |
|------|-----------|----------|----------|------------|------|
| (表格由 `generate_report.py` 从 pytest 结果填充) | | | | | |
## 已忽略/归一化项
- `timestamp`、各层 `message` 文案差异、UUID 等(如策略允许可在 `parity_config.yaml` 配置)。
## 未启动实例时的说明
- 若出现「基线/新版无法连接健康检查」,对拍不执行,需在本机或联调机启动两 JAR 后重跑。
@@ -0,0 +1,19 @@
# 电梯应用 API 新旧对拍报告(示例)
- **时间**: 2026-04-25T12:00:00
- **基线(旧) URL**: `http://127.0.0.1:18080`
- **新构建 URL**: `http://127.0.0.1:18081`
## 用例
| 用例 | 方法+路径 | HTTP(旧) | HTTP(新) | 等值 | 说明 |
| ---- | -------- | -------- | -------- | ---- | ---- |
| person_add_visitor_min | `POST /elevator/person/add/visitor` | 200 | 200 | Y | 双端 `code` 一致(联调同库同配置时) |
| person_detail | `POST /elevator/person/detail` | 200 | 200 | Y | 同上 |
| passrule_page | `POST /elevator/passRule/page` | 200 | 200 | Y | 同上 |
| record_page | `POST /intelligent/acs/elevator/record/page` | 200 | 200 | Y | 同上 |
## 汇总
- 通过: 4,不一致: 0。
- **上线前请人工与联调/业务确认**(本表为结构示例,实跑见 `tools/elevator_api_parity/report/parity-*.md`)。
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# 本脚本位于 maven-cw-elevator-application/scripts/:先打新包,再跑对拍用例与报告
set -e
REPO="$(cd "$(dirname "$0")/.." && pwd)"
TOOL="${REPO}/tools/elevator_api_parity"
JAR_NEW="${REPO}/cw-elevator-application-starter/target/cw-elevator-application-2.0.0.jar"
JAR_LEGACY_DEFAULT="$(cd "$REPO/.." && pwd)/cw-elevator-application-V1.0.0.20211103/cw-elevator-application-V1.0.0.20211103.jar"
JAR_LEGACY="${ELEVATOR_JAR_LEGACY:-$JAR_LEGACY_DEFAULT}"
if [[ -z "${JAVA_8:-}" ]]; then
for d in /usr/lib/jvm/java-8-openjdk-amd64 /usr/lib/jvm/java-1.8.0-openjdk; do
if [[ -x "$d/bin/java" ]]; then
export JAVA_8="$d"
break
fi
done
fi
: "${JAVA_8:=/usr/lib/jvm/java-8-openjdk-amd64}"
export JAVA_HOME="$JAVA_8"
export PATH="$JAVA_HOME/bin:$PATH"
cd "$REPO"
echo "==> mvn package (skip tests)"
mvn -q -DskipTests clean package
echo "==> 新 JAR: $JAR_NEW [$(test -f "$JAR_NEW" && echo OK || echo 缺失)]"
echo "==> 基线 JAR(路径): $JAR_LEGACY [$(test -f "$JAR_LEGACY" && echo 存在 || echo 不存在,可设ELEVATOR_JAR_LEGACY)]"
echo " (对拍为 HTTP; 需另开终端分别 java -jar --server.port=18080/18081 与相同配置)"
(cd "$TOOL" && pip install -q -r requirements.txt 2>/dev/null || pip3 install -q -r requirements.txt 2>/dev/null || true)
echo "==> 单元(无联调): test_unit_compare"
export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1
(cd "$TOOL" && python3 -m pytest tests/test_unit_compare.py -q --tb=short)
echo "==> 对拍(无两实例时跳过 test_parity_endpoints): 全部"
(cd "$TOOL" && python3 -m pytest tests/ -q --tb=line)
echo "报告: $TOOL/report/ (对拍有执行且成功时由 pytest 会话写出 parity-*.md)"
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.pytest_cache/
report/parity-*.md
@@ -0,0 +1,44 @@
# elevator_api_parity — 新旧 JAR 接口对拍
## 环境
- Python 3.8+
- 安装:`pip install -r requirements.txt`(在**本目录**下执行)
## 环境说明
本机若安装过 `allure_pytest` 等全局 pytest 插件且与 Python 版本冲突,请执行:
`export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1``run_elevator_parity.sh` 已自动设置)后再跑 `pytest`
## 快速使用
1. 构建新 JAR(在 reactor 根目录、JDK8
`mvn -DskipTests clean package`
得到:`../cw-elevator-application-starter/target/cw-elevator-application-2.0.0.jar`
2. 将历史 JAR 放到 `cw-elevator-application-V1.0.0.20211103/cw-elevator-application-V1.0.0.20211103.jar` 或设 `ELEVATOR_JAR_LEGACY`
3. 准备**与现网相同**的 `application.yml`(或目录),如 `ELEVATOR_SPRING_CONFIG=file:/path/to/application-elevator.yml`
4. 从仓库**反编译**根或本目录执行:
`../../scripts/run_elevator_parity.sh`(见该脚本内环境变量说明)
或手动启动两实例后:
```bash
export ELEVATOR_BASE_OLD=http://127.0.0.1:18080
export ELEVATOR_BASE_NEW=http://127.0.0.1:18081
export ELEVATOR_HEADER_BUSINESSID=... # 与现网/联调一致
# ...
cd tools/elevator_api_parity
python -m pytest tests/ -v --tb=short
```
## 配置
- `api_catalog.json`:对拍端点、方法、fixture 文件名、说明。
- `fixtures/*.json`:各接口请求体。
- 归一化忽略键可扩展 `parity/compare.py``DEFAULT_STRIP_PATHS`(如 `data.rows` 内动态字段,谨慎)。
## 报告
- `report/parity-YYYYMMDD-HHMMSS.md`:实跑时由 `conftest` 的 session hook 与 `report/generate_report.py` 组合生成。
- JUnit(可选):`--junitxml=report/junit.xml`
@@ -0,0 +1,37 @@
{
"version": 1,
"endpoints": [
{
"id": "person_add_visitor_min",
"name": "访客派梯-最小体(多依赖业务失败,仅比对 HTTP+业务code",
"method": "POST",
"path": "/elevator/person/add/visitor",
"fixture": "person_add_visitor_min.json",
"compare_mode": "code_only"
},
{
"id": "person_detail",
"name": "人员详情-最小分页体",
"method": "POST",
"path": "/elevator/person/detail",
"fixture": "person_detail_min.json",
"compare_mode": "code_only"
},
{
"id": "passrule_page",
"name": "通行规则-分页",
"method": "POST",
"path": "/elevator/passRule/page",
"fixture": "passrule_page.json",
"compare_mode": "code_only"
},
{
"id": "record_page",
"name": "通行记录-分页",
"method": "POST",
"path": "/intelligent/acs/elevator/record/page",
"fixture": "record_page.json",
"compare_mode": "code_only"
}
]
}
@@ -0,0 +1,81 @@
from __future__ import annotations
import os
from pathlib import Path
import pytest
import requests
from parity.client import can_reach_both, default_headers
_DIR = Path(__file__).resolve().parent
def pytest_configure(config):
config._parity_rows = [] # type: ignore[attr-defined]
def pytest_addoption(parser):
parser.addoption(
"--base-old",
default=os.environ.get("ELEVATOR_BASE_OLD", "http://127.0.0.1:18080"),
)
parser.addoption(
"--base-new",
default=os.environ.get("ELEVATOR_BASE_NEW", "http://127.0.0.1:18081"),
)
@pytest.fixture(scope="session")
def base_old(request):
return str(request.config.getoption("--base-old")).rstrip("/")
@pytest.fixture(scope="session")
def base_new(request):
return str(request.config.getoption("--base-new")).rstrip("/")
@pytest.fixture(scope="session")
def session_http():
s = requests.Session()
s.headers.update(default_headers())
yield s
s.close()
@pytest.fixture(scope="session")
def two_instances_ready(base_old, base_new, session_http, request):
ok, msg = can_reach_both(base_old, base_new, session_http)
require = os.environ.get("ELEVATOR_PARITY_REQUIRE_LIVE", "")
if not ok and not require:
pytest.skip(f"双端健康检查不通过(跳过用例): {msg}")
if not ok and require:
pytest.fail(f"ELEVATOR_PARITY_REQUIRE_LIVE=1 且双端不可达: {msg}")
return True
def pytest_sessionfinish(session, exitstatus):
rows = getattr(session.config, "_parity_rows", None)
if not rows:
return
try:
from report import generate_report
p = _DIR / "report" / generate_report.timestamped_name("parity")
p.parent.mkdir(parents=True, exist_ok=True)
generate_report.write_file(
p,
str(session.config.getoption("--base-old", default="")),
str(session.config.getoption("--base-new", default="")),
rows,
)
print(f"\n[parity] 报告: {p}")
except Exception as e:
print(f"\n[parity] 报告未生成: {e}")
def load_catalog() -> dict:
from parity import catalog_loader
return catalog_loader.load()
@@ -0,0 +1,4 @@
{
"pageNo": 1,
"pageSize": 1
}
@@ -0,0 +1,7 @@
{
"visitorId": "",
"personId": "",
"begVisitorTime": 1,
"endVisitorTime": 1,
"floorIds": []
}
@@ -0,0 +1,8 @@
{
"currentPage": 1,
"rowsOfPage": 1,
"parentId": "",
"zoneId": "",
"ruleId": "",
"personName": ""
}
@@ -0,0 +1,6 @@
{
"currentPage": 1,
"rowsOfPage": 1,
"startTime": 1,
"endTime": 2
}
@@ -0,0 +1 @@
"""Parity: HTTP client, health probe, JSON compare for elevator JARs."""
@@ -0,0 +1,8 @@
import json
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
def load() -> dict:
return json.loads((_ROOT / "api_catalog.json").read_text(encoding="utf-8"))
@@ -0,0 +1,135 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any, Optional
import requests
from . import compare
from .health import probe_healthy
@dataclass
class ParityResult:
name: str
method: str
path: str
old_status: int
new_status: int
old_text: str
new_text: str
match: bool
message: str
def default_headers() -> dict[str, str]:
h: dict[str, str] = {
"Content-Type": "application/json",
}
m = {
"businessid": "ELEVATOR_HEADER_BUSINESSID",
"loginid": "ELEVATOR_HEADER_LOGINID",
"platformuserid": "ELEVATOR_HEADER_PLATFORMUSERID",
"authorization": "ELEVATOR_HEADER_AUTHORIZATION",
"applicationid": "ELEVATOR_HEADER_APPLICATIONID",
}
for hdr, evar in m.items():
v = os.environ.get(evar, "").strip()
if v:
h[hdr] = v
return h
def call_both(
name: str,
method: str,
path: str,
body: Any,
base_old: str,
base_new: str,
extra_headers: Optional[dict] = None,
compare_mode: str = "deep",
session: requests.Session | None = None,
) -> ParityResult:
s = session or requests.Session()
h = {**default_headers(), **(extra_headers or {})}
url_o = base_old.rstrip("/") + path
url_n = base_new.rstrip("/") + path
if body is None:
data = None
else:
data = body if isinstance(body, str) else json.dumps(body, ensure_ascii=False)
m = method.upper()
if m == "GET":
r_old = s.get(url_o, headers=h, timeout=60)
r_new = s.get(url_n, headers=h, timeout=60)
else:
r_old = s.post(url_o, headers=h, data=data, timeout=120)
r_new = s.post(url_n, headers=h, data=data, timeout=120)
ot, nt = r_old.text, r_new.text
st_o, st_n = r_old.status_code, r_new.status_code
msg = ""
if compare_mode == "status_only":
m_ok = st_o == st_n
if not m_ok:
msg = f"http {st_o} vs {st_n}"
elif compare_mode == "code_only":
msg = ""
oj, nj = _safe_json(ot), _safe_json(nt)
c1, c2 = compare.business_code(oj), compare.business_code(nj)
m_ok = st_o == st_n
if m_ok and c1 is not None and c2 is not None:
m_ok = c1 == c2
elif m_ok and c1 is None and c2 is None and st_o == st_n and 400 <= st_o < 600:
m_ok = True
msg = "http status only (no parseable JSON body)"
elif m_ok and (c1 is None) != (c2 is None):
m_ok = False
if not m_ok and not msg:
msg = compare.describe_diff(ot, nt, "code_only")
elif m_ok and not msg:
msg = ""
else:
m_ok = st_o == st_n
oj, nj = _safe_json(ot), _safe_json(nt)
if oj is not None and nj is not None and m_ok:
m_ok = compare.normalize(oj) == compare.normalize(nj)
elif m_ok and (ot or "") != (nt or ""):
m_ok = (ot or "").strip() == (nt or "").strip()
if not m_ok:
msg = compare.describe_diff(ot, nt, "deep") or f"http {st_o}/{st_n} or body mismatch"
return ParityResult(
name=name,
method=m,
path=path,
old_status=st_o,
new_status=st_n,
old_text=ot,
new_text=nt,
match=m_ok,
message=msg,
)
def _safe_json(text: str) -> Any:
if not (text or "").strip():
return None
try:
return compare.parse_json_loose(text)
except Exception:
return None
def can_reach_both(
base_old: str, base_new: str, s: requests.Session | None = None
) -> tuple[bool, str]:
s = s or requests.Session()
p1, ok1, _ = probe_healthy(base_old, s)
p2, ok2, _ = probe_healthy(base_new, s)
if ok1 and ok2:
return True, f"ok old health path~{p1} new probed"
return False, f"health old ok={ok1} new ok={ok2} (paths like {p1})"
@@ -0,0 +1,94 @@
"""
Compare two JSON response bodies. CloudwalkResult: compare top-level `code` when
both parse as objects; also deep-compare with optional key stripping.
"""
from __future__ import annotations
import copy
import json
from typing import Any, List, Set
# JSONPath-like: tuple of keys in order for nesting
DEFAULT_STRIP_PATHS: Set[tuple] = {
# ("data", "ts"),
}
def _strip(obj: Any, rules: Set[tuple], prefix: tuple) -> Any:
if not isinstance(obj, (dict, list)):
return obj
if isinstance(obj, list):
return [_strip(x, rules, prefix + (i,)) for i, x in enumerate(obj)]
out = copy.deepcopy(obj)
for k, v in list(obj.items()) if isinstance(obj, dict) else []:
p = prefix + (k,)
if p in rules:
if k in out:
del out[k]
continue
if isinstance(v, (dict, list)):
out[k] = _strip(v, rules, p)
return out
def parse_json_loose(text: str) -> Any:
if not (text or "").strip():
return None
return json.loads(text)
def normalize(
data: Any,
strip_paths: Set[tuple] | None = None,
) -> str:
rules = strip_paths if strip_paths is not None else DEFAULT_STRIP_PATHS
s = _strip(data, rules, tuple())
return json.dumps(s, ensure_ascii=False, sort_keys=True, default=str)
def business_code(data: Any) -> str | None:
if isinstance(data, dict) and "code" in data:
c = data.get("code")
return str(c) if c is not None else None
return None
def same_shape(old_text: str, new_text: str) -> bool:
try:
a = parse_json_loose(old_text)
b = parse_json_loose(new_text)
except json.JSONDecodeError:
return False
if type(a) != type(b):
return False
if isinstance(a, dict) and isinstance(b, dict) and a.keys() == b.keys():
return all(type(a[k]) is type(b[k]) for k in a)
if isinstance(a, list) and isinstance(b, list):
if len(a) == len(b) and len(a) == 0:
return True
return False
def describe_diff(
old_text: str, new_text: str, compare_norm: str = "deep"
) -> str:
try:
o = parse_json_loose(old_text)
n = parse_json_loose(new_text)
except json.JSONDecodeError as e:
return f"json parse: {e}"
if compare_norm == "code_only":
c1, c2 = business_code(o), business_code(n)
if c1 == c2:
return ""
return f"business code: old={c1!r} new={c2!r}"
no = normalize(o)
nn = normalize(n)
if no == nn:
return ""
if business_code(o) is not None and business_code(n) is not None and business_code(o) != business_code(
n
):
return f"normalized JSON differs; first code: old={business_code(o)} new={business_code(n)}"
return "normalized JSON differs (see full bodies in test log)"
@@ -0,0 +1,29 @@
"""Probes that work for both Spring Boot 1.5/2.x with common actuator settings."""
import requests
_CANDIDATES = (
"/actuator/health",
"/actuator/health/", # noqa: PIE
"/health",
"/application/health", # very old
)
def probe_healthy(base_url: str, session: requests.Session, timeout: float = 2.0) -> tuple[str, bool, int]:
base = base_url.rstrip("/")
last_status = 0
for path in _CANDIDATES:
try:
r = session.get(base + path, timeout=timeout, allow_redirects=True)
last_status = r.status_code
if r.status_code == 200 and _body_up(r.text):
return path, True, r.status_code
except requests.RequestException:
last_status = -1
return _CANDIDATES[0], False, last_status
def _body_up(text: str) -> bool:
t = (text or "").lower()
return "up" in t or '"status":"ok"' in t or '"status": "ok"' in t
@@ -0,0 +1,8 @@
[pytest]
minversion = 6.0
addopts = -q --strict-markers
testpaths = tests
markers =
live: 需要两实例可访问
unit: 纯逻辑单测
pythonpath = .
@@ -0,0 +1 @@
"""Parity report generator."""
@@ -0,0 +1,42 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import List
def timestamped_name(prefix: str) -> str:
return f"{prefix}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.md"
def write_file(
out_path: Path,
base_old: str,
base_new: str,
rows: List[dict],
) -> None:
lines = [
f"# 电梯应用 API 新旧对拍报告\n",
f"- **时间**: {datetime.now().isoformat()}\n",
f"- **基线(旧) URL**: {base_old}\n",
f"- **新构建 URL**: {base_new}\n",
"\n## 用例\n\n",
"| 用例 | 方法+路径 | HTTP(旧) | HTTP(新) | 等值 | 说明 |\n",
"| ---- | -------- | -------- | -------- | ---- | ---- |\n",
]
ok, bad = 0, 0
for r in rows:
name = r.get("name", "")
pth = f"{r.get('method', '')} {r.get('path', '')}"
s1, s2 = r.get("old_status", ""), r.get("new_status", "")
eq = "Y" if r.get("match") else "N"
if r.get("match"):
ok += 1
else:
bad += 1
msg = (r.get("message", "") or "").replace("|", "\\|")[:200]
lines.append(f"| {name} | `{pth}` | {s1} | {s2} | {eq} | {msg} |\n")
lines.append(
f"\n## 汇总\n- 通过: {ok},不一致: {bad}\n- **上线前请人工与联调/业务确认**。\n"
)
out_path.write_text("".join(lines), encoding="utf-8")
@@ -0,0 +1,2 @@
requests>=2.28.0
pytest>=7.4.0
@@ -0,0 +1,58 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from parity.catalog_loader import load as load_catalog
from parity.client import call_both
_DIR = Path(__file__).resolve().parent.parent
_FIX = _DIR / "fixtures"
@pytest.mark.usefixtures("two_instances_ready")
@pytest.mark.live
def test_parity_from_catalog(
request,
base_old,
base_new,
session_http,
):
"""按 api_catalog 双端对拍。compare_mode: deep | code_only | status_only"""
cat = load_catalog()["endpoints"] # type: ignore[operator]
for ep in cat:
name = ep["id"]
fixture = ep.get("fixture")
body: dict
if fixture:
body = json.loads((_FIX / fixture).read_text(encoding="utf-8"))
else:
body = {}
pr = call_both(
name=ep.get("name", name),
method=ep.get("method", "POST"),
path=ep["path"],
body=body,
base_old=base_old,
base_new=base_new,
session=session_http,
compare_mode=ep.get("compare_mode", "code_only"),
)
row = {
"id": name,
"name": ep.get("name", name),
"method": pr.method,
"path": pr.path,
"old_status": pr.old_status,
"new_status": pr.new_status,
"match": pr.match,
"message": pr.message,
}
request.config._parity_rows.append(row) # type: ignore
# 在 code_only 下通常允许两边同一业务 code;若都非 200 也记录
assert pr.match, (
f"{name} mismatch: {pr.message}\n"
f"old_http={pr.old_status} new_http={pr.new_status}\n"
f"old_head={pr.old_text[:500]!r}\nnew_head={pr.new_text[:500]!r}"
)
@@ -0,0 +1,17 @@
"""不依赖联调机:纯比较逻辑自测。"""
from __future__ import annotations
from parity import compare as C
def test_normalize_equal():
a = {"b": 2, "a": 1, "c": {"z": 1, "y": 2}}
b = {"c": {"y": 2, "z": 1}, "a": 1, "b": 2}
assert C.normalize(a) == C.normalize(b)
def test_business_code_from_cloudwalk_result():
j = '{"code":"0","data":{}}'
p = C.parse_json_loose(j)
assert C.business_code(p) == "0"