feat(mcfold): P5 mod jar 解析(三种加载器+多加载器诊断+方块名提取,含诚实边界标注)

This commit is contained in:
pi
2026-09-25 14:32:39 +08:00
parent 40c5739cae
commit 85ad7e580e
4 changed files with 365 additions and 0 deletions

126
tests/test_modjar.py Normal file
View File

@ -0,0 +1,126 @@
"""mod jar 解析回归 —— 用**真实 mod jar** 验证(三种加载器 + 边界)。"""
import sys, os, json, zipfile, io, tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mcfold.modjar import parse_mod, scan_mods, summarize, _parse_toml, detect_loaders
FORGE = "/home/mc-test/mods/curtain-mc1.20.1-1.3.2.jar"
NEOFORGE= "/home/minecraft/mods/bettercombat-neoforge-2.3.2+1.21.1.jar"
MULTI = "/home/minecraft/mods/Explorify v1.6.4 f15-88.mod.jar"
BLOCKS = "/home/minecraft/mods/[扩充乐事] expandeddelight-0.1.4.jar"
def exists(p): return os.path.isfile(p)
def test_toml_parser_comment_headers():
"""★ 踩过的坑:表头带行内注释('[[mods]] #mandatory')必须正确识别。"""
text = '''
loaderVersion = "[47,)"
[[mods]] #mandatory
modId = "curtain" #mandatory
version = "${file.jarVersion}"
displayName = "Curtain"
[[dependencies.curtain]]
modId = "forge"
'''
m = _parse_toml(text)
assert len(m["mods"]) == 1, f"mods 段数应为 1,实际 {len(m['mods'])}"
assert m["mods"][0]["modId"] == "curtain", m["mods"][0]
assert m["mods"][0]["displayName"] == "Curtain"
# 依赖段的 modId 不应污染 mods[0]
assert m["mods"][0]["modId"] != "forge"
print(" ✓ TOML: 带行内注释的 [[mods]] 正确识别,依赖段不污染")
def test_forge_real():
if not exists(FORGE):
print(" - forge 样本不存在,跳过"); return
m = parse_mod(FORGE)
assert m.loader == "forge", m.loader
assert m.mod_id == "curtain", m.mod_id
assert m.name == "Curtain", m.name
# curtain 只加命令、不新增方块 —— 0 方块是正确结果
assert m.block_names == [], m.block_names
print(f" ✓ Forge 真实: id={m.mod_id} name={m.name} 方块=0(它只加命令)")
def test_neoforge_real():
if not exists(NEOFORGE):
print(" - neoforge 样本不存在,跳过"); return
m = parse_mod(NEOFORGE)
assert m.loader == "neoforge", m.loader
assert m.mod_id == "bettercombat", m.mod_id
assert m.version == "2.3.2+1.21.1", m.version
print(f" ✓ NeoForge 真实: id={m.mod_id} ver={m.version}")
def test_multi_loader_jar():
"""多加载器 jar(同时含 forge/neoforge/fabric 元数据)必须能诊断。"""
if not exists(MULTI):
print(" - 多加载器样本不存在,跳过"); return
m = parse_mod(MULTI)
assert len(m.loaders_present) >= 2, m.loaders_present
assert m.mod_id == "explorify", m.mod_id
# 结构 mod:用原版方块生成地形,自己不新增方块 ⇒ 0 方块正确
assert m.block_names == [], m.block_names
print(f" ✓ 多加载器 jar: 含 {m.loaders_present},id={m.mod_id}(结构 mod,0 方块)")
def test_block_extraction_real():
"""★ 核心:从真实 mod 提取方块名清单(这是 mod 支持的可见部分)。"""
if not exists(BLOCKS):
print(" - 方块 mod 样本不存在,跳过"); return
m = parse_mod(BLOCKS)
assert m.mod_id == "expandeddelight", m.mod_id
assert len(m.block_names) > 20, f"方块数偏少: {len(m.block_names)}"
# 已知方块(前面实测过)
for want in ("cask", "cheese_wheel", "asparagus_crate"):
assert want in m.block_names, f"缺方块 {want}"
q = m.qualified_blocks()
assert "expandeddelight:cask" in q, q[:5]
# 物品也应提取到
assert len(m.item_names) > 20, len(m.item_names)
print(f" ✓ 方块提取: {len(m.block_names)} 方块 / {len(m.item_names)} 物品,含 cask/cheese_wheel")
def test_no_state_id_claim():
"""★ 诚实性:解析结果里**不含** state id(因为拿不到),摘要必须明说。"""
m = parse_mod(BLOCKS) if exists(BLOCKS) else None
if m is None:
print(" - 跳过"); return
s = summarize([m])
assert "state id" in s["note"], s["note"]
assert "block_names" in s and s["block_name_count"] > 0
# 摘要里不应有任何形如 state id 的映射承诺
assert "state_ids" not in s and "stateId" not in json.dumps(s)
print(f" ✓ 诚实标注: 摘要含 {s['block_name_count']} 方块名,并注明无 state id")
def test_bad_jar_no_crash():
"""损坏的 jar 不能中断整目录扫描。"""
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, "broken.jar")
open(p, "wb").write(b"this is not a zip")
m = parse_mod(p)
assert not m.ok and "zip" in (m.error or "").lower(), m.error
good = os.path.join(d, "ok.jar")
with zipfile.ZipFile(good, "w") as zf:
zf.writestr("fabric.mod.json", json.dumps({"id":"t","name":"T","version":"1"}))
mods = scan_mods(d)
assert len(mods) == 2, len(mods)
s = summarize(mods)
assert s["loaded"] == 1 and s["failed"] == 1, s
print(f" ✓ 损坏 jar 不中断扫描: loaded={s['loaded']} failed={s['failed']}")
def test_json_meta_without_assets():
"""纯 JSON 元数据、无 assets 的 mod 也要能拿到 id(边界)。"""
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, "t.jar")
with zipfile.ZipFile(p, "w") as zf:
zf.writestr("fabric.mod.json", json.dumps(
{"id":"mymod","name":"My Mod","version":"2.0",
"depends":{"minecraft":">=1.20","fabric-api":"*"}}))
m = parse_mod(p)
assert m.loader == "fabric" and m.mod_id == "mymod", (m.loader, m.mod_id)
assert m.depends.get("minecraft") == ">=1.20", m.depends
assert m.block_names == []
print(" ✓ fabric 纯元数据: id/depends 正确,无 assets 不报错")
if __name__ == "__main__":
for fn in [test_toml_parser_comment_headers, test_forge_real, test_neoforge_real,
test_multi_loader_jar, test_block_extraction_real, test_no_state_id_claim,
test_bad_jar_no_crash, test_json_meta_without_assets]:
fn()
print("\nmod jar 解析:全部通过")