数字推演复核修正(第 3 轮):决定取哪一格的容器不再给可勾的行段,包着它的引用给了新值时不再说不准

- cell(nodes.q, len(vars.res.rows) - 5, …) 里的 vars.res.rows 这种决定取哪一格的数组字段、整份结果,第 2 轮起排除它的行
  只回「说不准」,可 row_span 照给:界面让人勾 10 行,勾了推演不出来,「不支持推演的输入」里又说它不能用。现在这种容器
  不给行段;决定取哪一格的单格照给(勾掉它确定是排除后无法计算)。前端 row_span 的类型说明跟着补上这一条
- 第 2 轮加的「说不准」不看包着它的引用有没有给新值:那一格改成 100、再排除别的行,以前能算出 100,第 2 轮起回说不准。
  _refs 现在记下每个嵌在里面的引用归哪个最外层引用(嵌几层都记最外层);最外层引用都给了新值时,只嵌在里面的输入无关
  紧要(_Plan.moot):它那一行排除了不再报排除后无法计算,对不上快照的也不再说不准。它自己也是最外层引用的照常拿值,
  只是取哪一格不再说不准
- 补测试:选取用的容器不给行段、只有那一格那一行勾了有确定结果(单元和真跑);那一格给了新值后排除任何行都照常算;
  既数行数又选取的容器;嵌在下标里的单格、对不上快照的下标(含嵌两层)、两个引用共用一个下标要都给新值;碰到了却没变
  时 maybe_changed 为假、下游口径卡不标(单元和真跑);嵌在下标里的遮罩格让整个指标推演不了(BLOCKED 加一例);读不出来
  的快照上嵌在里面的那一格不给行段(原先守它的测试被这次的选取判断盖住了)
- 逐个做过变异,全部变红:选取容器给行段、moot 不过滤、说不准不看 settled、_doubt 不看 moot、只记直接上一层、settled
  用 any、moot 含最外层引用,以及复核点名的 maybe_changed 只看碰没碰到、遮罩只看最外层。第 2 轮的两份变异单重放后也都
  变红,只有「row_span 只看快照读不读得出」成了等价变异:完整值找不回来的容器在最外层时整个指标推演不了,嵌在里面时
  又是选取用的,两头都已不给行段
