公众授权版:normal · 1000 次 · 有效期至 2026-12-31
副本编号 LY-20261007-AC63(水印指纹已嵌入) 含 README、.gitignore;排除 .DS_Store / __pycache__ / .pytest_cache
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# 黄金样本:历史时区与夏令时 —— 设计文档 §6.1 C 组
|
||||
# utc_offset_min = 钟表时间 − UTC(分钟),用于验证 UTC 解算本身;
|
||||
# source 区分偏移来源:"historical_table"(自维护历史表)或 "zoneinfo"。
|
||||
|
||||
- name: 1988年夏_北京夏令时
|
||||
place: 北京市
|
||||
birth: "1988-07-15 10:00"
|
||||
expect:
|
||||
tz_used: "Asia/Shanghai"
|
||||
is_dst: true
|
||||
dst_offset_min: 60
|
||||
utc_offset_min: 540 # UTC+9(夏令时),UTC = 当日 01:00
|
||||
|
||||
- name: 1935年_兰州民国陇蜀时区
|
||||
place: 甘肃省兰州市
|
||||
birth: "1935-03-20 10:00"
|
||||
expect:
|
||||
source: "historical_table" # tzdata 的 Asia/Chongqing 1935 段为 +8,不符民国陇蜀时区
|
||||
utc_offset_min: 420 # 陇蜀时区中央经线 105°E → UTC+7
|
||||
is_dst: false
|
||||
|
||||
- name: 1900年_北京_zoneinfo兜底
|
||||
place: 北京市
|
||||
birth: "1900-06-15 10:00"
|
||||
expect:
|
||||
source: "zoneinfo"
|
||||
tz_used: "Asia/Shanghai"
|
||||
is_dst: false
|
||||
|
||||
- name: 1990年_乌鲁木齐_不执行夏令时
|
||||
place: 新疆乌鲁木齐市
|
||||
birth: "1990-06-15 12:00"
|
||||
expect:
|
||||
is_dst: false
|
||||
utc_offset_min: 480 # UTC = 钟表时间 − 8h(新疆不按夏令时拨快)
|
||||
@@ -0,0 +1,44 @@
|
||||
# 黄金样本:经度修正梯度 —— lon_correction_min = (lon − 120) × 4 分钟
|
||||
# 覆盖 120°E 附近(≈0)、西部大负修正、东部正修正;lon 为城市表应有值(±0.1° 级,见 §4.1)。
|
||||
|
||||
- name: 上海_几乎零修正
|
||||
place: 上海市
|
||||
lon: 121.47
|
||||
birth: "1990-06-15 12:00"
|
||||
expect_lon_correction_min: 5.88
|
||||
tolerance: 0.1
|
||||
|
||||
- name: 杭州_东经120度附近
|
||||
place: 浙江省杭州市
|
||||
lon: 120.16
|
||||
birth: "1990-06-15 12:00"
|
||||
expect_lon_correction_min: 0.64
|
||||
tolerance: 0.1
|
||||
|
||||
- name: 喀什_最西_负修正近三小时
|
||||
place: 新疆喀什市
|
||||
lon: 75.99
|
||||
birth: "1990-06-15 12:00"
|
||||
expect_lon_correction_min: -176.04 # ≈ −176 分
|
||||
tolerance: 0.1
|
||||
|
||||
- name: 哈尔滨_最东_正修正
|
||||
place: 黑龙江省哈尔滨市
|
||||
lon: 126.63
|
||||
birth: "1990-06-15 12:00"
|
||||
expect_lon_correction_min: 26.52 # ≈ +26.5 分
|
||||
tolerance: 0.1
|
||||
|
||||
- name: 成都_负修正过半时辰
|
||||
place: 四川省成都市
|
||||
lon: 104.07
|
||||
birth: "1990-06-15 12:00"
|
||||
expect_lon_correction_min: -63.72
|
||||
tolerance: 0.1
|
||||
|
||||
- name: 乌鲁木齐_负修正超两时辰
|
||||
place: 新疆乌鲁木齐市
|
||||
lon: 87.62
|
||||
birth: "1990-06-15 12:00"
|
||||
expect_lon_correction_min: -129.52
|
||||
tolerance: 0.1
|
||||
@@ -0,0 +1,46 @@
|
||||
# 黄金样本:真太阳时边界(A 组)+ 晚子时日界(B 组)—— 设计文档 §6.1
|
||||
#
|
||||
# 经度修正公式:(lon − 120°E) × 4 分钟/度
|
||||
# A 组断言 expect.lon_correction_min(容差 tolerance);
|
||||
# 带 expect.true_solar_time 的条目另按 true_solar_time_tolerance_min 宽松比对;
|
||||
# B 组断言全链条(geo → tz → solar → day_boundary)输出的 expect.is_late_zi。
|
||||
|
||||
# ---------- A 组:经度修正 ----------
|
||||
- name: 乌鲁木齐_北京时间上午_必然错位
|
||||
place: 新疆乌鲁木齐市
|
||||
birth: "1990-06-15 08:00"
|
||||
tolerance: 0.1
|
||||
expect:
|
||||
lon_correction_min: -129.5 # (87.62 − 120) × 4 = −129.52
|
||||
true_solar_time: "1990-06-15 05:52" # 文档给定(约值)
|
||||
true_solar_time_tolerance_min: 3
|
||||
|
||||
- name: 成都_经度修正过半
|
||||
place: 四川省成都市
|
||||
birth: "1990-06-15 12:00"
|
||||
tolerance: 0.1
|
||||
expect:
|
||||
lon_correction_min: -63.7 # (104.07 − 120) × 4 = −63.72
|
||||
|
||||
- name: 上海_几乎无修正
|
||||
place: 上海市
|
||||
birth: "1990-06-15 12:00"
|
||||
tolerance: 0.1
|
||||
expect:
|
||||
lon_correction_min: 5.9 # (121.47 − 120) × 4 = +5.88
|
||||
|
||||
# ---------- B 组:晚子时边界(子初换日@真太阳时 23:00) ----------
|
||||
# 北京 lon=116.41,经度修正约 −14.4 分;1 月 15 日均时差约 −9.5 分。
|
||||
# 注意:不能用 6–9 月的日期——1990 年北京处于夏令时(钟表已拨快 1 小时),
|
||||
# 会先把钟表读数拉回 22 点,破坏样本意图。1 月无夏令时。
|
||||
- name: 北京_钟表23:30_真太阳时23点_晚子时
|
||||
place: 北京市
|
||||
birth: "1990-01-15 23:30"
|
||||
expect:
|
||||
is_late_zi: true # 真太阳时 ≈ 23:06 → 归次日
|
||||
|
||||
- name: 北京_钟表22:30_真太阳时22点_非晚子时
|
||||
place: 北京市
|
||||
birth: "1990-01-15 22:30"
|
||||
expect:
|
||||
is_late_zi: false # 真太阳时 ≈ 22:06 → 当日
|
||||
@@ -0,0 +1,69 @@
|
||||
"""子初换日(设计文档 §4.5 / §6.1 B 组)。
|
||||
|
||||
- 纯函数边界:真太阳时 22:59→当日 False,23:00→次日 True,
|
||||
23:59→次日 True,00:00→当日 False
|
||||
- 黄金样本:golden/time_boundary.yaml 中带 expect.is_late_zi 的条目,
|
||||
走全链条 L0-a ~ L0-e(日界判定必须用真太阳时,不能用钟表时间)
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS_DIR = SKILL_ROOT / "scripts"
|
||||
GOLDEN_DIR = Path(__file__).resolve().parent / "golden"
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from astro.day_boundary import resolve_calendar_day # noqa: E402
|
||||
from astro.solar_time import to_true_solar_time # noqa: E402
|
||||
from astro.timezone_resolve import to_utc # noqa: E402
|
||||
from geo.cities import resolve_location # noqa: E402
|
||||
|
||||
# (真太阳时, 期望日历日, 期望 is_late_zi)
|
||||
BOUNDARY_CASES = [
|
||||
("1990-06-15 22:59", "1990-06-15", False),
|
||||
("1990-06-15 23:00", "1990-06-16", True),
|
||||
("1990-06-15 23:59", "1990-06-16", True),
|
||||
("1990-06-16 00:00", "1990-06-16", False),
|
||||
]
|
||||
|
||||
|
||||
class DayBoundaryUnitTest(unittest.TestCase):
|
||||
def test_boundary_cases(self):
|
||||
for tst_str, day_str, expect_late in BOUNDARY_CASES:
|
||||
with self.subTest(tst=tst_str):
|
||||
tst = datetime.strptime(tst_str, "%Y-%m-%d %H:%M")
|
||||
day, flag = resolve_calendar_day(tst)
|
||||
self.assertEqual(day, datetime.strptime(day_str, "%Y-%m-%d"))
|
||||
self.assertIs(flag["is_late_zi"], expect_late)
|
||||
|
||||
def test_day_is_midnight(self):
|
||||
# 返回的日历日必须是 00:00:00
|
||||
day, _flag = resolve_calendar_day(datetime(1990, 6, 15, 23, 30))
|
||||
self.assertEqual((day.hour, day.minute, day.second), (0, 0, 0))
|
||||
|
||||
|
||||
class LateZiGoldenTest(unittest.TestCase):
|
||||
def test_late_zi_samples(self):
|
||||
with open(GOLDEN_DIR / "time_boundary.yaml", encoding="utf-8") as f:
|
||||
samples = [
|
||||
s for s in yaml.safe_load(f)
|
||||
if "is_late_zi" in s.get("expect", {})
|
||||
]
|
||||
self.assertGreaterEqual(len(samples), 2, "time_boundary.yaml B 组应有 2 条样本")
|
||||
for s in samples:
|
||||
with self.subTest(sample=s["name"], place=s["place"]):
|
||||
loc = resolve_location(s["place"])
|
||||
clock = datetime.strptime(s["birth"], "%Y-%m-%d %H:%M")
|
||||
utc, _tz_audit = to_utc(clock, loc)
|
||||
tst, _st_audit = to_true_solar_time(utc, loc.lon)
|
||||
_day, flag = resolve_calendar_day(tst)
|
||||
self.assertIs(flag["is_late_zi"], s["expect"]["is_late_zi"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""端到端:subprocess 调 scripts/cast_chart.py(设计文档 §4.10 / §5)。
|
||||
|
||||
退出码约定:0 成功;2 输入不可用(地点/格式);3 库内部错误。
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS_DIR = SKILL_ROOT / "scripts"
|
||||
CAST_CHART = SCRIPTS_DIR / "cast_chart.py"
|
||||
|
||||
|
||||
def run_cast(*args):
|
||||
env = dict(os.environ)
|
||||
env["PYTHONPATH"] = str(SCRIPTS_DIR) + os.pathsep + env.get("PYTHONPATH", "")
|
||||
return subprocess.run(
|
||||
[sys.executable, str(CAST_CHART), *args],
|
||||
cwd=str(SCRIPTS_DIR),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
|
||||
class CastChartE2ETest(unittest.TestCase):
|
||||
def test_chengdu_morning_chart(self):
|
||||
"""张三 男 1990-05-14 08:30 四川省成都市 → 正常出盘(巳月辛巳)。"""
|
||||
r = run_cast(
|
||||
"--name", "张三", "--gender", "男",
|
||||
"--birth", "1990-05-14 08:30", "--place", "四川省成都市",
|
||||
)
|
||||
self.assertEqual(r.returncode, 0, f"stderr:\n{r.stderr}")
|
||||
for marker in ("## 四柱", "## 大运", "## 排盘审计", "辛巳"):
|
||||
self.assertIn(marker, r.stdout)
|
||||
|
||||
def test_beijing_late_zi_hint(self):
|
||||
"""李四 女 1990-01-15 23:30 北京市 → 真太阳时约 23:06,须含晚子时提示。
|
||||
|
||||
取 1 月而非 6 月:1990 年北京处于夏令时,6 月的钟表 23:30 会被拉回 22 点。
|
||||
"""
|
||||
r = run_cast(
|
||||
"--name", "李四", "--gender", "女",
|
||||
"--birth", "1990-01-15 23:30", "--place", "北京市",
|
||||
)
|
||||
self.assertEqual(r.returncode, 0, f"stderr:\n{r.stderr}")
|
||||
self.assertIn("晚子时", r.stdout)
|
||||
|
||||
def test_unresolvable_place_exit_2(self):
|
||||
"""王五 男 1990-01-01 12:00 火星 → LocationError,退出码 2。"""
|
||||
r = run_cast(
|
||||
"--name", "王五", "--gender", "男",
|
||||
"--birth", "1990-01-01 12:00", "--place", "火星",
|
||||
)
|
||||
self.assertEqual(r.returncode, 2,
|
||||
f"stdout:\n{r.stdout}\nstderr:\n{r.stderr}")
|
||||
self.assertIn("ERROR", r.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""均时差单测:四个已知极值点(设计文档 §4.2 / §6.2)。
|
||||
|
||||
采样方式:UTC 正午(12:00Z),容差 ±0.5 分。
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS_DIR = SKILL_ROOT / "scripts"
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from astro.equation_of_time import equation_of_time_minutes # noqa: E402
|
||||
|
||||
# (月, 日, 已知均时差/分) —— 文档 §4.2 给出的全年极值
|
||||
EXTREMA = [
|
||||
(2, 11, -14.2), # 全年最小
|
||||
(5, 14, 3.7),
|
||||
(7, 26, -6.5),
|
||||
(11, 3, 16.4), # 全年最大
|
||||
]
|
||||
|
||||
|
||||
class EquationOfTimeExtremumTest(unittest.TestCase):
|
||||
def test_extremum_points(self):
|
||||
for month, day, expected in EXTREMA:
|
||||
with self.subTest(date=f"1990-{month:02d}-{day:02d}"):
|
||||
dt_utc = datetime(1990, month, day, 12, 0, tzinfo=timezone.utc)
|
||||
eot = equation_of_time_minutes(dt_utc)
|
||||
self.assertAlmostEqual(eot, expected, delta=0.5)
|
||||
|
||||
def test_year_round_within_physical_bounds(self):
|
||||
# 每月 15 日 UTC 正午采样,均时差物理范围应落在约 −14.3 ~ +16.5 分内
|
||||
for month in range(1, 13):
|
||||
with self.subTest(month=month):
|
||||
dt_utc = datetime(1990, month, 15, 12, 0, tzinfo=timezone.utc)
|
||||
eot = equation_of_time_minutes(dt_utc)
|
||||
self.assertGreaterEqual(eot, -14.7)
|
||||
self.assertLessEqual(eot, 16.9)
|
||||
|
||||
def test_naive_datetime_rejected(self):
|
||||
# 文档 §4.2 契约:必须传入带时区的 UTC datetime
|
||||
with self.assertRaises(AssertionError):
|
||||
equation_of_time_minutes(datetime(1990, 1, 1, 12, 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""软授权锁:母版放行、副本扣次、用尽/过期/篡改/总开关。"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
import _license_gate as gate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path):
|
||||
return tmp_path
|
||||
|
||||
|
||||
def make_state(root, **kw):
|
||||
kw.setdefault("copy_id", "LY-20261007-A3F9")
|
||||
kw.setdefault("licensee", "张三")
|
||||
kw.setdefault("max_uses", 2)
|
||||
return gate.write_state(root, **kw)
|
||||
|
||||
|
||||
class TestMasterCopy:
|
||||
def test_no_state_file_passes(self, root):
|
||||
assert gate.check(root) is None
|
||||
|
||||
def test_consume_without_state_is_noop(self, root):
|
||||
gate.consume(root)
|
||||
assert not (root / "data" / ".license.json").exists()
|
||||
|
||||
|
||||
class TestLicensedCopy:
|
||||
def test_decrements_per_use_then_denies(self, root):
|
||||
make_state(root)
|
||||
assert gate.check(root) is None
|
||||
gate.consume(root)
|
||||
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 1
|
||||
assert gate.check(root) is None
|
||||
gate.consume(root)
|
||||
assert "次数已用尽" in gate.check(root)
|
||||
|
||||
def test_expired_denies(self, root):
|
||||
make_state(root, expires_at="2020-01-01")
|
||||
assert "到期" in gate.check(root)
|
||||
|
||||
def test_future_expiry_passes(self, root):
|
||||
make_state(root, expires_at="2999-01-01")
|
||||
assert gate.check(root) is None
|
||||
|
||||
def test_tampered_state_denies(self, root):
|
||||
make_state(root)
|
||||
p = root / "data" / ".license.json"
|
||||
st = json.loads(p.read_text("utf-8"))
|
||||
st["remaining_uses"] = 999 # 手改次数但不重算签名
|
||||
p.write_text(json.dumps(st, ensure_ascii=False), "utf-8")
|
||||
assert "校验失败" in gate.check(root)
|
||||
|
||||
def test_corrupted_state_denies(self, root):
|
||||
(root / "data").mkdir()
|
||||
(root / "data" / ".license.json").write_text("not json", "utf-8")
|
||||
assert gate.check(root) is not None
|
||||
|
||||
|
||||
class TestMasterSwitch:
|
||||
def test_disabled_gate_passes_exhausted_copy(self, root, monkeypatch):
|
||||
make_state(root, max_uses=0)
|
||||
monkeypatch.setattr(gate, "_ENABLED", False)
|
||||
assert gate.check(root) is None
|
||||
gate.consume(root) # 不扣次
|
||||
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 0
|
||||
@@ -0,0 +1,85 @@
|
||||
"""真太阳时经度修正黄金样本(设计文档 §4.4 / §6.1)。
|
||||
|
||||
数据来源:
|
||||
- golden/longitudes.yaml —— 不同经度城市逐条断言 lon_correction_min
|
||||
- golden/time_boundary.yaml —— 其中带 expect.lon_correction_min 的 A 组样本
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS_DIR = SKILL_ROOT / "scripts"
|
||||
GOLDEN_DIR = Path(__file__).resolve().parent / "golden"
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from astro.solar_time import to_true_solar_time # noqa: E402
|
||||
from astro.timezone_resolve import to_utc # noqa: E402
|
||||
from geo.cities import resolve_location # noqa: E402
|
||||
|
||||
|
||||
def load_golden(name):
|
||||
with open(GOLDEN_DIR / name, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def true_solar_chain(place, birth):
|
||||
"""数据流 L0-a ~ L0-d:地名 + 钟表时间 → (真太阳时, solar_audit, loc)。"""
|
||||
loc = resolve_location(place)
|
||||
clock = datetime.strptime(birth, "%Y-%m-%d %H:%M")
|
||||
utc, _tz_audit = to_utc(clock, loc)
|
||||
tst, st_audit = to_true_solar_time(utc, loc.lon)
|
||||
return tst, st_audit, loc
|
||||
|
||||
|
||||
class LongitudeCorrectionGoldenTest(unittest.TestCase):
|
||||
def test_longitudes_yaml(self):
|
||||
samples = load_golden("longitudes.yaml")
|
||||
self.assertGreaterEqual(len(samples), 5, "longitudes.yaml 至少需要 5 组样本")
|
||||
for s in samples:
|
||||
with self.subTest(sample=s["name"], place=s["place"]):
|
||||
_tst, audit, loc = true_solar_chain(s["place"], s["birth"])
|
||||
# 城市表经度 sanity check(0.1° 级即够,见文档 §4.1)
|
||||
self.assertAlmostEqual(loc.lon, s["lon"], delta=0.1)
|
||||
self.assertAlmostEqual(
|
||||
audit["lon_correction_min"],
|
||||
s["expect_lon_correction_min"],
|
||||
delta=s["tolerance"],
|
||||
)
|
||||
|
||||
|
||||
class TimeBoundaryGoldenTest(unittest.TestCase):
|
||||
"""文档 §6.1 A 组:乌鲁木齐 / 成都 / 上海。"""
|
||||
|
||||
def test_time_boundary_yaml(self):
|
||||
samples = [
|
||||
s for s in load_golden("time_boundary.yaml")
|
||||
if "lon_correction_min" in s.get("expect", {})
|
||||
]
|
||||
self.assertGreaterEqual(len(samples), 3, "time_boundary.yaml A 组应有 3 条样本")
|
||||
for s in samples:
|
||||
with self.subTest(sample=s["name"], place=s["place"]):
|
||||
tst, audit, _loc = true_solar_chain(s["place"], s["birth"])
|
||||
self.assertAlmostEqual(
|
||||
audit["lon_correction_min"],
|
||||
s["expect"]["lon_correction_min"],
|
||||
delta=s["tolerance"],
|
||||
)
|
||||
if "true_solar_time" in s["expect"]:
|
||||
want = datetime.strptime(
|
||||
s["expect"]["true_solar_time"], "%Y-%m-%d %H:%M"
|
||||
)
|
||||
tol_min = s["expect"].get("true_solar_time_tolerance_min", 3)
|
||||
self.assertLessEqual(
|
||||
abs((tst - want).total_seconds()),
|
||||
tol_min * 60,
|
||||
f"真太阳时 {tst} 与期望 {want} 差距超过 {tol_min} 分钟",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""历史时区 / 夏令时黄金样本(设计文档 §4.3 / §6.1 C 组)。
|
||||
|
||||
逐条读 golden/history_tz.yaml,断言 to_utc 返回的 (utc, audit):
|
||||
- tz_used / is_dst / dst_offset_min / source:audit 字段逐项相等
|
||||
- utc_offset_min:钟表时间 − UTC(分钟),验证 UTC 解算本身
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS_DIR = SKILL_ROOT / "scripts"
|
||||
GOLDEN_DIR = Path(__file__).resolve().parent / "golden"
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from astro.timezone_resolve import to_utc # noqa: E402
|
||||
from geo.cities import resolve_location # noqa: E402
|
||||
|
||||
AUDIT_KEYS = ("tz_used", "is_dst", "dst_offset_min", "source")
|
||||
|
||||
|
||||
class HistoryTzGoldenTest(unittest.TestCase):
|
||||
def test_history_tz_yaml(self):
|
||||
with open(GOLDEN_DIR / "history_tz.yaml", encoding="utf-8") as f:
|
||||
samples = yaml.safe_load(f)
|
||||
self.assertGreaterEqual(len(samples), 4, "history_tz.yaml 应有 4 条样本")
|
||||
for s in samples:
|
||||
with self.subTest(sample=s["name"], place=s["place"]):
|
||||
loc = resolve_location(s["place"])
|
||||
clock = datetime.strptime(s["birth"], "%Y-%m-%d %H:%M")
|
||||
utc, audit = to_utc(clock, loc)
|
||||
expect = s["expect"]
|
||||
for key in AUDIT_KEYS:
|
||||
if key in expect:
|
||||
self.assertEqual(
|
||||
audit[key], expect[key], f"audit[{key!r}] 不符"
|
||||
)
|
||||
if "utc_offset_min" in expect:
|
||||
offset_min = (
|
||||
clock - utc.replace(tzinfo=None)
|
||||
).total_seconds() / 60
|
||||
self.assertAlmostEqual(
|
||||
offset_min, expect["utc_offset_min"], delta=0.5,
|
||||
msg=f"UTC 偏移 {offset_min} 分 ≠ 期望 "
|
||||
f"{expect['utc_offset_min']} 分",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""历法库版本哨兵(设计文档 §6.2 / §7)。
|
||||
|
||||
tyme4py 用 ==1.5.0 精确锁定:历表数据随版本漂移,库升级导致回归时
|
||||
本测试第一时间定位,并提醒重新验证全部黄金样本。
|
||||
"""
|
||||
import unittest
|
||||
|
||||
import tyme4py
|
||||
|
||||
|
||||
class TymeVersionSentinelTest(unittest.TestCase):
|
||||
def test_tyme4py_version_locked(self):
|
||||
self.assertEqual(
|
||||
tyme4py.__version__,
|
||||
"1.5.0",
|
||||
"tyme4py 版本漂移:升级后必须重新验证全部黄金样本,"
|
||||
"确认后再同步本断言",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user