Files
agentmc/tests/test_servers.py
JianFeeeee 9c5fed5808 M10b:加载器服务端(让 server.py 能起带 mod 的服)+ 11 项判据
用户裁定「加载器都做」+ 选项 A(测试服装 mod)。原 server.py 是硬编码
vanilla 直起(java -jar server.jar),装不了 mod ⇒ 选项 A 走不通。

新增 mcfold/servers.py:
- create_modded_server():在**实例层**装一个 modded 服务端
- fabric_installer / fabric_server_launcher / fabric_profile_meta:
  装前检查(版本/加载器显式给,不猜、不用「最新」)
- 沿用既有三层隔离,**内容根零写入**

隔离要点(tests/test_version_isolation 守同一条不变量):
  mods 落**实例层** ⇒ 同一内容根可起多个「装了不同 mod 的服」,
  它们互不看见对方的 mod(这正是版本隔离要防的崩因)。

★ 关键设计:未接的加载器(NeoForge/Forge)**明说未接且不留痕**
  - 静默降级成 vanilla 的后果很严重:服起来了但 mod 全没加载,
    而使用者以为装上了 ⇒ 后续所有观察都基于错误前提
  - 失败时不建实例目录(否则会留个「看着存在但没配好」的壳,
    list_instances 会把它列出来)—— 实测修掉了这个

顺手补上:instance.json 记全 version/loader/role
  (之前 7 个实例里有 2 个缺它,list_instances 只能报 has_meta: False
    ⇒ 隔离没有可核查的依据)

判据 11 项(tests/test_servers.py),全程用临时目录不碰真实 mcview;
fabric 路径替换掉下载函数 ⇒ 判据不依赖网络(否则网络一挂就假红)。

4 项变异全被抓:
  A mods 写进内容根(破坏隔离)
  B 未接加载器静默降级成 vanilla(最危险)
  C 缺 mod 静默跳过
  D instance.json 不记 loader/version

下一步(选项 A 的收尾):在 M0 测试服起一个装了 Fabric mod 的服,
验证 mod 方块能被识别与渲染。

220 项全通过。
2026-09-26 14:10:43 +08:00

