公众授权版:normal · 1000 次 · 有效期至 2026-12-31

副本编号 LY-20261007-AC63(水印指纹已嵌入)
含 README、.gitignore;排除 .DS_Store / __pycache__ / .pytest_cache
This commit is contained in:
2026-10-07 17:29:52 +08:00
commit d9da4301cd
772 changed files with 48534 additions and 0 deletions
+35
View File
@@ -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(新疆不按夏令时拨快)
+44
View File
@@ -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
+46
View File
@@ -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 → 当日
+69
View File
@@ -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()
+64
View File
@@ -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()
+50
View File
@@ -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()
+73
View File
@@ -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
+85
View File
@@ -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()
+54
View File
@@ -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()
+22
View File
@@ -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()