get_rng 的"共享单例生成器"设计由用户本人提出,本提交只在其上修 bug。 - random_state 判断从 isinstance(x, int) 放宽到 (int, np.integer): np.int64 不是 int 的子类,之前会静默退回全局流、种子被无声忽略 - 非预期类型改成抛 TypeError(含 bool / float / str / list),不再静默降级 - get_rng 签名补上 np.integer 与 Generator,与 docstring 的承诺一致 - set_seed 同时调用 np.random.seed:sklearn / statsmodels 走的是 numpy 旧全局状态,不覆盖它们"提交能重跑"就有洞(带 noqa: NPY002 与理由) - 测试:TestRandom 由 4 条增到 7 条(np.integer、非法类型、共享生成器推进) - 新增 docs/dev/personal-pitfalls.md:个人易错点清单,只追加、不通读, 首批记录本轮 4 个点;docs/README.md 补上 05 与该文件的索引 - STATUS.md:L0 进度说明更新,决策 24 改写(共享单例)、新增决策 25 (src/ 实现默认由用户本人写)
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
"""core 包测试。
|
||
|
||
TODO(L0):TestValidate 实现后去掉 skip 标记(Result 与 Random 已完成)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import numpy as np
|
||
import pytest
|
||
|
||
|
||
class TestRandom:
|
||
def test_get_rng_is_reproducible(self):
|
||
from mcm_toolkit.core import get_rng
|
||
|
||
assert get_rng(42).random() == get_rng(42).random()
|
||
|
||
def test_get_rng_passes_through_generator(self):
|
||
from mcm_toolkit.core import get_rng
|
||
|
||
g = np.random.default_rng(0)
|
||
assert get_rng(g) is g
|
||
|
||
def test_get_rng_accepts_numpy_integer(self):
|
||
"""np.int64 不是 int 的子类,必须当成整数处理,不能静默退化成全局流。"""
|
||
from mcm_toolkit.core import get_rng, set_seed
|
||
|
||
set_seed(1)
|
||
first = get_rng(np.int64(42))
|
||
second = get_rng(np.int64(42))
|
||
assert first is not second, "传 np.integer 应当新建独立生成器"
|
||
assert first.random() == np.random.default_rng(42).random()
|
||
|
||
def test_get_rng_rejects_bad_types(self):
|
||
"""类型不对就报错。静默当成"没传种子",换来的只会是不可复现的结果。"""
|
||
from mcm_toolkit.core import get_rng
|
||
|
||
for bad in ["42", 42.0, True, [42]]:
|
||
with pytest.raises(TypeError):
|
||
get_rng(bad)
|
||
|
||
def test_get_rng_without_seed_shares_and_advances(self):
|
||
from mcm_toolkit.core import get_rng, set_seed
|
||
|
||
set_seed(3)
|
||
assert get_rng() is get_rng(), "无参调用应返回同一个共享生成器"
|
||
assert get_rng().random() != get_rng().random(), "共享生成器必须被推进"
|
||
|
||
def test_get_rng_default_follows_global_seed(self):
|
||
"""set_seed 之后,不传 random_state 也必须复现——这是"论文能重跑"的底线。"""
|
||
from mcm_toolkit.core import get_rng, set_seed
|
||
|
||
set_seed(7)
|
||
first = get_rng().random()
|
||
set_seed(7)
|
||
second = get_rng().random()
|
||
assert first == second
|
||
|
||
def test_set_seed_also_covers_third_party_global_state(self):
|
||
"""sklearn / statsmodels 走的是 np.random 旧全局,set_seed 要连它一起覆盖。"""
|
||
from mcm_toolkit.core import set_seed
|
||
|
||
set_seed(7)
|
||
first = np.random.random() # noqa: NPY002 - 这里就是要验证旧全局被设上了
|
||
set_seed(7)
|
||
second = np.random.random() # noqa: NPY002
|
||
assert first == second
|
||
|
||
|
||
@pytest.mark.skip(reason="未实现:见 docs/03-roadmap.md L0")
|
||
class TestValidate:
|
||
def test_check_same_length_raises_with_detail(self):
|
||
from mcm_toolkit.core import check_same_length
|
||
|
||
with pytest.raises(ValueError, match=r"2.*3|长度"):
|
||
check_same_length([1, 2], [1, 2, 3])
|
||
|
||
def test_check_array_rejects_nan(self):
|
||
from mcm_toolkit.core import check_array
|
||
|
||
with pytest.raises(ValueError):
|
||
check_array([1.0, np.nan, 2.0], finite=True)
|
||
|
||
def test_check_probability_bounds(self):
|
||
from mcm_toolkit.core import check_probability
|
||
|
||
assert check_probability(0.5) == 0.5
|
||
with pytest.raises(ValueError):
|
||
check_probability(1.5)
|
||
|
||
def test_check_square_matrix(self):
|
||
from mcm_toolkit.core import check_square_matrix
|
||
|
||
check_square_matrix([[1, 2], [3, 4]])
|
||
with pytest.raises(ValueError):
|
||
check_square_matrix([[1, 2, 3], [4, 5, 6]])
|
||
|
||
|
||
def test_result_repr_is_readable():
|
||
from mcm_toolkit.core import Result
|
||
|
||
text = repr(Result("demo", params={"a": 1}, metrics={"r2": 0.9}))
|
||
# 只断言关键信息在场,不断言完整字符串——否则以后调格式会让测试集体变红
|
||
assert "demo" in text and "r2" in text
|
||
|
||
|
||
def test_result_repr_stays_short_and_safe():
|
||
"""默认值、失败结果、十万级数组:repr 都不能崩,也不能刷屏。"""
|
||
from mcm_toolkit.core import Result
|
||
|
||
assert "empty" in repr(Result("empty"))
|
||
|
||
failed = repr(Result("boom", success=False, message="求解未收敛"))
|
||
assert "success=False" in failed
|
||
assert "求解未收敛" in failed
|
||
|
||
noisy = repr(Result("mc", data=np.arange(100_000)))
|
||
assert "ndarray(100000)" in noisy
|
||
assert len(noisy) < 200, "data 不应被整个打印出来"
|
||
|
||
|
||
def test_result_summary_is_markdown_table():
|
||
from mcm_toolkit.core import Result
|
||
|
||
result = Result("topsis", params={"n": 3}, metrics={"best": 0.83})
|
||
text = result.summary()
|
||
lines = text.splitlines()
|
||
|
||
assert lines[0] == "### topsis"
|
||
assert lines[2] == "| 项 | 值 |"
|
||
assert "| success | True |" in lines
|
||
assert "| params.n | 3 |" in lines
|
||
assert "| metrics.best | 0.83 |" in lines
|
||
assert result.summary() == text, "summary 必须无副作用、可重复调用"
|
||
|
||
|
||
def test_result_summary_appends_message_on_failure():
|
||
from mcm_toolkit.core import Result
|
||
|
||
text = Result("gm11", success=False, message="样本太少").summary()
|
||
assert "| success | False |" in text
|
||
assert text.rstrip().endswith("> 样本太少")
|