diff --git a/.gitignore b/.gitignore index 015fddc..df5af24 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,9 @@ __pycache__/ *.pyc .pytest_cache/ .venv/ + +# 运行时本机状态:授权文件与自更新缓存一律不入库、不随 zipball 分发 +# (与 tools/build_manifest.py 的 EXCLUDED_FILES 保持一致;公众版须不带 +# 授权状态文件出货,否则下载者会拿到别人已签发/已耗尽的额度) +data/.license.json +data/.update_check.json diff --git a/VERSION b/VERSION index 9575d51..b727628 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.6.1 +3.6.2 diff --git a/data/cities.json b/data/cities.json index 4c150fc..44a73eb 100644 --- a/data/cities.json +++ b/data/cities.json @@ -1774,12 +1774,12 @@ "香港": { "lon": 114.17, "lat": 22.32, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "澳门": { "lon": 113.54, "lat": 22.2, - "tz": "Asia/Shanghai" + "tz": "Asia/Macau" }, "北京市东城区": { "lon": 116.4125, @@ -16024,132 +16024,132 @@ "香港特别行政区中西区": { "lon": 114.1494, "lat": 22.2847, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区湾仔区": { "lon": 114.178, "lat": 22.2791, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区东区": { "lon": 114.2212, "lat": 22.2825, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区南区": { "lon": 114.155, "lat": 22.2486, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区油尖旺区": { "lon": 114.1684, "lat": 22.3145, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区深水埗区": { "lon": 114.1582, "lat": 22.3366, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区九龙城区": { "lon": 114.1879, "lat": 22.3153, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区黄大仙区": { "lon": 114.199, "lat": 22.3391, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区观塘区": { "lon": 114.2092, "lat": 22.3237, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区荃湾区": { "lon": 114.116, "lat": 22.371, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区屯门区": { "lon": 113.9717, "lat": 22.3968, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区元朗区": { "lon": 114.0274, "lat": 22.4442, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区北区": { "lon": 114.1423, "lat": 22.4988, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区大埔区": { "lon": 114.1668, "lat": 22.4484, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区西贡区": { "lon": 114.2598, "lat": 22.3171, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区沙田区": { "lon": 114.1905, "lat": 22.3823, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区葵青区": { "lon": 114.1343, "lat": 22.3666, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "香港特别行政区离岛区": { "lon": 113.9413, "lat": 22.2894, - "tz": "Asia/Shanghai" + "tz": "Asia/Hong_Kong" }, "澳门特别行政区花地玛堂区": { "lon": 113.5478, "lat": 22.2108, - "tz": "Asia/Shanghai" + "tz": "Asia/Macau" }, "澳门特别行政区花王堂区": { "lon": 113.5439, "lat": 22.2021, - "tz": "Asia/Shanghai" + "tz": "Asia/Macau" }, "澳门特别行政区望德堂区": { "lon": 113.5451, "lat": 22.1967, - "tz": "Asia/Shanghai" + "tz": "Asia/Macau" }, "澳门特别行政区大堂区": { "lon": 113.5486, "lat": 22.1915, - "tz": "Asia/Shanghai" + "tz": "Asia/Macau" }, "澳门特别行政区风顺堂区": { "lon": 113.5368, "lat": 22.1903, - "tz": "Asia/Shanghai" + "tz": "Asia/Macau" }, "澳门特别行政区嘉模堂区": { "lon": 113.5536, "lat": 22.1567, - "tz": "Asia/Shanghai" + "tz": "Asia/Macau" }, "澳门特别行政区路凼填海区": { "lon": 113.5645, "lat": 22.1396, - "tz": "Asia/Shanghai" + "tz": "Asia/Macau" }, "澳门特别行政区圣方济各堂区": { "lon": 113.5549, "lat": 22.1264, - "tz": "Asia/Shanghai" + "tz": "Asia/Macau" }, "_aliases": { "乌市": "乌鲁木齐", diff --git a/manifest.json b/manifest.json index f3ab8da..8c257b6 100644 --- a/manifest.json +++ b/manifest.json @@ -1,14 +1,14 @@ { "files": { - ".gitignore": "1e4671dee2d3123a45609ed82a4329531e0d41bf1b6895ab169ecd8813cfb8f5", + ".gitignore": "6afd9b30daced03071366d6069e30682c6d4bccd6e0aa08d17936c8d373e8789", "LICENSE.md": "6b839540e0fd4ab75530ec31fc5c3cbc3c2d5de5fe85068ef1c0901e54be9315", "README.md": "adb4481f20360c66e67fd89076c1535c577cdf85f34122d2dac1ff727dd81c38", "SKILL.md": "720aa2de6e7e6f65c44f63421f0aece401146921e54a6c99f24db1af14241813", - "VERSION": "de58d841bd709aa85a66ecc500dfbb48e7c207e84c399e30e751e8ccad6e8c9b", + "VERSION": "bb8408e7188bc6e00f87159c1898e6bfee03926fb2c65b5af3f372a7abfbc7fb", "data/.public_edition.json": "e8692c45d512c78903fc89a924d0e6d4dc9b95a78ca6b359bb7a5ef5c946ffee", - "data/cities.json": "00f17905c8843218162e25238a78aeb4c4a3bb5424b611cc30ed9e436db31854", + "data/cities.json": "9ae91a498d2b12e5c2f53efc5dc4989e891cf138b06c9b593796427cdc272393", "memory/README.md": "a979f985dd87e87bfca49676ebf10a537d299b1eeebac121c506c320c88a3afd", - "pyproject.toml": "b202c2ca784d501959d3dc4d7024851de8b66b15a7e3368b81566b2e045601b4", + "pyproject.toml": "81b2ddba56b7a5cf158e807ede54273750f3040d7fa0426ffd9187fd73993292", "requirements.txt": "46a4c971b192cbf2c4972dd0f420e7928b7e44cd3cb9f7dc5a36efb321ab6fd6", "rules/00_总纲_取格.md": "829a35454f5c91966ecafe4dd2113b913588933efe08d0e1c127ae82ccec0da8", "rules/11_正官格.md": "385193d93dfba89a935ca8907098f70548ad1fc12ab6a07a5280fd04224c83b6", @@ -568,22 +568,22 @@ "scripts/_runtime_guard.py": "c4f9d00acbb5b2ba49680b23ed4f846c60211e67b9d58950079b3e8909dcfe81", "scripts/_update_sig.py": "9bb3521b950ab834c770e313a98c9cc8f7e997997540258724505af774a4abe6", "scripts/astro/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "scripts/astro/day_boundary.py": "b5a24b3937fbcd9c278331b5b19f8cf708acc987f6f6f97f346524786735af9a", + "scripts/astro/day_boundary.py": "b0ddd270be439b51fab46595661d2f9b11d83b52acda9c2f1ec6f79fec7aae78", "scripts/astro/equation_of_time.py": "ee9331022b4ed35d407664e97b5ce9ce99167a06ce7cb8c9ffba5497a437da8e", "scripts/astro/solar_time.py": "35b45edc365083a6ce90afe076176c6f3e798509e76d3f03819fd1845491b153", "scripts/astro/timezone_resolve.py": "3a287a64bd50736ce91ac15876e88edeb98745eb6d0459cf18e7e6ee3c026aba", - "scripts/cast_chart.py": "9b9f33cfae9af797859554c630529d8fc5c27c5688a0414fbc5038ae2e5c90d8", + "scripts/cast_chart.py": "f5fd58dffc9a66d85d018740714b4b7e497cbc13b75c9bf4e901d827ae5f026e", "scripts/chart/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "scripts/chart/ganzhi.py": "94d53d2a8cd7674312223e976ce6e1379a6fbb53873253b34963b3e3d645508e", "scripts/chart/luck.py": "d930f3f402cf814eaaa10fdcaa0a8be4a47411e6961dd0324ac38a8f898b4299", "scripts/chart/model.py": "8279145da10901cfb1a7b4823762074169dfd554bbe8ce39ae15862334b001f6", "scripts/chart/shensha.py": "9db4a0e4cfaaa667efa9bbdcf7b8fd3d0c461cafbdf125e037cb467d75803939", - "scripts/chart/tyme_adapter.py": "06a46f16efc3d745edb5f35bbdcfee1c78552a44235fc85de0af3eb6c163eaca", + "scripts/chart/tyme_adapter.py": "5a8831050738d41e7a27a9bee55f86a84b351b0e9a203a35d12f383a9f9ef801", "scripts/chart_shot.py": "0096a01e64128a2628ee0e2d3bc211d93fbb0e2b17a49a34f4f2f9a37da4960c", "scripts/geo/__init__.py": "1211b885ec767b399472df572f970b07d6d028a5889af49fe50f6fc1a2a0cf7d", "scripts/geo/cities.py": "9d393608c03a2ad5c7352939e70ea717dae5dd4bc833dfdd0e2f663940c61fb5", "scripts/memory_lookup.py": "23f02b04636924a1f6872d338e85ee2145ed1cde742b3e2e5b6ee4592ff92d50", - "scripts/memory_save.py": "cfdf6dcd652475fb9bf04521655581dad10f36d7d7f69d079bf07833b99ba4e6", + "scripts/memory_save.py": "eea51e7483966630c89705094b8a56c14f999573e7b06f5bb911bdd6d0accbca", "scripts/query_shensha.py": "e4c852c8e56fb8b32bde0fb376d985647bcfd93a74fe30fd34c5884d2ab77dc3", "scripts/render/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "scripts/render/assets/lingyao-logo.png": "3d63f71ff9af61a443708f2665d1331737bb567a4e731d049676dfeb4710e323", @@ -601,20 +601,20 @@ "tests/golden/longitudes.yaml": "6fda1e6b632d29c135362c9273b931c4416c9f24c31b07e2a7baadc6e2077bc2", "tests/golden/time_boundary.yaml": "637090a1c081b9b824fc0073d29dad073d29138c3b48082bdf41ce77f5af3b37", "tests/test_chart_shot.py": "7923d2868ded898331a65152b237cf014fa49b180b08706584ff7319f88b7a33", - "tests/test_day_boundary.py": "0e6c9ee32177a178f21a7812f83032abe4d1f585de48d07d21e2b432bdbcbd04", + "tests/test_day_boundary.py": "6e2038152738a02642a3b04edfaf542328179998e62c1960a59389a527868850", "tests/test_e2e.py": "e324930fad582dc38618fa3599425bb48ec718beaf426efcb76531911c9464fa", "tests/test_equation_of_time.py": "8a3a1baf7e61c42015405cc80fc0bdf4fd9bc732d440bc156f9c92c94e30f790", "tests/test_html.py": "d40f4558ddfda156706a0c3d4c52c794fbc1e59427acdc93bcdad7df34217638", "tests/test_license_gate.py": "ee44226b75620b3965ba52cc2a0cc5404fbff37822fd96a6faf2aed89d07d47e", "tests/test_luck.py": "821f98bc19ab1e920353de58c6ffacac5d757c887c690a2d596f51c1485b21ea", - "tests/test_memory.py": "068241927df08301806451ff6e66206ceb9d439d95197b3506fe519d892c28ed", + "tests/test_memory.py": "bec4427e3e874b655583a6d9a69c85a590aab15c09ad645e6f7ce2a7139403db", "tests/test_runtime_guard.py": "7fd7e2f334433432452ea68fde30df1698def2126a1920f8da6d6aab236c1caf", "tests/test_self_update.py": "87a0fdaf28132e70fe407a552ff77f5911e13e67ca1d568581a273eaba875712", "tests/test_share.py": "7938344415cbba95f683b2a09ecec9af28d00658f475ff0a56563dd13beff630", "tests/test_shensha.py": "2aaa181279739356828f05a2175027b0ae711b322b4bd0e60d5c5e62538bcac8", "tests/test_solar_time.py": "f072e5ea82fd0cb331c8a4fdc6dce08148cd9ac81ef9fe4506196fa68a42d1e4", - "tests/test_sync_tool.py": "67395f121cfa0092af7edb579dac54a07690b989eb82bb16c6ea815325e47ce3", - "tests/test_timezone.py": "3698878999d232bd431d6908c42fe8f4845c696da6085731abaf2f74017d722d", + "tests/test_sync_tool.py": "b96cf8ac26480cc7431c95335095b77f718d240caf04243e844d2b5c10d7dd76", + "tests/test_timezone.py": "8b292cc8e5649b8af3e9e1a0214fcd28c06a2aef28f3529874f43c3c6d77ba42", "tests/test_update_sig.py": "3bfd6ce28a699afe233fe51d9d9c7aacc308f54a4dcb9f9e7ecd272bb0a9b5a2", "tests/test_version_sentinel.py": "9b2bc603a2e86aa33d904f1381ecd8ab41eb0e759803ec47122d662c10a7df83", "vendor/VENDOR.md": "dbf017616ac2fda4fef56ce95e3610e77457108b7fa426136fd25764d78fa3c8", @@ -1279,5 +1279,5 @@ "vendor/tzdata/zoneinfo/zonenow.tab": "7f9d1c11445af7a17862c20a9081226d31a09a1730e270ff59a34c8d2b4c7f7f", "vendor/tzdata/zones": "28d8f3eb25839095fa8a209997bb20e822ebfbebe21e60edc483a3d5e20dc881" }, - "version": "3.6.1" + "version": "3.6.2" } diff --git a/manifest.json.sig b/manifest.json.sig index 940eaf6..951b075 100644 --- a/manifest.json.sig +++ b/manifest.json.sig @@ -1 +1 @@ -/hVDbf9RW2/oWP5z1nhGyc6/Ip76Inw4mLKaRtsojbuDS/tAaY9km8phEBGgC4T8qunh/FD8qc4dcVHk+iJfCA== +bQwEXaTayoFrFHTwoJ3+vFTSB6kmWkPNNoEXOnVgdUqe2+H1f7ZYn2dp+CzE9XQFozkBqoCX1zsmdoORNEJsAw== diff --git a/pyproject.toml b/pyproject.toml index d7c06c5..8886b2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ZiPingEightChar" -version = "3.6.1" +version = "3.6.2" requires-python = ">=3.10" # zoneinfo 需 3.9+,dataclass 语法需 3.10 dependencies = [ "tyme4py==1.5.0", # 精确锁定:历表数据跨版本会变,锁版本 = 锁可复现性 diff --git a/scripts/astro/day_boundary.py b/scripts/astro/day_boundary.py index ac4a8db..8f6de6c 100644 --- a/scripts/astro/day_boundary.py +++ b/scripts/astro/day_boundary.py @@ -5,9 +5,13 @@ ZI_HOUR_START = 23 # 真太阳时 23:00 换日 def resolve_calendar_day(tst: datetime, rule: str = "子初") -> tuple[datetime, dict]: """ - 日界归算:rule="子初"(默认)真太阳时 23:00–24:00 归入次日; + 日界归算与审计:rule="子初"(默认)真太阳时 23:00–24:00 归次日; rule="子正" 以 0:00 为日界,晚子时不换日。 + 注意:换日的**实际排盘效果**由 chart.tyme_adapter.cast 的 sect 参数 + (tyme4py provider)原生实现;本函数返回的日历日仅供审计与外部参考, + 调用方不得把它再拼回时刻传给 cast(会双重换日)。 + 返回 (日历日 00:00 的 datetime, flag) """ diff --git a/scripts/cast_chart.py b/scripts/cast_chart.py index 10fb2ae..601ee18 100644 --- a/scripts/cast_chart.py +++ b/scripts/cast_chart.py @@ -88,11 +88,13 @@ def main() -> int: # L0-d UTC → 真太阳时(经度修正 + 均时差) tst, st_audit = to_true_solar_time(utc, loc.lon) - # L0-e 子初换日:真太阳时 23:00–24:00 归入次日 - day, bd_audit = resolve_calendar_day(tst) + # L0-e 日界审计:is_late_zi 警示与口径文案;换日本身由排盘层 + # provider 口径实现(见 tyme_adapter.cast 的 sect 参数),此处不再滚日 + _day, bd_audit = resolve_calendar_day(tst) - # 排盘时刻 = 换日后的日历日 + 真太阳时的时分秒 - cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second) + # 排盘时刻 = 真太阳时原样(晚子时的日界归算交给 tyme4py provider, + # 预先滚日会造成双重换日) + cast_dt = tst # L1-a 排盘(tyme4py 唯一接触面) raw = cast(cast_dt, a.gender) diff --git a/scripts/chart/tyme_adapter.py b/scripts/chart/tyme_adapter.py index 67ef53f..540b6be 100644 --- a/scripts/chart/tyme_adapter.py +++ b/scripts/chart/tyme_adapter.py @@ -55,11 +55,27 @@ def _shen_sha(ganzhi: dict, gender: str) -> dict: } -def cast(cast_dt: datetime, gender: str) -> dict: - """cast_dt:已完成子初换日调整的真太阳时(naive,北京时间语义)。""" - solar = SolarTime.from_ymd_hms(cast_dt.year, cast_dt.month, cast_dt.day, - cast_dt.hour, cast_dt.minute, cast_dt.second) - ec = solar.get_lunar_hour().get_eight_char() +def cast(cast_dt: datetime, gender: str, sect: int = 1) -> dict: + """cast_dt:真太阳时(naive,北京时间语义)。 + + sect:日界流派。1=子初换日(tyme4py 默认 provider,晚子时 23:xx 日柱归次日); + 2=子正换日(LunarSect2,晚子时日柱算当天)。晚子时的换日由 provider + 原生实现,调用方不得再对 cast_dt 预先滚日(否则双重换日)。 + """ + from tyme4py.eightchar.provider.impl import ( + DefaultEightCharProvider, LunarSect2EightCharProvider, + ) + from tyme4py.lunar import LunarHour + + prev_provider = LunarHour.provider + LunarHour.provider = (LunarSect2EightCharProvider() if sect == 2 + else DefaultEightCharProvider()) + try: + solar = SolarTime.from_ymd_hms(cast_dt.year, cast_dt.month, cast_dt.day, + cast_dt.hour, cast_dt.minute, cast_dt.second) + ec = solar.get_lunar_hour().get_eight_char() + finally: + LunarHour.provider = prev_provider pillars = {k: getattr(ec, f"get_{k}")() for k in _PILLARS} me = ec.get_day().get_heaven_stem() diff --git a/scripts/memory_save.py b/scripts/memory_save.py index fdc7940..1e7014e 100644 --- a/scripts/memory_save.py +++ b/scripts/memory_save.py @@ -85,13 +85,14 @@ def _cast_chart(name: str, gender: str, birth: str, place: str, # L0-d UTC → 真太阳时(经度修正 + 均时差) tst, st_audit = to_true_solar_time(utc, loc.lon) - # L0-e 日界归算:默认子初换日(真太阳时 23:00–24:00 归入次日) - day, bd_audit = resolve_calendar_day(tst, rule=day_boundary) + # L0-e 日界审计:is_late_zi 警示与口径文案;换日由排盘层 provider + # 口径实现(sect 参数),此处不再对时刻预先滚日(避免双重换日) + _day, bd_audit = resolve_calendar_day(tst, rule=day_boundary) - cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second) + cast_dt = tst - # L1-a 排盘(tyme4py 唯一接触面) - raw = cast(cast_dt, gender) + # L1-a 排盘(tyme4py 唯一接触面);子正口径走 sect=2(晚子时日柱算当天) + raw = cast(cast_dt, gender, sect=2 if day_boundary == "子正" else 1) # L1-b 起运、大运、流年、流月、小运 forward = is_forward(gender, raw["year"]["gan"]) diff --git a/tests/test_day_boundary.py b/tests/test_day_boundary.py index d371cc1..98f17cc 100644 --- a/tests/test_day_boundary.py +++ b/tests/test_day_boundary.py @@ -17,6 +17,9 @@ 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)) +_VENDOR = SKILL_ROOT / "vendor" +if str(_VENDOR) not in sys.path: + sys.path.insert(0, str(_VENDOR)) from astro.day_boundary import resolve_calendar_day # noqa: E402 from astro.solar_time import to_true_solar_time # noqa: E402 @@ -65,5 +68,39 @@ class LateZiGoldenTest(unittest.TestCase): self.assertIs(flag["is_late_zi"], s["expect"]["is_late_zi"]) +class LateZiSectPillarsTest(unittest.TestCase): + """晚子时双滚回归:换日由 tyme4py provider 口径实现(sect 参数), + 调用方不再预滚日。锚点:1973-08-23 23:32 真太阳时。 + 子初 → 壬辰日庚子时(滚一日);子正 → 辛卯日庚子时(不滚,晚子时时柱仍次日起)。 + """ + + def test_sect1_rolls_once(self): + from chart.tyme_adapter import cast + r = cast(datetime(1973, 8, 23, 23, 32), "男", sect=1) + self.assertEqual(r["day"]["gan"] + r["day"]["zhi"], "壬辰") + self.assertEqual(r["hour"]["gan"] + r["hour"]["zhi"], "庚子") + + def test_sect2_no_roll(self): + from chart.tyme_adapter import cast + r = cast(datetime(1973, 8, 23, 23, 32), "男", sect=2) + self.assertEqual(r["day"]["gan"] + r["day"]["zhi"], "辛卯") + self.assertEqual(r["hour"]["gan"] + r["hour"]["zhi"], "庚子") + + def test_provider_restored(self): + from chart.tyme_adapter import cast + from tyme4py.eightchar.provider.impl import DefaultEightCharProvider + from tyme4py.lunar import LunarHour + cast(datetime(1973, 8, 23, 23, 32), "男", sect=2) + self.assertIsInstance(LunarHour.provider, DefaultEightCharProvider) + + def test_non_late_zi_unaffected(self): + from chart.tyme_adapter import cast + for sect in (1, 2): + with self.subTest(sect=sect): + r = cast(datetime(1973, 8, 23, 12, 0), "男", sect=sect) + self.assertEqual(r["day"]["gan"] + r["day"]["zhi"], "辛卯") + self.assertEqual(r["hour"]["gan"] + r["hour"]["zhi"], "甲午") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_memory.py b/tests/test_memory.py index dd7b125..8428e1d 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -131,7 +131,7 @@ class TestUpdateChart: r1 = run(SAVE, tmp_path, *WANZI) assert r1.returncode == 0, f"stderr:\n{r1.stderr}" path = Path(r1.stdout.strip()) - assert "丙子庚子庚辰丙子" in path.name # 默认子初换日盘 + assert "丙子庚子己卯甲子" in path.name # 默认子初换日盘(晚子时归次日) # 模拟核对后的回写 + 固定 created_at text = path.read_text(encoding="utf-8") @@ -140,28 +140,28 @@ class TestUpdateChart: text, count=1, flags=re.M) path.write_text(text, encoding="utf-8") - # 用户确认子正换日:重排替换盘面段,日柱/时柱变 → 改新主键名 + # 用户确认子正换日:重排替换盘面段,日柱变(时柱同为晚子时起法,不变)→ 改新主键名 r2 = run(SAVE, tmp_path, "--update-chart", str(path), "--day-boundary", "子正") assert r2.returncode == 0, f"stderr:\n{r2.stderr}" - assert "日柱 庚辰→己卯" in r2.stderr and "时柱 丙子→甲子" in r2.stderr + assert "日柱 己卯→戊寅" in r2.stderr assert "待复核" in r2.stderr # 变动柱影响条目提示复核 new_path = Path(r2.stdout.strip()) assert new_path != path and not path.exists() - assert "丙子庚子己卯甲子" in new_path.name + assert "丙子庚子戊寅甲子" in new_path.name assert list((tmp_path / "archives").glob("*.md")) == [new_path] text = new_path.read_text(encoding="utf-8") meta = read_meta(text) - assert meta["pillars"] == '["丙子", "庚子", "己卯", "甲子"]' - assert meta["day_master"] == "己" + assert meta["pillars"] == '["丙子", "庚子", "戊寅", "甲子"]' + assert meta["day_master"] == "戊" assert meta["created_at"] == "2000-01-01 00:00" # 保留 # 盘面段只剩新盘:日界审计与晚子时警示均为子正口径,无旧盘残留 assert "- 日界规则:子正换日(真太阳时 00:00)" in text assert "已按「子正换日」定盘" in text assert text.count("# 命盘") == 1 chart_section = text.split("## 盘面")[1].split("## 主要关注方向")[0] - assert "日柱:己卯" in chart_section and "日柱:庚辰" not in chart_section + assert "日柱:戊寅" in chart_section and "日柱:己卯" not in chart_section # 其余小节保留 assert "- 【已核实】2016 年离乡赴厦门读大学。" in text diff --git a/tests/test_sync_tool.py b/tests/test_sync_tool.py index 00a7343..cfdb7de 100644 --- a/tests/test_sync_tool.py +++ b/tests/test_sync_tool.py @@ -1,7 +1,9 @@ """外层 tools/sync_license_copy.py 的集成测试。 sync/build/sign 工具留在授权方工作区外层 tools/,不随本仓库分发; -副本或 clone 环境下 tools/ 不存在,整个模块 skip。 +副本或 clone 环境下 tools/ 不存在,整个模块 skip。注意 skipif 只跳过测试 +**执行**、挡不住模块导入,故下面的模块级 _load_module 也带同一存在性守卫, +否则副本里 `pytest tests/` 会在收集阶段就 FileNotFoundError 并中断全套测试。 测试用微型假母版(monkeypatch 替换模块 SRC)同步进临时授权副本, 不碰真实母版与真实副本;签名用测试私钥,UPDATE_PUBLIC_KEY_HEX 同步造假。 @@ -18,10 +20,12 @@ ROOT = Path(__file__).resolve().parents[1] TOOLS = ROOT.parent / "tools" SYNC_TOOL = TOOLS / "sync_license_copy.py" +_HAS_TOOLS = (SYNC_TOOL.is_file() + and (TOOLS / "build_manifest.py").is_file() + and (TOOLS / "sign_manifest.py").is_file()) + pytestmark = pytest.mark.skipif( - not (SYNC_TOOL.is_file() - and (TOOLS / "build_manifest.py").is_file() - and (TOOLS / "sign_manifest.py").is_file()), + not _HAS_TOOLS, reason="外层 tools/ 不随仓库分发,本环境无同步工具", ) @@ -41,8 +45,10 @@ def _load_module(name: str, path: Path): return mod -sync_tool = _load_module("sync_license_copy", SYNC_TOOL) -build_manifest = _load_module("build_manifest", TOOLS / "build_manifest.py") +# 无 tools/ 时置 None:本模块所有测试已被 skipif 跳过,不会触达这两个名字 +sync_tool = _load_module("sync_license_copy", SYNC_TOOL) if _HAS_TOOLS else None +build_manifest = (_load_module("build_manifest", TOOLS / "build_manifest.py") + if _HAS_TOOLS else None) def _make_master(src: Path, pk_hex: str) -> None: diff --git a/tests/test_timezone.py b/tests/test_timezone.py index 868d00a..75a0ac0 100644 --- a/tests/test_timezone.py +++ b/tests/test_timezone.py @@ -50,5 +50,36 @@ class HistoryTzGoldenTest(unittest.TestCase): ) +class HkMoDstTest(unittest.TestCase): + """香港/澳门时区回归:1986–1991 中国大陆实行夏令时,港澳 1979 年后无; + cities.json 港澳条目必须为 Asia/Hong_Kong / Asia/Macau,不得随大陆拨快。""" + + def test_hongkong_1987_no_dst(self): + loc = resolve_location("香港") + _utc, audit = to_utc(datetime(1987, 7, 5, 5, 0), loc) + self.assertEqual(loc.tz, "Asia/Hong_Kong") + self.assertIs(audit["is_dst"], False) + + def test_macau_1987_no_dst(self): + loc = resolve_location("澳门") + _utc, audit = to_utc(datetime(1987, 7, 5, 5, 0), loc) + self.assertEqual(loc.tz, "Asia/Macau") + self.assertIs(audit["is_dst"], False) + + def test_mainland_1987_dst_kept(self): + # 回归保护:大陆 1987 年夏令时仍应拨快 + loc = resolve_location("北京") + _utc, audit = to_utc(datetime(1987, 7, 5, 5, 0), loc) + self.assertIs(audit["is_dst"], True) + + def test_hongkong_1970_historical_dst(self): + # 香港 1949 后的历史夏令时(如 1970 年)应被 IANA 库正确处理 + # (1949 前走民国五时区历史表分支,不在此断言范围) + loc = resolve_location("香港") + _utc, audit = to_utc(datetime(1970, 7, 15, 12, 0), loc) + self.assertEqual(audit["tz_used"], "Asia/Hong_Kong") + self.assertIs(audit["is_dst"], True) + + if __name__ == "__main__": unittest.main()