mirror of
https://github.com/hpd840321/starRiverProperty.git
synced 2026-06-09 08:20:31 +08:00
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:
@@ -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
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"visitorId": "",
|
||||
"personId": "",
|
||||
"begVisitorTime": 1,
|
||||
"endVisitorTime": 1,
|
||||
"floorIds": []
|
||||
}
|
||||
+8
@@ -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
|
||||
+58
@@ -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"
|
||||
Reference in New Issue
Block a user