diff --git a/backend/app/analysis/__init__.py b/backend/app/analysis/__init__.py index 895df813..200fc7f2 100644 --- a/backend/app/analysis/__init__.py +++ b/backend/app/analysis/__init__.py @@ -52,7 +52,8 @@ def _compute(op: str, mode: str | None, raw: Mapping[str, Any], env: Any) -> Ana from app.analysis import comparable, decompose, differ, series if op == "decompose": - return {"additive": decompose.additive, "ratio": decompose.ratio, "factor": decompose.factor}[mode](raw, env) # type: ignore[index] + modes = {"additive": decompose.additive, "ratio": decompose.ratio, "factor": decompose.factor} + return modes[mode](raw, env) # type: ignore[index] if op == "baseline": return series.baseline(raw, env) if op == "trend": diff --git a/backend/app/analysis/common.py b/backend/app/analysis/common.py index bb323774..3eb39865 100644 --- a/backend/app/analysis/common.py +++ b/backend/app/analysis/common.py @@ -128,9 +128,24 @@ def closes(total: Fraction, cell: Fraction, raw: Any) -> bool: return scale != 0 and gap <= scale * Fraction(1, 10 ** 9) +def column_types(columns: Sequence[str], *, dims: Mapping[str, str | None] | None = None, + texts: Sequence[str] = ()) -> dict[str, str]: + """结果表各列的类型(ResultTable.column_types,按列的先后):dims 里的列照给的类型(维度列照输入快照里那一列查询时记下的 + 类型,没记的为 None、不写,同快照「拿不准的列不在里面」);texts 里的是文字列(说明、叫法)写 text;其余都是数值列写 + number(格子是十进制字符串)。""" + out: dict[str, str] = {} + for column in columns: + if dims is not None and column in dims: + if dims[column]: + out[column] = dims[column] # type: ignore[assignment] + else: + out[column] = "text" if column in texts else "number" + return out + + def sign_word(value: Fraction | float | int) -> str | None: return "up" if value > 0 else "down" if value < 0 else None -__all__ = ["DERIVED_PARAMS", "Env", "build", "closes", "column_label", "label_for", "need", "refuse", "sign_word", - "today_param", "written_places"] +__all__ = ["DERIVED_PARAMS", "Env", "build", "closes", "column_label", "column_types", "label_for", "need", "refuse", + "sign_word", "today_param", "written_places"] diff --git a/backend/app/analysis/comparable.py b/backend/app/analysis/comparable.py index c34abfce..38a6bb9d 100644 --- a/backend/app/analysis/comparable.py +++ b/backend/app/analysis/comparable.py @@ -435,9 +435,11 @@ def table_of(outcome: Outcome) -> dict[str, Any]: """检查表(结论的 table):检查项、结果、基期、本期、说明。""" rows = [[CHECK_NAMES[c.id], STATUS_NAMES[c.status], c.base, c.target, ";".join(c.reasons) or None] for c in outcome.checks] - return {"columns": ["check", "status", "base", "target", "note"], + columns = ["check", "status", "base", "target", "note"] + return {"columns": columns, "labels": {"check": "检查项", "status": "结果", "base": "基期", "target": "本期", "note": "说明"}, - "rows": rows} + "rows": rows, + "column_types": dict.fromkeys(columns, "text")} def values_of(outcome: Outcome) -> dict[str, Any]: diff --git a/backend/app/analysis/contract.py b/backend/app/analysis/contract.py index 75b7af52..c52c1865 100644 --- a/backend/app/analysis/contract.py +++ b/backend/app/analysis/contract.py @@ -13,7 +13,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Mapping from datetime import date -from typing import Any, Literal, TypedDict +from typing import Any, Literal, NotRequired, TypedDict # -------------------------------------------------------------------------- # 名字(一种说法:别处一律引用这里,不另写字面量) @@ -184,7 +184,9 @@ class InputSnapshot(TypedDict, total=False): source: str # 数据源名 sql: str columns: list[str] - column_types: list[str | None] + # 查询时记下的列类型(number / text / date / datetime / time / boolean,拿不准的列不在里面)。tools 的 to_payload 给的是 + # {列: 类型}(data/engine.QueryResult.column_types);按列序排的列表也认(analysis/inputs.Snap.kind 两种都读) + column_types: dict[str, str] | list[str | None] rows: list[list[Any]] truncated: bool masked: list[str] # 不能当维度、度量的列(遮罩列、按 SQL 认出改了名字的遮罩列),比较时不分大小写 @@ -244,6 +246,9 @@ class ResultTable(TypedDict): columns: list[str] # 稳定的列 id(维度列照输入的列名,其余是 base、target、delta、share……) labels: dict[str, str] # 列 id → 给人看的列名(基期、本期、变化、占变化的比重……) rows: list[list[Any]] # 数一律十进制字符串或 None,维度值照原样 + # 列 id → 类型(写法同查询快照的 column_types;第 1 波 W1-a 加,可选):维度列照输入快照里那一列查询时记下的类型 + # (文本型的码值 "1203" 才不会被渲染成「1,203」;输入没记类型的不写),数值列 number,说明、叫法这类文字列 text + column_types: NotRequired[dict[str, str]] class AnalysisRequest(TypedDict, total=False): diff --git a/backend/app/analysis/decompose.py b/backend/app/analysis/decompose.py index f55224ce..0079d2a1 100644 --- a/backend/app/analysis/decompose.py +++ b/backend/app/analysis/decompose.py @@ -26,7 +26,8 @@ from typing import Any from app.analysis import comparable as cmp from app.analysis import phrases as ph -from app.analysis.common import Env, build, closes, column_label, label_for, need, refuse, sign_word, today_param +from app.analysis.common import (Env, build, closes, column_label, column_types, label_for, need, refuse, sign_word, + today_param) from app.analysis.contract import CallError from app.analysis.inputs import Snap, as_list, hashable, same_value from app.analysis.numbers import canon, to_fraction @@ -390,10 +391,12 @@ def _additive_result(env: Env, params: dict[str, Any], used: list[str], segs: li d = deltas[x.key] rows.append([*x.key, canon(x.base), canon(x.target), canon(d), canon(d / x.base) if x.base != 0 else None, canon(shares[x.key]) if shares else None, "、".join(x.flags) or None]) - table = {"columns": [*by, *_FIXED_ADDITIVE], + columns = [*by, *_FIXED_ADDITIVE] + table = {"columns": columns, "labels": {**dict(zip(by, dims)), "base": "基期", "target": "本期", "delta": "变化", "pct": "变化率", "share": "占变化的比重", "flag": "说明"}, - "rows": rows} + "rows": rows, + "column_types": column_types(columns, dims={c: side_t.snap.kind(c) for c in by}, texts=("flag",))} values: dict[str, Any] = { "base_total": canon(B), "target_total": canon(T), "delta_total": canon(delta), "pct_total": canon(delta / abs(B)) if B != 0 else None, "top_key": ph.key_text(top.key), @@ -472,7 +475,7 @@ def ratio(raw: Mapping[str, Any], env: Env) -> Any: ph.say(ph.key_text(x.key), f"{when}的", den_label, "为 0 而", num_label, "不为 0,比率拆不了"), criterion=criterion) return _ratio_result(env, params, used, segs, by, dims, label, num_label, den_label, heads, parent_row, outcome, - criterion) + criterion, target_snap=ts) def ratio_effects(segs: Sequence[tuple[Fraction, Fraction, Fraction, Fraction]] @@ -501,7 +504,7 @@ def ratio_effects(segs: Sequence[tuple[Fraction, Fraction, Fraction, Fraction]] def _ratio_result(env: Env, params: dict[str, Any], used: list[str], segs: list[Segment], by: list[str], dims: list[str], label: str, num_label: str, den_label: str, heads: Mapping[str, tuple[Fraction, Any]], parent_row: Mapping[str, Any] | None, - outcome: cmp.Outcome | None, criterion: str) -> Any: + outcome: cmp.Outcome | None, criterion: str, *, target_snap: Snap) -> Any: main_share, flat_pct = env.threshold("main_share"), env.threshold("flat_pct") min_den = env.threshold("min_denominator") live = [x for x in segs if x.base[1] != 0 or x.target[1] != 0] @@ -589,12 +592,14 @@ def _ratio_result(env: Env, params: dict[str, Any], used: list[str], segs: list[ for x in dropped: rows.append([*x.key, canon(x.base[0]), canon(x.target[0]), "0", "0", None, None, None, None, None, None, None, "两期分母都为 0,不计入"]) - table = {"columns": [*by, *_FIXED_RATIO], + columns = [*by, *_FIXED_RATIO] + table = {"columns": columns, "labels": {**dict(zip(by, dims)), "n_base": ph.say("基期", num_label), "n_target": ph.say("本期", num_label), "d_base": ph.say("基期", den_label), "d_target": ph.say("本期", den_label), "w_base": "基期权重", "w_target": "本期权重", "r_base": "基期比率", "r_target": "本期比率", "mix": "结构效应", "rate": "自身效应", "total": "合计影响", "flag": "说明"}, - "rows": rows} + "rows": rows, + "column_types": column_types(columns, dims={c: target_snap.kind(c) for c in by}, texts=("flag",))} values: dict[str, Any] = { "r_base_total": canon(R), "r_target_total": canon(R2), "delta": canon(delta), "mix_total": canon(mix_total), "rate_total": canon(rate_total), "mix_share": canon(mix_share), "rate_share": canon(rate_share), @@ -717,10 +722,12 @@ def factor(raw: Mapping[str, Any], env: Env) -> Any: b, t = cells["base"][i + 1][0], cells["target"][i + 1][0] rows.append([names[i], canon(b), canon(t), canon((t - b) / b), canon(contrib[i]), canon(shares[i]) if shares[i] is not None else None]) - table = {"columns": ["factor", "base", "target", "pct", "contrib", "share"], + columns = ["factor", "base", "target", "pct", "contrib", "share"] + table = {"columns": columns, "labels": {"factor": "因子", "base": "基期", "target": "本期", "pct": "变化率", "contrib": "贡献", "share": "占变化的比重"}, - "rows": rows} + "rows": rows, + "column_types": column_types(columns, texts=("factor",))} values = {"base_total": canon(M), "target_total": canon(M2), "delta_total": canon(delta), "pct_total": canon(delta / abs(M)), "top_key": names[top], "top_share": canon(shares[top]) if shares[top] is not None else None} @@ -729,4 +736,5 @@ def factor(raw: Mapping[str, Any], env: Env) -> Any: phrase=ph.with_caveats(main, caveats), caveats=caveats) -__all__ = ["HEADLINE_SHAPE", "PARENT_SHAPE", "SAME_QUERY", "SIMPSON_SHARE", "additive", "factor", "lmdi", "ratio", "ratio_effects"] +__all__ = ["HEADLINE_SHAPE", "PARENT_SHAPE", "SAME_QUERY", "SIMPSON_SHARE", "additive", "factor", "lmdi", "ratio", + "ratio_effects"] diff --git a/backend/app/analysis/differ.py b/backend/app/analysis/differ.py index 733ceb52..e8f839af 100644 --- a/backend/app/analysis/differ.py +++ b/backend/app/analysis/differ.py @@ -28,7 +28,7 @@ from typing import Any from app.analysis import phrases as ph from app.analysis import stats -from app.analysis.common import Env, build, column_label, label_for, need, refuse, sign_word +from app.analysis.common import Env, build, column_label, column_types, label_for, need, refuse, sign_word from app.analysis.contract import CallError from app.analysis.inputs import Snap, as_list, hashable, same_value from app.analysis.numbers import canon, to_fraction @@ -181,7 +181,7 @@ def differ(raw: Mapping[str, Any], env: Env) -> Any: pairs = _pairs(raw, params, groups, mode, dim_label, by_period, group_col) # type: ignore[arg-type] env.inputs.use(snap, "data", [group_col, *cols.values()], sorted({r for p in pairs for g in (p.a, p.b) for r in g.rows})) - return _decide(env, params, used, pairs, mode, label, criterion) # type: ignore[arg-type] + return _decide(env, params, used, pairs, mode, label, criterion, kind=snap.kind(group_col)) # type: ignore[arg-type] def _groups(snap: Snap, group_col: str, cols: Mapping[str, str], mode: str, by_period: bool, @@ -274,7 +274,7 @@ def _pairs(raw: Mapping[str, Any], params: dict[str, Any], groups: dict[Any, Gro def _decide(env: Env, params: dict[str, Any], used: list[str], pairs: list[Pair], mode: str, label: str, - criterion: str) -> Any: + criterion: str, *, kind: str | None) -> Any: alpha = env.threshold("differ_alpha") min_expected = env.threshold("min_expected") if mode == "rate" else Fraction(0) for pair in pairs: @@ -324,12 +324,16 @@ def _decide(env: Env, params: dict[str, Any], used: list[str], pairs: list[Pair] rows.append([p.a.name, p.b.name, canon(p.a_value), canon(p.b_value), canon(p.diff), canon(p.ci[0]) if p.ci else None, canon(p.ci[1]) if p.ci else None, str(p.a.n), str(p.b.n), TESTS.get(p.test or "") or None, canon(p.p), canon(p.p_adj), "、".join(flags) or None]) - table = {"columns": ["a", "b", "a_value", "b_value", "diff", "ci_lo", "ci_hi", "a_n", "b_n", "test", "p", "p_adj", - "flag"], + columns = ["a", "b", "a_value", "b_value", "diff", "ci_lo", "ci_hi", "a_n", "b_n", "test", "p", "p_adj", "flag"] + # 组、对照组照分组列(期次列)在快照里的类型;和「其余各组合计」比时对照组那一列有一格是叫法,写 text + rest = any(p.b.key == ("__rest__",) for p in pairs) + table = {"columns": columns, "labels": {"a": "组", "b": "对照组", "a_value": f"组的{value_name}", "b_value": f"对照组的{value_name}", "diff": "差", "ci_lo": "差的区间下限", "ci_hi": "差的区间上限", "a_n": "组的样本量", "b_n": "对照组的样本量", "test": "检验", "p": "p 值", "p_adj": "校正后的 p 值", "flag": "说明"}, - "rows": rows} + "rows": rows, + "column_types": column_types(columns, dims={"a": kind, "b": "text" if rest else kind}, + texts=("test", "flag"))} shown = head if head is not None else pairs[0] values = {"a": canon(shown.a_value), "b": canon(shown.b_value), "diff": canon(shown.diff), "ci_lo": canon(shown.ci[0]) if shown.ci else None, "ci_hi": canon(shown.ci[1]) if shown.ci else None, diff --git a/backend/app/analysis/phrases.py b/backend/app/analysis/phrases.py index 438416c9..5c7f2412 100644 --- a/backend/app/analysis/phrases.py +++ b/backend/app/analysis/phrases.py @@ -512,6 +512,6 @@ __all__ = [ "factor_driver", "key_text", "multiple_text", "names", "no_evidence_phrase", "null_periods_text", "null_segments_text", "number", "offset_text", "outlier_text", "pct", "pick_label", "points", "ratio_both", "ratio_change", "ratio_mix", "ratio_rate", "ratio_value", "record_text", "refused", "say", "share_words", - "simpson_text", "small_denominator_text", "threshold_pct", "small_sample_mean_text", "small_sample_rate_text", "trend_phrase", - "unconfirmed_text", "unlabeled_text", "with_caveats", + "simpson_text", "small_denominator_text", "small_sample_mean_text", "small_sample_rate_text", "threshold_pct", + "trend_phrase", "unconfirmed_text", "unlabeled_text", "with_caveats", ] diff --git a/backend/app/analysis/series.py b/backend/app/analysis/series.py index 37f022a0..e3c6c2ac 100644 --- a/backend/app/analysis/series.py +++ b/backend/app/analysis/series.py @@ -29,7 +29,7 @@ from app.analysis import comparable as cmp from app.analysis import periods as pd from app.analysis import phrases as ph from app.analysis import stats -from app.analysis.common import Env, build, column_label, label_for, need, refuse, sign_word, today_param +from app.analysis.common import Env, build, column_label, column_types, label_for, need, refuse, sign_word, today_param from app.analysis.contract import CallError from app.analysis.inputs import Snap, same_value from app.analysis.numbers import canon, to_fraction @@ -198,10 +198,13 @@ def baseline(raw: Mapping[str, Any], env: Env) -> Any: main = ph.baseline_phrase(code, series.label, current, n, unit, ph.band_text(lo, hi, basis=basis)) rows = [[p.raw, canon(lv), canon(h) if basis == "change" else None, "历史"] for p, h, lv in history] rows.append([target.raw, canon(x_level), canon(x) if basis == "change" else None, "本期"]) - table = {"columns": [series.period, "value", "change", "role"], + columns = [series.period, "value", "change", "role"] + table = {"columns": columns, "labels": {series.period: column_label(env, series.snap, series.period), "value": series.label, "change": "变化率", "role": "说明"}, - "rows": rows} + "rows": rows, + "column_types": column_types(columns, dims={series.period: series.snap.kind(series.period)}, + texts=("role",))} values = {"x": canon(x), "value": canon(x_level), "median": canon(m), "band_lo": canon(lo), "band_hi": canon(hi), "z": canon(z) if z is not None else None, "rank": str(stats.rank_desc(x, hv)), "n_history": str(n), "hist_min": canon(min(hv)), "hist_max": canon(max(hv))} @@ -285,10 +288,12 @@ def trend(raw: Mapping[str, Any], env: Env) -> Any: main = ph.trend_phrase(code, series.label, n, unit, per, slope_text, (run, direction) if run >= 3 and direction else None) # type: ignore[arg-type] rows = [[p_.raw, canon(v), canon(v - tail[i - 1][1]) if i else None] for i, (p_, v) in enumerate(tail)] - table = {"columns": [series.period, "value", "change"], + columns = [series.period, "value", "change"] + table = {"columns": columns, "labels": {series.period: column_label(env, series.snap, series.period), "value": series.label, "change": "比上一期"}, - "rows": rows} + "rows": rows, + "column_types": column_types(columns, dims={series.period: series.snap.kind(series.period)})} values = {"slope": canon(slope), "rel_slope": canon(rel) if rel is not None else None, "p": canon(mk.p), "s": str(mk.s), "n": str(n), "run_length": str(run), "run_direction": direction} verdict = {"code": code, "subjects": [], "direction": "up" if code == "rising" else "down" if code == "falling" diff --git a/backend/tests/test_analysis_comparable.py b/backend/tests/test_analysis_comparable.py index 5b9c514d..645c3d9d 100644 --- a/backend/tests/test_analysis_comparable.py +++ b/backend/tests/test_analysis_comparable.py @@ -35,6 +35,7 @@ async def test_two_weeks_that_differ_only_in_dates_are_comparable(): assert result["params"]["today"] == "2026-10-21" and result["params"]["measure"] == "gmv" assert [p["column"] for p in result["probes"]] == ["order_date"] assert result["table"]["columns"] == ["check", "status", "base", "target", "note"] + assert result["table"]["column_types"] == dict.fromkeys(result["table"]["columns"], "text") async def test_other_literals_differing_mean_two_segments_not_two_periods(): diff --git a/backend/tests/test_analysis_decompose.py b/backend/tests/test_analysis_decompose.py index 129c4b76..dbc31725 100644 --- a/backend/tests/test_analysis_decompose.py +++ b/backend/tests/test_analysis_decompose.py @@ -47,6 +47,8 @@ async def test_a_driver_is_named_with_its_share_and_the_whole_shape_is_filled(): table = result["table"] assert table["columns"] == ["region", "base", "target", "delta", "pct", "share", "flag"] assert table["labels"]["region"] == "地区" and table["labels"]["share"] == "占变化的比重" + assert table["column_types"] == {"region": "text", "base": "number", "target": "number", "delta": "number", + "pct": "number", "share": "number", "flag": "text"} assert [r[0] for r in table["rows"]] == ["华东", "华北", "华南", "西南"] # 按变化的绝对值从大到小 assert result["settings"] == {"main_share": 0.5, "concentration": 0.8, "offset_ratio": 0.25, "flat_pct": 0.005, "table_top_k": 10} @@ -418,3 +420,31 @@ async def test_the_same_query_cannot_be_both_periods(): assert error.code == "same_query" error = await call_error("comparable", snaps=[weekly()], base_query="Q2", target_query="Q2") assert error.code == "same_query" + + +async def test_dimension_columns_keep_the_type_recorded_at_query_time(): + """文本型的码值(门店编码 "1203")在结果表里仍是 text,渲染时不会写成「1,203」;查询时没记类型的维度列不写。""" + sql = "SELECT store_code, week, SUM(amount) AS gmv FROM orders GROUP BY 1, 2" + rows = [["1203", "W1", 500], ["1203", "W2", 300], ["0871", "W1", 200], ["0871", "W2", 260]] + typed = snap("Q2", sql, ["store_code", "week", "gmv"], rows, + types={"store_code": "text", "week": "text", "gmv": "number"}) + result = await analyze("decompose", "additive", snaps=[typed], query="Q2", by="store_code", period="week", + base="W1", target="W2", measure="gmv") + assert result["table"]["column_types"]["store_code"] == "text" + assert [r[0] for r in result["table"]["rows"]] == ["1203", "0871"] + untyped = snap("Q2", sql, ["store_code", "week", "gmv"], rows) + result = await analyze("decompose", "additive", snaps=[untyped], query="Q2", by="store_code", period="week", + base="W1", target="W2", measure="gmv") + assert "store_code" not in result["table"]["column_types"] + assert result["table"]["column_types"]["delta"] == "number" + listed = snap("Q2", sql, ["store_code", "week", "gmv"], rows, types=None, column_types=["text", "text", "number"]) + result = await analyze("decompose", "additive", snaps=[listed], query="Q2", by="store_code", period="week", + base="W1", target="W2", measure="gmv") + assert result["table"]["column_types"]["store_code"] == "text" # 按列序的列表也认 + ratio_result = await ratio([["线上", "W41", 50, 1000], ["线上", "W42", 80, 1000], ["线下", "W41", 20, 1000], + ["线下", "W42", 26, 1000]]) + ratio_types = ratio_result["table"]["column_types"] + assert ratio_types["flag"] == "text" and ratio_types["mix"] == "number" and "channel" not in ratio_types + factor_result = await factor([["W41", "1000", 100, "10"], ["W42", "1320", 120, "11"]]) + assert factor_result["table"]["column_types"] == {"factor": "text", "base": "number", "target": "number", + "pct": "number", "contrib": "number", "share": "number"} diff --git a/backend/tests/test_analysis_differ.py b/backend/tests/test_analysis_differ.py index 5f7e10c0..f07d4e6c 100644 --- a/backend/tests/test_analysis_differ.py +++ b/backend/tests/test_analysis_differ.py @@ -53,6 +53,9 @@ async def test_several_groups_each_against_the_rest_with_holm(): table = result["table"] assert [r[0] for r in table["rows"]] == ["西南", "华东", "华南", "华北"] assert table["rows"][0][1] == "其余各地区" + # 对照组那一列有「其余各地区」这样的叫法:写 text;没记类型的分组列(组那一列)不写 + assert table["column_types"]["b"] == "text" and "a" not in table["column_types"] + assert table["column_types"]["p"] == "number" and table["column_types"]["test"] == "text" raw = [Fraction(r[10]) for r in table["rows"]] adjusted = [Fraction(r[11]) for r in table["rows"]] assert all(a >= p for a, p in zip(adjusted, raw)) diff --git a/backend/tests/test_analysis_run.py b/backend/tests/test_analysis_run.py index 6baa0ccb..f4207208 100644 --- a/backend/tests/test_analysis_run.py +++ b/backend/tests/test_analysis_run.py @@ -57,7 +57,10 @@ def _assert_shape(result): assert all(isinstance(s, list) and all(isinstance(x, str) for x in s) for s in result["verdict"]["subjects"]) assert result["verdict"]["direction"] in (None, "up", "down") table = result["table"] - assert set(table) == {"columns", "labels", "rows"} and set(table["labels"]) == set(table["columns"]) + assert set(table) == {"columns", "labels", "rows", "column_types"} + assert set(table["labels"]) == set(table["columns"]) and set(table["column_types"]) <= set(table["columns"]) + assert list(table["column_types"]) == [c for c in table["columns"] if c in table["column_types"]] + assert set(table["column_types"].values()) <= {"number", "text", "date", "datetime", "time", "boolean"} assert all(len(r) == len(table["columns"]) for r in table["rows"]) assert all(v is None or isinstance(v, str) for v in result["values"].values()) assert all(set(cv) == {"code", "text"} and cv["code"] in c.CAVEATS for cv in result["caveats"]) diff --git a/backend/tests/test_analysis_series.py b/backend/tests/test_analysis_series.py index b09c5fed..4f1b8dcf 100644 --- a/backend/tests/test_analysis_series.py +++ b/backend/tests/test_analysis_series.py @@ -56,6 +56,7 @@ async def test_level_basis_with_hand_computed_median_and_mad(): assert result["params"]["grain"] == "seq" and result["params"]["lag"] == 1 assert result["settings"] == {"k_warn": 2.0, "k_alert": 3.0, "min_history": 8, "max_history": 52} assert result["table"]["rows"][-1][-1] == "本期" + assert result["table"]["column_types"] == {"week": "text", "value": "number", "change": "number", "role": "text"} async def test_change_basis_within_outside_and_far_outside(): @@ -167,6 +168,7 @@ async def test_rising_and_falling_trends_with_run_length(): assert rising["phrase"].startswith("过去 12 周线上销售额呈上升趋势(每周约 +") assert ",已连续 11 周上升" in rising["phrase"] assert rising["settings"] == {"trend_min_points": 6, "trend_alpha": 0.05, "max_history": 52} + assert rising["table"]["column_types"] == {"week": "text", "value": "number", "change": "number"} falling = await trend(weeks([200, 196, 197, 190, 185, 186, 180, 181, 175, 170])) assert falling["verdict"]["code"] == "falling" assert falling["values"]["run_length"] == "2" and "已连续" not in falling["phrase"]