This commit is contained in:
ThearchyHelios
2026-10-05 07:37:02 +08:00
parent 4debcf2ef3
commit c8d0e5d18b
4 changed files with 217 additions and 39 deletions
+53 -29
View File
@@ -25,7 +25,10 @@ round(和 metrics.run_metrics 一样只 round 浮点),渲染走 evidence.r
别的原样交回。排除行时有两种例外,都只能说不知道(unsupported),不能报「无变化」:
- 指标的某个输入对不上任何快照(完整值找不回来的容器、加工过的结果、按位置从结果里取的一格、由查询结果算出来的数),
它可能就是从被排除了行的那份结果来的,重算不了。写它的节点在那份快照交回之前就跑完了的,影响不到它,照常不算
- 决定取哪一格的容器(cell(nodes.q, len(vars.res.rows) - 1, …) 里的 vars.res.rows)少了几行:取到的是哪一格说不准
- 决定取哪一格的容器(cell(nodes.q, len(vars.res.rows) - 1, …) 里的 vars.res.rows)少了几行:取到的是哪一格说不准。
这种容器也就不给可勾的行段(勾了只能说不知道);决定取哪一格的单格照给,勾掉它确定是排除后无法计算
给了新值的以新值为准:只嵌在给了新值的引用里的输入(cell(…) 那一格给了新值,它行号里的 vars.res.rows)取到哪一格
已无关紧要,它那一行排除了、它说不准受不受影响都不碍事(_Plan.moot)。
"""
from __future__ import annotations
@@ -166,6 +169,18 @@ class _Plan:
tops: list[str] = field(default_factory=list)
inputs: dict[str, _Input] = field(default_factory=dict)
problem: str | None = None
#: 嵌在别的引用里的输入 → 包着它的最外层引用(_refs)
within: dict[str, set[str]] = field(default_factory=dict)
def settled(self, path: str, changes: Changes) -> bool:
"""包着这个输入的最外层引用都给了新值:它决定取哪一格已无关紧要,那几个引用以新值为准。"""
outer = self.within.get(path)
return bool(outer) and all(top in changes.inputs for top in outer)
def moot(self, path: str, changes: Changes) -> bool:
"""这个输入这次用不上:只嵌在别的引用里,包着它的又都给了新值。它那一行排除了、它说不准受不受影响,都碍不着
重算。它自己也是最外层引用的不算:那一处照常拿它的值。"""
return path not in self.tops and self.settled(path, changes)
@property
def blocked(self) -> tuple[str, str] | None:
@@ -234,50 +249,54 @@ def _plan(metric: dict[str, Any], store: _Store) -> _Plan:
tree, _, _ = parse_expression(expr)
except ExpressionError as e:
return _Plan(metric, problem=f"表达式有误({e})")
tops, nested = _refs(tree)
remaining = _remaining(metric.get("substituted"), [*tops, *nested])
tops, within = _refs(tree)
remaining = _remaining(metric.get("substituted"), [*tops, *within])
inputs: dict[str, _Input] = {}
for item in items:
if isinstance(item, dict) and isinstance(item.get("path"), str) and item["path"] not in inputs:
inputs[item["path"]] = _restore(item, remaining, nested, store)
plan = _Plan(metric, tree, tops, inputs)
inputs[item["path"]] = _restore(item, remaining, set(within), store)
plan = _Plan(metric, tree, tops, inputs, within=within)
if lost := [p for p in tops if p not in inputs]:
plan.problem = f"口径卡缺少输入 {'、'.join(lost[:3])} 的记录,不能推演"
return plan
def _refs(tree: ast.AST) -> tuple[list[str], set[str]]:
"""表达式引用的值:(最外层的引用, 嵌在别的引用里面的)。和 leaf_refs 按同一套规则认引用,只多分一下里外:
vars.rows[vars.i] 里的 vars.i、cell(nodes.q, vars.r, 'gmv') 里的 vars.r 是嵌在里面的,它们决定取哪一格。"""
def _refs(tree: ast.AST) -> tuple[list[str], dict[str, set[str]]]:
"""表达式引用的值:(最外层的引用, 嵌在别的引用里面的 → 包着它的最外层引用)。和 leaf_refs 按同一套规则认引用,
只多分一下里外:vars.rows[vars.i] 里的 vars.i、cell(nodes.q, vars.r, 'gmv') 里的 vars.r 是嵌在里面的,它们决定
取哪一格。嵌了几层的也记最外层那个:重算时只有最外层的引用拿值(_evaluate),给了新值的就是它们。"""
top: list[str] = []
inner: set[str] = set()
within: dict[str, set[str]] = {}
def add(node: ast.AST, nested: bool) -> None:
def add(node: ast.AST, outer: str | None) -> str:
"""记下这个引用,交回它里面的引用归谁:已经嵌在里面的归原来那个最外层引用,最外层的归它自己。"""
path = ast.unparse(node)
if nested:
inner.add(path)
elif path not in top:
if outer is not None:
within.setdefault(path, set()).add(outer)
return outer
if path not in top:
top.append(path)
return path
def visit(node: ast.AST, nested: bool) -> None:
def visit(node: ast.AST, outer: str | None) -> None:
if _cell_call(node):
add(node, nested)
owner = add(node, outer)
for arg in node.args[1:]:
visit(arg, True)
visit(arg, owner)
return
if isinstance(node, (ast.Attribute, ast.Subscript, ast.Name)) and _chain_root(node):
add(node, nested)
owner = add(node, outer)
cur: ast.AST = node
while isinstance(cur, (ast.Attribute, ast.Subscript)):
if isinstance(cur, ast.Subscript):
visit(cur.slice, True)
visit(cur.slice, owner)
cur = cur.value
return
for child in ast.iter_child_nodes(node):
visit(child, nested)
visit(child, outer)
visit(tree, False)
return top, inner
visit(tree, None)
return top, within
def _remaining(substituted: Any, paths: list[str]) -> set[str]:
@@ -556,13 +575,13 @@ def _doubt(plan: _Plan, changes: Changes, sources: Sources) -> str | None:
数组字段的完整值找不回来时也知道它用到哪几行,排除了那几行就算碰到了它(指标因它还原不了而推演不了)。
别的输入——完整值找不回来的容器、对不上任何快照的容器(加工过的结果)、不是直接取自快照的数(按位置从整份结果里
取的一格、transform 由查询结果算出来的数)——都可能来自被排除了行的那份结果,又重算不了,只能说不知道,不能拿
原值算出一个「无变化」或者一个不对应任何一份数据的数。给了新值的以新值为准;写它的节点在那份快照交回之前就跑完了
的,影响不到它(_after)。
原值算出一个「无变化」或者一个不对应任何一份数据的数。给了新值的以新值为准,只嵌在给了新值的引用里的也就无关紧要
(_Plan.moot);写它的节点在那份快照交回之前就跑完了的,影响不到它(_after)。
"""
if not changes.exclude:
return None
for path, inp in plan.inputs.items():
if path in changes.inputs or inp.snapshot is not None:
if path in changes.inputs or inp.snapshot is not None or plan.moot(path, changes):
continue
if any(_after(snapshot, inp, sources) for snapshot in changes.exclude):
why = inp.problem or (DOUBT_UNMATCHED if inp.shape == "container" else DOUBT_DERIVED)
@@ -582,14 +601,16 @@ def _after(snapshot: str, inp: _Input, sources: Sources) -> bool:
def _compute(plan: _Plan, values: dict[str, Any], notes: dict[str, list[int]], changes: Changes,
doubt: str | None) -> tuple[str, Any, str | None]:
"""(status, 新值, 原因)。整个推演不了的(_Plan.blocked)到不了这里。排除后缺了的、说不准受不受影响的先说,
都没有才求值。"""
hit = [(path, inp) for path, inp in plan.inputs.items() if path in notes and path not in changes.inputs]
都没有才求值。只嵌在给了新值的引用里的输入(_Plan.moot)不论排除了什么都不碍事:那几个引用以新值为准。"""
hit = [(path, inp) for path, inp in plan.inputs.items()
if path in notes and path not in changes.inputs and not plan.moot(path, changes)]
for path, inp in hit:
if inp.row is not None:
return "excluded", None, f"{EXCLUDED}:{path} 所在的第 {inp.row + 1} 行已排除"
for path, inp in hit:
if inp.selector:
# 嵌在下标、cell() 行列里的容器少了几行:外面那个引用取到的是哪一格跟着变,卡上记的值不再作数
if inp.selector and not plan.settled(path, changes):
# 嵌在下标、cell() 行列里的容器少了几行:外面那个引用取到的是哪一格跟着变,卡上记的值不再作数。
# 包着它的引用都给了新值时,它只剩自己作为最外层引用的那一处(排除了哪几行是确定的),照常算
return "unsupported", None, DOUBT.format(path=path, why=DOUBT_SELECTOR)
if doubt:
return "unsupported", None, doubt
@@ -741,11 +762,14 @@ def _editable(inp: _Input, *, blocked: bool) -> bool:
def _row_span(inp: _Input) -> tuple[int, int] | None:
"""这个输入实际用到的快照行(含两端):单格一行、数组字段一段、整份结果全部。排除别的行影响不到它,界面据此只让
这几行可勾。遮罩的、还原不了的(完整值找不回来、快照读不出来,都记在 problem 里)不给:排除它们的行什么也推演
不出来。"""
不出来。决定取哪一格的数组字段、整份结果也不给:少了几行,外面那个引用取到哪一格说不准(_compute),勾了只能
说不知道,「不支持推演的输入」里又说它不能用。决定取哪一格的单格照给:勾掉它,确定是排除后无法计算。"""
if inp.snapshot is None or inp.masked or inp.problem is not None:
return None
if inp.row is not None:
return inp.row, inp.row
if inp.selector:
return None
if inp.rows is not None:
return inp.rows
rows = inp.value.get("rows") if isinstance(inp.value, dict) else inp.value
+121 -9
View File
@@ -6,7 +6,7 @@
- 容器输入的还原:agent 数组字段(按 locator.rows 去掉被排除的行)、nodes.* 从节点产出、vars.* 只认 value_artifact
- 遮罩列取来的输入既不给值、也不能拿来推演
- 整个指标推演不了的(遮罩列、还原不了的输入、读不出来的快照、没记输入),不改动也照实说,输入一个都不让动
- 排除了行却说不准受不受影响的指标,不报「无变化」
- 排除了行却说不准受不受影响的指标,不报「无变化」,也不给勾了只能说不准的行段;给了新值的以新值为准
- 不合法的请求抛 WhatIfError(接口回 422),说清哪里不对
"""
from __future__ import annotations
@@ -484,15 +484,118 @@ def test_only_inputs_written_after_the_snapshot_came_back_are_doubted(finished,
assert (mixed["status"], mixed["changed"]) == (("unsupported", None) if doubted else ("unchanged", False))
#: cell() 的行号由整份结果算出:取最后一行
LAST_CELL = "cell(nodes.pull, len(vars.res.rows) - 1, 'amount')"
LAST = lone("last", LAST_CELL, "40.0", 40.0, [
{**cell("pull", 3, "amount", 40.0), "path": LAST_CELL},
{**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}], decimals=1)
def test_a_container_that_picks_the_cell_is_doubted_when_its_rows_are_excluded():
"""cell() 的行号由整份结果算出(取最后一行):排除了别的行,行号跟着变,取到的是哪一格说不准,不能报「无变化」。"""
path = "cell(nodes.pull, len(vars.res.rows) - 1, 'amount')"
card = lone("last", path, "40.0", 40.0, [
{**cell("pull", 3, "amount", 40.0), "path": path},
{**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}], decimals=1)
assert recompute(card["metrics"][0], Changes(), sources())["new"] == 40.0
[last] = whatif(card, Changes(exclude={S1: frozenset({1})}), sources())["metrics"]
assert last["status"] == "unsupported" and last["changed"] is None and "vars.res.rows" in last["reason"]
"""排除了别的行,行号跟着变,取到的是哪一格说不准,不能报「无变化」。整份结果因此不给行段:勾了也推演不出来,
「不支持推演的输入」里又说它不能用。那一格自己那一行照给:勾掉它,确定是排除后无法计算。"""
assert recompute(LAST["metrics"][0], Changes(), sources())["new"] == 40.0
baseline = whatif(LAST, Changes(), sources())
assert [(i["path"], i.get("row_span")) for i in baseline["metrics"][0]["inputs"]] == [
(LAST_CELL, [3, 3]), ("vars.res.rows", None)]
assert [u["input"] for u in baseline["unsupported"]] == ["vars.res.rows"]
assert "取哪" in baseline["unsupported"][0]["reason"]
for row in (0, 1, 2):
[last] = whatif(LAST, Changes(exclude={S1: frozenset({row})}), sources())["metrics"]
assert last["status"] == "unsupported" and last["changed"] is None and "vars.res.rows" in last["reason"]
[last] = whatif(LAST, Changes(exclude={S1: frozenset({3})}), sources())["metrics"]
assert last["status"] == "excluded" and "第 4 行" in last["reason"]
@pytest.mark.parametrize("rows", [{1}, {3}, {0, 1, 2, 3}])
def test_an_explicit_value_on_the_picked_cell_settles_the_container_that_picks_it(rows):
"""那一格给了新值:取到的是哪一格已无关紧要,以新值为准。排除整份结果里的行(哪怕连它自己那一行)照常算。"""
[last] = whatif(LAST, Changes(inputs={LAST_CELL: 100}, exclude={S1: frozenset(rows)}), sources())["metrics"]
assert (last["status"], last["new"], last["changed"]) == ("ok", 100, True), last
def test_a_container_that_is_also_counted_keeps_only_the_count_once_the_cell_has_a_new_value():
"""整份结果既拿来数行数、又决定取哪一格:那一格给了新值,它只剩数行数的那一处,排除了哪几行是确定的,照常算。"""
card = lone("both", f"len(vars.res.rows) + {LAST_CELL}", "len(vars.res.rows) + 40.0", 44.0, [
{**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS},
{**cell("pull", 3, "amount", 40.0), "path": LAST_CELL}], decimals=1)
assert recompute(card["metrics"][0], Changes(), sources())["new"] == 44.0
[both] = whatif(card, Changes(exclude={S1: frozenset({1})}), sources())["metrics"]
assert both["status"] == "unsupported" and "vars.res.rows" in both["reason"]
[both] = whatif(card, Changes(inputs={LAST_CELL: 100}, exclude={S1: frozenset({1})}), sources())["metrics"]
assert (both["status"], both["new"]) == ("ok", 3 + 100), both
#: 按第 2 行的区域取费率:费率写在查询之前(fetch),区域那一格决定取哪一项
FEE = "vars.kpi.rates[cell(nodes.pull, 1, 'region')]"
REGION = "cell(nodes.pull, 1, 'region')"
@pytest.mark.parametrize("expression, substituted, value, after", [
# 区域那一格只用来取费率:费率给了新值,它就无关紧要
(f"{FEE} * 100", "0.05 * 100", 5.0, ("ok", 8.0)),
# 区域那一格另外还拿来数字数:那一处照常拿它的值,它那一行排除了照样算不出来
(f"len({REGION}) + {FEE} * 100", "len('华南') + 0.05 * 100", 7.0, ("excluded", None)),
])
def test_a_cell_that_picks_the_item_is_moot_once_the_item_has_a_new_value(expression, substituted, value, after):
"""区域那一格所在的行排除了:取哪一项不知道,排除后无法计算。费率给了新值:取哪一项已无关紧要,照常算——
那一格另外还直接参与计算的除外。"""
card = lone("fee", expression, substituted, value, [scalar(FEE, 0.05), cell("pull", 1, "region", "华南")],
decimals=1)
timed = sources(returned={S1: 10}, finished={"fetch": 5})
assert recompute(card["metrics"][0], Changes(), timed)["new"] == value
[fee] = whatif(card, Changes(exclude={S1: frozenset({1})}), timed)["metrics"]
assert fee["status"] == "excluded" and "第 2 行" in fee["reason"]
[fee] = whatif(card, Changes(inputs={FEE: 0.08}, exclude={S1: frozenset({1})}), timed)["metrics"]
assert (fee["status"], fee["new"]) == after, fee
@pytest.mark.parametrize("path, middle", [
("vars.kpi.rates[vars.kpi.region]", []),
# 嵌了两层:区域名先换成区域代码再取费率,包着它的最外层引用照样是费率
("vars.kpi.rates[vars.kpi.codes[vars.kpi.region]]", [scalar("vars.kpi.codes[vars.kpi.region]", "S")]),
])
def test_an_index_that_would_be_doubted_is_moot_once_the_item_has_a_new_value(path, middle):
"""取哪一项由区域名决定,区域名是查询交回之后才算出来的(对不上快照):排除行后取到的是哪一项说不准。费率给了
新值:区域名受不受影响已无关紧要,按剩下的行照常算。"""
card = lone("fee2", f"round({path} * sum(vars.kpi.amounts), 1)", "round(0.05 * sum(vars.kpi.amounts), 1)", 17.0,
[scalar(path, 0.05), *middle, scalar("vars.kpi.region", "华南", node="label"), AMOUNTS], decimals=1)
timed = sources(returned={S1: 10}, finished={"fetch": 5, "label": 12})
assert recompute(card["metrics"][0], Changes(), timed)["new"] == 17.0
[fee] = whatif(card, Changes(exclude={S1: frozenset({1})}), timed)["metrics"]
assert fee["status"] == "unsupported" and "排除行对 vars.kpi.region 的影响无法确定" in fee["reason"]
[fee] = whatif(card, Changes(inputs={path: 0.1}, exclude={S1: frozenset({1})}), timed)["metrics"]
assert (fee["status"], fee["new"]) == ("ok", round(0.1 * (340.0 - 80.0), 1)) == ("ok", 26.0), fee
def test_an_index_shared_by_two_items_is_moot_only_when_both_have_new_values():
"""费率和固定费都按区域名取:只给费率新值,固定费取到哪一项照样说不准;两样都给了才无关紧要。"""
rate, fixed = "vars.kpi.rates[vars.kpi.region]", "vars.kpi.fixed[vars.kpi.region]"
card = lone("fee4", f"round({rate} * sum(vars.kpi.amounts) + {fixed}, 1)",
"round(0.05 * sum(vars.kpi.amounts) + 3, 1)", 20.0,
[scalar(rate, 0.05), scalar("vars.kpi.region", "华南", node="label"), AMOUNTS, scalar(fixed, 3)],
decimals=1)
timed = sources(returned={S1: 10}, finished={"fetch": 5, "label": 12})
assert recompute(card["metrics"][0], Changes(), timed)["new"] == 20.0
[fee] = whatif(card, Changes(inputs={rate: 0.1}, exclude={S1: frozenset({1})}), timed)["metrics"]
assert fee["status"] == "unsupported" and "vars.kpi.region" in fee["reason"]
[fee] = whatif(card, Changes(inputs={rate: 0.1, fixed: 5}, exclude={S1: frozenset({1})}), timed)["metrics"]
assert (fee["status"], fee["new"]) == ("ok", 31.0), fee
def test_a_change_that_leaves_the_value_as_it_was_moves_nothing_downstream():
"""改动碰到了指标,算出来却没变:4 行里排除 1 行,min(行数, 3) 还是 3;填回原值也一样。照实说无变化,下游也不会
受影响(maybe_changed 为假)。排除到只剩 2 行才变。"""
card = lone("cap", "min(len(vars.res.rows), 3)", "min(len(vars.res.rows), 3)", 3,
[{**scalar("vars.res.rows", "[4 项]", node="parse"), "value_artifact": RES_ROWS}])
result = whatif(card, Changes(exclude={S1: frozenset({3})}), sources())
[cap] = result["metrics"]
assert (cap["status"], cap["new"], cap["changed"], cap["inputs"][0]["excluded_rows"]) == ("ok", 3, False, [3])
assert result["maybe_changed"] is False
same = whatif(CARD, Changes(inputs={"vars.kpi.gmv": 45678.5}), sources(), metric="wow")
assert (same["metrics"][0]["status"], same["metrics"][0]["changed"], same["maybe_changed"]) == ("ok", False, False)
moved = whatif(card, Changes(exclude={S1: frozenset({0, 3})}), sources())
assert moved["metrics"][0]["new"] == 2 and moved["maybe_changed"] is True
def test_a_metric_without_lineage_is_unsupported_whatever_changes():
@@ -503,6 +606,8 @@ def test_a_metric_without_lineage_is_unsupported_whatever_changes():
assert old["status"] == "unsupported" and old["changed"] is None and "没有记录输入" in old["reason"]
#: 按遮罩列那一格的长度取第几项
PHONE_PICK = "vars.kpi.list[len(cell(nodes.crm, 0, 'phone')) - 10]"
#: 整个指标推演不了的几种:(卡, Sources 的改动, 改点什么, 状态, 原因里该有的字)。每种都另有一个本来能改的输入
BLOCKED = {
# 老运行:容器型 vars 输入没存完整值
@@ -513,6 +618,11 @@ BLOCKED = {
"masked": (lone("mix", "len(cell(nodes.crm, 0, 'phone')) + vars.kpi.k", "len('13800000000') + 2", 13,
[cell("crm", 0, "phone", "13800000000", artifact=S2), scalar("vars.kpi.k", 2)]),
{}, Changes(inputs={"vars.kpi.k": 5}), "masked", ["遮罩"]),
# 遮罩列的那一格只嵌在下标里(决定取哪一项):改了别的输入,取到哪一项照样露出它的长度
"masked_index": (lone("pick_m", f"{PHONE_PICK} + vars.kpi.k", "20 + 2", 22,
[scalar(PHONE_PICK, 20), cell("crm", 0, "phone", "13800000000", artifact=S2),
scalar("vars.kpi.k", 2)]),
{}, Changes(inputs={"vars.kpi.k": 5}), "masked", ["遮罩"]),
# 那一格所在的查询快照读不出来:遮没遮核对不了
"unreadable": (lone("mix2", "cell(nodes.pull, 0, 'amount') + vars.kpi.k", "120.5 + 2", 122.5,
[cell("pull", 0, "amount", 120.5), scalar("vars.kpi.k", 2)], decimals=1),
@@ -638,6 +748,8 @@ def test_a_withheld_cell_inside_another_reference_stays_out_of_the_substituted_e
[m] = whatif(card, Changes(), sources(load=lambda a: None if a == S1 else store.get(a)))["metrics"]
assert m["status"] == "unchanged" and m["substituted"] is None
assert [i.get("withheld") for i in m["inputs"]] == [None, True]
# 那一格所在的快照读不出来:它那一行也不给勾(排除读不出来的快照里的行,接口回 422)
assert "row_span" not in m["inputs"][1]
# --------------------------------------------------------------------------
+41
View File
@@ -811,6 +811,29 @@ async def test_inputs_carry_the_rows_they_use(client, monkeypatch):
assert cell["row_span"] == [5, 5] and rows["row_span"] == [0, 9]
async def test_a_container_that_picks_the_cell_offers_no_rows(client, monkeypatch):
"""cell() 的行号由整份结果算出:整份结果不给行段(勾了别的行,取到哪一格说不准,什么也推演不出来),那一格
自己那一行照常可勾、勾了确定是排除后无法计算。那一格给了新值,取到哪一格已无关紧要,排除别的行照常算。"""
pick = "cell(nodes.fetch, len(vars.res.rows) - 5, 'amount')"
row = await run_graph(monkeypatch, query_graph(metrics=[
{"id": "pick", "name": "倒数第五单金额", "unit": "元", "decimals": 1, "format": "thousands", "expression": pick}]),
text="倒数第五单金额 [[m:pick]]。")
snapshot = await snapshot_of(row.id)
body = await whatif(client, row.id, {"card": "card"})
[m] = body["metrics"]
assert m["status"] == "unchanged" and m["old"] == 600.5
assert [(i["path"], i.get("row_span"), i["editable"]) for i in m["inputs"]] == [
(pick, [5, 5], True), ("vars.res.rows", None, False)]
assert [(u["input"], "取哪" in u["reason"]) for u in body["unsupported"]] == [("vars.res.rows", True)]
[m] = (await whatif(client, row.id, {"card": "card", "overrides": {"exclude_rows": {snapshot: [5]}}}))["metrics"]
assert m["status"] == "excluded" and "第 6 行" in m["reason"]
body = await whatif(client, row.id, {"card": "card", "report": "write",
"overrides": {"inputs": {pick: 1000}, "exclude_rows": {snapshot: [0]}}})
[m] = body["metrics"]
assert (m["status"], m["new"], m["rendered_new"]) == ("ok", 1000, "1,000.0元"), m
assert [(s["rendered_old"], s["rendered_new"]) for s in body["segments"]] == [("600.5元", "1,000.0元")]
async def test_rows_of_a_sealed_snapshot_can_be_paged_with_masks_applied(client, monkeypatch, shop):
"""推演区里给整份结果勾行:按页取封存范围内快照的行,遮罩列写「已遮罩」。只读。"""
row = await run_graph(monkeypatch, query_graph())
@@ -863,6 +886,24 @@ async def test_downstream_cards_through_an_intermediate_node_are_flagged(client,
assert (flagged["node_id"], flagged["metrics"], flagged["direct"]) == ("yearly", ["year"], False)
async def test_a_change_that_moves_no_metric_flags_no_downstream_card(client, monkeypatch):
"""改动碰到了指标、算出来却没变(10 单里排除 2 单,min(单数, 3) 还是 3):下游不会受影响,不标。排除到只剩 2 单
才变,这时才标。界面不会提交和原值一样的输入,排除行却常有这种情况(又如 max(…) 勾掉的不是最大值那一行)。"""
row = await run_graph(monkeypatch, query_graph(
metrics=[{"id": "cap", "name": "封顶单数", "unit": "单", "format": "thousands",
"expression": "min(len(vars.res.rows), 3)"}],
between=[node("yearly", "metrics", caliber="年口径", metrics=[
{"id": "year", "name": "年化", "expression": "nodes.card.metrics[0].value * 52"}])]),
text="封顶单数 [[m:cap]]。")
snapshot = await snapshot_of(row.id)
body = await whatif(client, row.id, {"card": "card", "overrides": {"exclude_rows": {snapshot: [2, 7]}}})
[cap] = body["metrics"]
assert (cap["status"], cap["new"], cap["changed"], cap["inputs"][0]["excluded_rows"]) == ("ok", 3, False, [2, 7])
assert body["affected_cards"] == []
body = await whatif(client, row.id, {"card": "card", "overrides": {"exclude_rows": {snapshot: list(range(8))}}})
assert body["metrics"][0]["new"] == 2 and [c["node_id"] for c in body["affected_cards"]] == ["yearly"]
async def test_downstream_cards_are_flagged_when_the_change_cannot_be_followed(client, monkeypatch):
"""改动碰到了指标、它却推演不了(还原不了、遮罩):新值不知道,下游同样可能受影响。"""
from app.engine.nodes import metrics as metrics_node
+2 -1
View File
@@ -3062,7 +3062,8 @@ export interface WhatIfInput {
excluded_rows?: number[]
/**
* 这个输入实际用到的快照行(含两端,从 0 数):单格一行、数组字段一段、整份查询结果全部。排除别的行影响不到它,
* 界面只让这几行可勾。遮罩的、还原不了的(完整值找不回来、快照读不出来)没有,整个指标推演不了时一律没有
* 界面只让这几行可勾。遮罩的、还原不了的(完整值找不回来、快照读不出来)没有,决定取哪一格的数组字段、整份结果
* 也没有(少了几行,取到哪一格说不准),整个指标推演不了时一律没有
*/
row_span?: [number, number]
masked?: boolean