227 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""M10b 加载器服务端判据:servers.py。
## 判据要点
1. **隔离**:mods 落**实例层**,内容根**零写入**
(这是 `test_version_isolation` 守的同一条不变量,加载器不能破坏它)
2. **未接的加载器要明说**,不能瞎猜或静默降级成 vanilla
3. **缺文件要指名道姓**,不能吞掉
4. 全程用**临时目录**,不碰真实 mcview
## 不测什么
下载安装器、起 JVM —— 那些需要网络/环境,放真实使用时验。
"""
import sys, os, json, tempfile, stat
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import mcfold.servers as S
def _tmp():
"""把模块的路径常量指到临时目录(隔离真实 mcview)。"""
tmp = Path(tempfile.mkdtemp(prefix="mcfold-srv-test-"))
S.BASE = tmp
S.STORE = tmp / "store"
S.INSTANCES = tmp / "instances"
S.RUNTIME = tmp / "runtime"
S.LOADER_CACHE = tmp / "loaders"
return tmp
def _fake_jar(tmp: Path, name: str = "server.jar", size: int = 5000) -> Path:
p = tmp / name
p.write_bytes(b"PK\x03\x04" + b"0" * size)
return p
# ---------- ① 隔离:mods 在实例层,内容根零写入 ----------
def test_mods_land_in_instance_not_content_root():
"""★★ mods 必须落**实例层**;内容根保持只读共享。
为什么这条是硬要求:一个 vanilla 内容根旁边要能起多个「装了不同 mod 的服」。
mods 若写进内容根 ⇒ 那些服会互相看到对方的 mod(这正是版本隔离要防的崩因)。
"""
tmp = _tmp()
jar = _fake_jar(tmp)
mod = _fake_jar(tmp, "coolmod.jar")
r = S.create_modded_server("inst1", version="1.21.1", loader="vanilla",
mod=[str(mod)], server_jar=str(jar))
assert r["ok"] is True, r
inst = Path(r["instance"])
assert (inst / "mods" / "coolmod.jar").exists(), "mod 应在实例的 mods/ 里"
# ★ 内容根零写入
assert not S.STORE.exists() or not any(S.STORE.iterdir()), \
f"内容根被写入了:{list(S.STORE.iterdir()) if S.STORE.exists() else []}"
print(" ★★ mods 落实例层,内容根零写入")
def test_two_instances_share_content_root_different_mods():
"""★ 同一版本的两个实例可装**不同** mod(多实例的意义)。"""
tmp = _tmp()
jar = _fake_jar(tmp)
a = _fake_jar(tmp, "modA.jar")
b = _fake_jar(tmp, "modB.jar")
r1 = S.create_modded_server("instA", version="1.21.1", loader="vanilla",
mod=[str(a)], server_jar=str(jar))
r2 = S.create_modded_server("instB", version="1.21.1", loader="vanilla",
mod=[str(b)], server_jar=str(jar))
assert r1["ok"] and r2["ok"]
ma = sorted(p.name for p in (Path(r1["instance"]) / "mods").iterdir())
mb = sorted(p.name for p in (Path(r2["instance"]) / "mods").iterdir())
assert ma == ["modA.jar"] and mb == ["modB.jar"], (ma, mb)
print(" ★ 同版本两实例各装各的 mod(互不看见)")
def test_instance_json_records_version_and_loader(monkey=None):
"""★ 实例元数据要记 version/loader —— 隔离的**依据**。
(之前 7 个实例里有 2 个缺 instance.json,list_instances 只能报
has_meta: False ⇒ 隔离没有可核查的依据。)
★ fabric 路径会去**下载** server-launch ⇒ 判据里替换掉下载函数,
避免判据依赖网络(网络一挂就假红)。
"""
tmp = _tmp()
jar = _fake_jar(tmp)
# 替换下载:造一个本地假 launcher
fake_launch = tmp / "fake-fabric-server-launch.jar"
fake_launch.write_bytes(b"PK\x03\x04" + b"0" * 5000)
orig = S.fabric_server_launcher
S.fabric_server_launcher = lambda v: {"ok": True, "path": str(fake_launch),
"cached": True, "_stub": True}
try:
r = S.create_modded_server("inst1", version="1.20.1", loader="fabric")
finally:
S.fabric_server_launcher = orig
assert r["ok"] is True, r
meta = json.loads((Path(r["instance"]) / "instance.json").read_text())
assert meta["version"] == "1.20.1", meta
assert meta["loader"] == "fabric", meta
assert meta["role"] == "server", meta
print(" ★ instance.json 记全 version/loader/role")
def test_runtime_dir_is_third_layer():
"""★ 运行时目录(world/logs/pid)在第三层,不在实例或内容根里。"""
tmp = _tmp()
jar = _fake_jar(tmp)
r = S.create_modded_server("inst1", version="1.21.1", loader="vanilla",
server_jar=str(jar))
rt = Path(r["runtime"])
assert rt.parent == S.RUNTIME, rt
assert rt.name == "inst1", rt
assert "/store/" not in str(rt) and "/instances/" not in str(rt)
print(f" ★ 运行时目录在第三层({rt})")
# ---------- ② 未接的加载器必须明说 ----------
def test_unimplemented_loader_says_so():
"""★★ 没接的加载器(NeoForge/Forge)必须**明说**,不能静默降级。
静默降级成 vanilla 的后果很严重:服起来了但**mod 全部没加载**,
而使用者以为装上了 ⇒ 后面所有观察都基于错误前提。
"""
tmp = _tmp()
jar = _fake_jar(tmp)
for loader in ("neoforge", "forge"):
r = S.create_modded_server(f"inst-{loader}", version="1.21.1",
loader=loader, server_jar=str(jar))
assert r["ok"] is False, f"{loader} 不该成功"
assert loader in r["why"], r
assert r.get("how_to"), r
# ★ 不能建出实例目录(否则会留个「看着存在但没配好」的壳)
assert not (S.INSTANCES / f"inst-{loader}").exists(), \
f"{loader} 失败时不该留下实例目录"
print(" ★★ NeoForge/Forge 明确说未接(不静默降级成 vanilla)")
# ---------- ③ 缺文件要指名道姓 ----------
def test_missing_mod_files_are_named():
"""★ 缺的 mod 要**逐个列出来**(不能吞掉让人以为全装上了)。"""
tmp = _tmp()
jar = _fake_jar(tmp)
r = S.create_modded_server("inst1", version="1.21.1", loader="vanilla",
mod=["/no/such1.jar", "/no/such2.jar"],
server_jar=str(jar))
assert r["ok"] is False, r
assert "such1" in r["why"] and "such2" in r["why"], r["why"]
assert r.get("installed") == [], r.get("installed")
print(" ★ 缺 mod 逐个指名(不静默吞)")
def test_missing_server_jar_for_vanilla_is_reported():
"""★ vanilla 没给 server.jar 要明说(不能起个残缺服)。"""
tmp = _tmp()
r = S.create_modded_server("inst1", version="1.21.1", loader="vanilla")
assert r["ok"] is False, r
assert "server_jar" in r["why"], r
assert r.get("how_to"), r
print(" ★ vanilla 缺 server.jar 明说")
def test_bad_instance_name_rejected():
"""★ 实例名要校验(它进路径,含分隔符会越界)。"""
tmp = _tmp()
for bad in ("../escape", "a/b", "", "x" * 100, "has space"):
r = S.create_modded_server(bad, version="1.21.1", loader="vanilla",
server_jar=str(_fake_jar(tmp)))
assert r["ok"] is False, f"{bad!r} 应被拒"
print(" ★ 非法实例名被拒(含路径分隔符)")
# ---------- ④ 启动脚本 ----------
def test_start_script_is_executable_and_self_contained():
"""★ 脚本要可执行且**自带 cd**(内容根零写入 ⇒ 不能依赖调用者 cwd)。"""
tmp = _tmp()
jar = _fake_jar(tmp)
r = S.create_modded_server("inst1", version="1.21.1", loader="vanilla",
server_jar=str(jar))
script = Path(r["start_script"])
assert script.exists(), script
assert script.stat().st_mode & stat.S_IXUSR, "应可执行"
body = script.read_text(encoding="utf-8")
assert 'cd "$(dirname "$0")"' in body, "应自带 cd 到实例目录"
assert "server.jar" in body
print(" ★ 启动脚本可执行且自带 cd")
def test_repeated_creation_is_idempotent_report():
"""★ 重复创建同一实例要**如实报告**(不假装全新,也不默默覆盖 mod)。"""
tmp = _tmp()
jar = _fake_jar(tmp)
a = _fake_jar(tmp, "modA.jar")
r1 = S.create_modded_server("inst1", version="1.21.1", loader="vanilla",
mod=[str(a)], server_jar=str(jar))
r2 = S.create_modded_server("inst1", version="1.21.1", loader="vanilla",
mod=[str(a)], server_jar=str(jar))
assert r1["ok"] and r2["ok"]
# 两次都成功(幂等),且 mod 没被重复堆积
mods = sorted(p.name for p in (Path(r2["instance"]) / "mods").iterdir())
assert mods == ["modA.jar"], mods
print(" ★ 重复创建幂等(mod 不堆积)")
if __name__ == "__main__":
for fn in [test_mods_land_in_instance_not_content_root,
test_two_instances_share_content_root_different_mods,
test_instance_json_records_version_and_loader,
test_runtime_dir_is_third_layer,
test_unimplemented_loader_says_so,
test_missing_mod_files_are_named,
test_missing_server_jar_for_vanilla_is_reported,
test_bad_instance_name_rejected,
test_start_script_is_executable_and_self_contained,
test_repeated_creation_is_idempotent_report]:
fn()
print("\n加载器服务端:全部通过")