"""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(), "共享生成器必须被推进" 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]]) 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("> 样本太少")