Files
MCM-ICM_Toolkit/tests/test_core.py
T

220 lines
8.3 KiB
Python
Raw Normal View History

"""core 包测试。
TODO(L0):TestValidate 的 check_* 实现后去掉 skip 标记(Result / Random / as_array 已完成)。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
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(), "共享生成器必须被推进"
2026-10-01 19:19:15 +08:00
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)
2026-10-01 19:19:15 +08:00
first = np.random.random() # noqa: NPY002 - 这里就是要验证旧全局被设上了
set_seed(7)
2026-10-01 19:19:15 +08:00
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]])
class TestAsArray:
"""`as_array` 是所有收数值函数的统一入口:白名单 + 转 ndarray + 报告原类型。"""
def test_reports_kind_for_every_supported_container(self):
from mcm_toolkit.core import as_array
df = pd.DataFrame({"a": [1.0, 2.0], "city": ["X", "Y"]})
series = pd.Series([1.0, 2.0], index=["u", "v"], name="s")
assert as_array(df)[1] == "frame"
assert as_array(series)[1] == "series"
for raw in (np.array([1.0, 2.0]), [1.0, 2.0], (1.0, 2.0)):
assert as_array(raw)[1] == "array"
def test_frame_keeps_only_numeric_columns_by_default(self):
# numeric_only 默认 True:一列数值都没有就报 ValueError(与 describe / zscore 同口径)
from mcm_toolkit.core import as_array
df = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0], "city": ["X", "Y"]})
arr, kind = as_array(df)
assert arr.shape == (2, 2) and kind == "frame"
assert list(df.select_dtypes(include=[np.number]).columns) == ["a", "b"]
with pytest.raises(ValueError, match="数值"):
as_array(pd.DataFrame({"city": ["X", "Y"]}))
def test_frame_with_text_and_numeric_only_false_raises(self):
# 元素级函数(normalize / standardize / log_transform)走 numeric_only=False,
# 因为跳过列会让返回的表少列、与输入对不上;转不动就该报错,不能静默丢列。
from mcm_toolkit.core import as_array
df = pd.DataFrame({"a": [1.0, 2.0], "city": ["X", "Y"]})
with pytest.raises(TypeError, match="转不成"):
as_array(df, numeric_only=False)
def test_default_never_flattens(self):
# 摊平曾是 t_test(DataFrame) 静默混合多列的根源,必须是显式行为
from mcm_toolkit.core import as_array
df = pd.DataFrame({"a": [1.0, 2.0, 3.0], "b": [4.0, 5.0, 6.0]})
assert as_array(df)[0].shape == (3, 2)
assert as_array(df, ndim=1)[0].shape == (6,)
assert as_array(pd.Series([1.0, 2.0, 3.0]), ndim=1)[0].shape == (3,)
def test_ndim_2_checks_instead_of_reshaping(self):
# 一维数据是"1 个样本"还是"1 个特征"没有唯一答案,不能替调用方猜
from mcm_toolkit.core import as_array
assert as_array([[1.0, 2.0]], ndim=2)[0].shape == (1, 2)
with pytest.raises(ValueError, match="二维"):
as_array([1.0, 2.0], ndim=2)
with pytest.raises(ValueError, match="ndim"):
as_array([1.0, 2.0], ndim=3)
def test_rejects_types_outside_the_whitelist(self):
from mcm_toolkit.core import as_array
# str 也是可迭代对象:不显式挡住,"abc" 会被当成三个字符算下去
for bad in ("abc", {1, 2}, {"a": 1}, None, object()):
with pytest.raises(TypeError):
as_array(bad)
def test_keeps_missing_values(self):
# 删缺失是各函数的**语义**(corr 成对删、t_test 配对要成对删),不能在这里做
from mcm_toolkit.core import as_array
arr = as_array([1.0, np.nan, 3.0])[0]
assert arr.shape == (3,) and np.isnan(arr[1])
def test_dtype_none_keeps_original_dtype(self):
from mcm_toolkit.core import as_array
assert as_array([1, 2, 3], dtype=None)[0].dtype.kind == "i"
assert as_array([1, 2, 3])[0].dtype.kind == "f"
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("> 样本太少")