From a54cec2ae8c2148bd3fe1014d2a585ad945fbc3e Mon Sep 17 00:00:00 2001 From: test Date: Fri, 31 Jul 2026 02:34:47 +0800 Subject: [PATCH] fix(card): a review with no persisted rule never claims one is standing (closes #546) The pre-commitment preview's no-commitment branch (card_renderer.py's _next_block) resolved state.rule_dim -- this period's fresh prescription -- into block_missing.rule_standing ("the standing rule remains: ...") without checking whether a prior commitment was ever recorded. cmd_preview always drafts with require_commitment=False, so this branch fires on every preview before the user has chosen a rule, not only a first-ever review; on a fresh root it told the user continuity the product never earned. Gate the wrapper on the same review_plan.state_snapshot.prior_commitment.rule predicate _reconciliation_lines already reads for #292: with a real prior commitment on record, the standing-rule copy is unchanged; with none, a new key (block_missing.rule_pending, synchronized across en/zh-TW/zh-CN) states the same {rule} as a recommendation awaiting the user's choice, replacing rather than adding a sentence. Both renderers share the branch through _card_structure, including Markdown's compact text-first scan. Co-Authored-By: Claude Opus 5 --- docs/output-contract.md | 3 +- skills/fomo-kernel/copy/en.json | 1 + skills/fomo-kernel/copy/zh-CN.json | 1 + skills/fomo-kernel/copy/zh-TW.json | 1 + skills/fomo-kernel/engine/card_renderer.py | 16 +++- tests/test_card_html.py | 88 +++++++++++++++++++--- 6 files changed, 95 insertions(+), 15 deletions(-) diff --git a/docs/output-contract.md b/docs/output-contract.md index aab1b90e..242775d5 100644 --- a/docs/output-contract.md +++ b/docs/output-contract.md @@ -187,7 +187,7 @@ never silent omission. | 3 strengths + hole | engine diagnosis present | one-line note | | 3 `[v]` amplify row | an `amplify` / `amplify_hypothesis` / `selection_inconclusive` prescription exists (strongest one only, in that order) | panel renders with the strength line alone | | 3 `[?]` pattern panel | an unjudged pattern fired (today: `sold_winner_early` tags on ≥1 instrument) **and** the review did not queue the answerable `exit_consistency` question about it | omit the panel — either nothing fired, or the question already carries the facts answerably | -| 4 next step | always — falls back to restating the standing rule when the engine proposes no change | — | +| 4 next step | always — restates the standing rule only when a persisted `prior_commitment` is on record; a review with none (a first review, or any later one whose most recent finalize set none) states the recommendation as pending instead (#546) | — | | 4 rule targets | the commitment carries a `dim` and that dimension has per-position facts (`risk_weights` over the cap; per-ticker averaging-down counts) | fall back to the aggregate `#248` grounding sentence; never leave the rule unanchored | | 4 trade-off line | the rule's dimension shrinks a position (`position_sizing` / `diversification`) **and** the card carries an `amplify` row | omit the line — an unconditional one is caveat noise | | snapshot route | suppresses history-performance modules by design (`card_renderer.py:1411`) | Block 1 = position-structure baseline only | @@ -337,3 +337,4 @@ LLM judge and dogfood verdicts: structure is mechanical, prose is judged. | 2026-07-24 | [#363](https://github.com/atomchung/fomo-kernel/issues/363) | Owner ruling: the Block-1 items #363 left open (2026-07-23 rows above) are closed. **Cumulative, not time-weighted:** the holdings-only and account-level return sentences (new copy keys `account_perf.holdings_only`, `account_perf.account_base`) rename their return-methodology word from "time-weighted return" to "cumulative return" in both locales — wording only; the engine field (`acct_perf.hold_twr`/`acct_twr`) and its number are unchanged. The neighbouring `irr_annual` clause ("annualized return") is a different, already-annualized indicator and keeps its own wording, so the same word never names two different things on the same sentence. **The alpha interval moves into the alpha tile's own sub** (new copy key `alpha_interval.tile_sub`, a short form, e.g. en "95% interval +7% to +54%"), whenever `alpha_stat.ci95` is present and finite; a card where it is not falls back to exactly the pre-#363 sub (the not-yet-credible legend, or nothing) — derived from that one data property (design-guidelines §3.3), not enumerated per scenario, so a non-credible alpha never loses the footnote for its value's "*" suffix just because no interval could be built. Putting *both* the interval and the not-yet-credible legend in the sub was measured and rejected first: at 760px the combined text clips (the sub's `-webkit-line-clamp:2; overflow:hidden` silently truncates the tail) — a tile's sub has room for one figure, and once the interval is that figure, the legend has nowhere left to go in the same slot. What a tile's sub still cannot hold once it carries the interval — the not-yet-credible legend and the #313 negative-interval caveat — collapses into one self-anchored standalone line below the whole grid (new copy keys `alpha_interval.below_unreliable`, `alpha_interval.below_negative`): each half fires on its own pre-existing, independent trigger (`credible`; the interval's lower bound), not merged, and each now names annualized alpha as its own subject, since the line no longer sits directly beneath a lead clause that already named alpha for it. The line renders only when at least one of the two triggers; a credible card with a wholly-positive interval now prints no alpha prose line below the grid at all — the point of the change. Markdown is untouched: it has no tile grid, so the complete original sentence (headline + interval + caveat, `_alpha_interval_line`) renders there exactly as before, on every card, through the pre-existing `kpi_id`/`html_text` mechanism (#344) — including cards with no alpha tile this period (mixed-market, month-gated), which keep the full sentence on HTML too. **The account-gate sentence drops its system-status framing.** All six `account_gate` keys lose their opening "account-level return stays locked" clause and their closing "the holdings pillar above is unaffected" reassurance, in both locales, leaving only the action for the four blockers a user can act on (e.g. `no_cash_anchor`, en: "Add one current cash balance, and you'll see the account-level return with cash included."); `mixed_trade_footprint` and `chain_unavailable` have no such action and instead state the blocking fact plainly and briefly. Copy-only — no engine change, no language branch. **Block-1 bullets, Markdown only.** Every Block-1 indicator line now renders as a `- ` bullet on the Markdown surface, superseding the 2026-07-22 bullet-pass ruling above (which bulleted only the market-grouped vs-market lines and left the main number lines as plain paragraphs). HTML is unchanged — it keeps `

` paragraphs, since its KPI tile grid already carries the visual structure Markdown's prose-only surface does not have. **Two defect fixes flagged on this issue land with it.** `cash_drag` (`acct_twr − hold_twr`, a difference of two returns) now renders through a new `_pp()` helper as e.g. `-33pp`, never through `_pct()`'s `-33%` (§5: "% means absolute return, pp means excess"). The zh `alpha_interval.lead` template gains a missing word-boundary space between the Latin "alpha" and the Chinese "annualized" word that immediately follows it whenever `{scope}` is empty (the common, single-market case) — cosmetic, copy-only; the en template already had that boundary and is unaffected. Measured (design-guidelines §4 step 7): a synthetic full-performance fixture (large P&L, cash, and `alpha_stat.ci95` populated for both a credible and a non-credible alpha) rendered at 360/520/760px, both locales — 12/12 combinations hold **row spread 0 and the alpha sub never clips** (`scrollHeight` ≤ `clientHeight` in every case), measured via `Emulation.setDeviceMetricsOverride` over the DevTools protocol rather than `--window-size` (this Chrome build floors `--window-size` at ~500px, too coarse for the 360px case). Persona-sweep byte-parity (`tests/persona_sweep.py --baseline`) drifts on all 156 rendered `.private.md` files, and every drifting line is the new bullet prefix alone: none of the offline sweep's mock personas populate `acct_perf`/`alpha_beta_breakdown` (no live prices in that environment), so the wording/pp/spacing/tile changes above are exercised instead by `test_review_v2.py`'s and `test_card_html.py`'s fixture-built unit and pin tests, not by the sweep. Bonus, same PR: `_performance_items`'s `account_hold`/`account` sentences — already being rewritten for the rename above — move out of their `if en`/`else` inline branches into a new `account_perf` copy group (`holdings_only`, `account_base`, `annualized_suffix`, `cash_drag_suffix`, `terminator`, split at each optional clause so the existing irr_annual/cash_drag conditional-concatenation logic is unchanged), continuing #368 Phase 2's migration; `tests/test_copy_ratchet.py`'s `BASELINE` moves 50 → 48. | | 2026-07-24 | [#382](https://github.com/atomchung/fomo-kernel/issues/382) | **The #361 row above's "zero clipped subs" measurement did not hold at every width the layout supports.** #361 measured 520/760px; #382 (independently reproduced, then re-measured against the shipped #363 fix) found the pnl and payoff tiles' subs still clip at 320-390px once realized/unrealized or win/loss reach 5-6 digits — the README's own headline magnitudes (`+$138,058`, realized `$19,000`, unrealized `$119,058`). The alpha tile's own sub, the third tile #382 found clipping, was already fixed by #363's unrelated interval-placement change (confirmed by re-measuring 30 combinations against the shipped `9923be3`: never clips). What was missing was the mechanism §1 names: a rule that bounds the cell's height (R2) is not a rule that bounds its *content* to that height — nothing had measured whether the copy actually fits at the narrowest supported width, in both locales, at realistic magnitudes, so the fixtures behind every prior "zero clipped subs" claim used 3-digit amounts (`avg_win: 140`, `total_pnl: -300`) that were never close to the cap. Two independent fixes, gated at the point each stops being sufficient: `kpi.payoff_sub` drops its redundant leading qualifier in both locales (en "Average", zh-TW "average") — the tile's own label already says the figure is realized, so the word was doing no work — and fits both locales down to 320px unmodified, no change to the money format. `kpi.pnl_sub` needed more: even after dropping the equivalent leading word the way payoff did, a 5-6 digit realized/unrealized pair still does not fit two-up in the ~84px-wide sub at 320-360px no matter how the surrounding words are trimmed — the figures themselves are wider than the slot. A new `_money_compact()` (card_renderer.py) rounds to the nearest thousand only once `abs(value) >= 10000` (e.g. `+$119k`), leaving anything smaller — including a genuinely small account's real `+$200` — at full precision, so a small gain can never misprint as `+$0k`; only the HTML tile's `.sub` reads through it, `_performance_items`'s Markdown mirror of the same sentence keeps full `_display_money` precision, since Markdown has no line-clamp to work around. en's final template (`{realized}, {unrealized} unrealized`) goes one step further and drops the leading "realized" word too — the number stays, first and unlabeled, following the realized-first order convention `pnl_lines.total` already establishes elsewhere in this catalog — while "unrealized" keeps its explicit label, since that is the figure a caveat actually attaches to (the card's own lede: "almost all of it is held and never sold"). zh-TW did not need that second cut: its pnl_sub keeps both the "already realized" and "not yet realized" words at full sentence structure, unchanged, once their two figures compact to "k" — its shorter, denser glyphs afford the fuller sentence at the same compacted-figure width where en's could not. Measured (design-guidelines §4 step 7) with a real viewport resize, not `--window-size` (the same ~500px floor #363's row above recorded): both templates, at the values above, clear `scrollHeight<=clientHeight` **and** `scrollWidth<=clientWidth` (a single unspaced token can overflow the box sideways instead of wrapping, which a height-only check misses — caught one false pass here) at en 320/360/390px and zh-TW 320/360px, row spread 0 throughout — the narrowest width each locale was reported broken at. Known residual, out of the issue's own measured scope: zh-TW's pnl sub still clips at 300px, one step past the 320px this ruling and the issue both measured; closing it would cost zh-TW its own explicit "already realized" label, the same trade en already made, and nothing reported 300px as broken. Enforced by `test_pnl_and_payoff_tile_subs_fit_the_two_line_cap_at_narrow_widths` (design-guidelines §7), hardcoded literals across three magnitude cases (large, the pre-#382 small fixture, and a negative-unrealized sign check) so the pin cannot compare the catalog against itself. Persona-sweep byte-parity drifts on 92 of 156 rendered `.private.md` files; every drifting line is the two wording changes above with the underlying figures unchanged (verified by extracting and comparing every number on every changed line against baseline) — no mock persona's realized/unrealized reaches the $10,000 compaction threshold, so that path is exercised only by the new pinned test, not the sweep, the same gap #363's row above recorded for its own change. | | 2026-07-30 | [#623](https://github.com/atomchung/fomo-kernel/issues/623) | **A card sentence that names a reward must attach a way to claim it, or say plainly that there is none.** A sweep of `copy/*.json`'s `block_missing` found five strings sharing the shape #357's fifth recurrence was diagnosed from ("telling me to add it on the finished card leaves me nowhere to act on it"), in three classes needing three different fixes. **Invitations** (`snapshot_unlock`, `rule_structural`) now name the one answer this user's book cannot reach — "whether your selling costs you more than your buying" — instead of a parenthetical feature catalogue ("win rate, payoff ratio, averaging-down discipline, and more"). The governing rule is [#617](https://github.com/atomchung/fomo-kernel/issues/617)'s, stated once in `references/decision-framing.md` and routed to from `references/card-policy.md` rather than restated, because two surfaces disagreeing about what an invitation owes is its own defect; at most one per card, and none at all on a complete behavioral review, which is asserted as its own counterweight (a manufactured invitation is the same defect as a manufactured disclosure). **A disclosure with a product path** (`annualized_reconciliation`) names the path inline in the user's own terms — "Share your current holdings view and the difference can be settled" — beside the number it explains, never promoted into the closing block, which would put two invitations on one card. **The price-blocked strings** (`annualized_prices`, `vs_market_prices`) were not a copy problem at all: `flows/first-review.md` step 0 already requires recovery before a degraded card is delivered, so that sentence reaching a user is evidence a required step was skipped — indistinguishable, until now, from sources that genuinely publish nothing. `review_plan.input.price_feed.recovery` records which happened and the shared draft path refuses the unattempted one, clearable by doing the step or by declaring the dead end with `prepare --prices-unavailable ""`. Net line count on every affected card is unchanged: no Note was added, and no sentence became a second paragraph. The other 12 `block_missing` strings honestly report a non-computable result with no user action available and are deliberately untouched. | +| 2026-07-31 | [#546](https://github.com/atomchung/fomo-kernel/issues/546) | **The pre-commitment preview claimed a standing rule "remains" on a review with no persisted commitment to restate.** Block 4's no-commitment branch (`card_renderer.py`'s `_next_block`) resolved `state.rule_dim` — this period's fresh prescription — into `block_missing.rule_standing` ("the standing rule remains: …") without checking whether a prior commitment was ever recorded. Because `cmd_preview` always drafts with `require_commitment=False`, this branch fires on every preview before the user has chosen, not only a first-ever review. Fixed by gating the wrapper on the same `review_plan.state_snapshot.prior_commitment.rule` predicate `_reconciliation_lines` already reads for #292: with a real prior commitment on record, the standing-rule copy is unchanged; with none, a new key (`block_missing.rule_pending`, en "No rule chosen yet this period; the recommendation is: …", each locale phrased natively rather than translated word-for-word, synchronized across `en`/`zh-TW`/`zh-CN`) states the same `{rule}` as a recommendation awaiting the user's choice, replacing rather than adding a sentence (net card line count unchanged). Both renderers share the branch through `_card_structure`, including Markdown's compact text-first scan (`_read_first_panels`, which quotes this same panel, so there is no second mirror to update). `tests/persona_sweep.py --baseline` against pre-fix `main` drifted exactly 12 of 156 rendered Markdown cards and 12 of 156 HTML cards — every one a `.first.preview` (or, for the `rotator` persona, a `.second.preview` whose own first review also set no commitment) switching from the standing to the pending wrapper, and nothing else. | diff --git a/skills/fomo-kernel/copy/en.json b/skills/fomo-kernel/copy/en.json index 838f716c..d30738f4 100644 --- a/skills/fomo-kernel/copy/en.json +++ b/skills/fomo-kernel/copy/en.json @@ -42,6 +42,7 @@ "risks": "No behavioral diagnosis this period: the engine scored no dimension.", "rule": "No new rule commitment this time; you can pick one at the next review.", "rule_standing": "No rule change this period; the standing rule remains: \"{rule}\"", + "rule_pending": "No rule chosen yet this period; the recommendation is: \"{rule}\"", "rule_skip": "You chose not to set a new commitment; the same baseline remains available next time.", "rule_snapshot": "This opening check keeps the structural baseline without forcing a commitment.", "rule_structural": "This is an opening structural check: it sets a baseline without forcing a commitment. Too few closed trades here to say yet whether your selling costs you more than your buying; a file carrying the matching sells would settle that one question.", diff --git a/skills/fomo-kernel/copy/zh-CN.json b/skills/fomo-kernel/copy/zh-CN.json index 9d6405b5..52c568c2 100644 --- a/skills/fomo-kernel/copy/zh-CN.json +++ b/skills/fomo-kernel/copy/zh-CN.json @@ -42,6 +42,7 @@ "risks": "本期没有行为诊断:引擎没有可评分的维度。", "rule": "这次没有新的规矩承诺;下次复盘可再选一条。", "rule_standing": "这期不改规矩;现行规矩维持:“{rule}”", + "rule_pending": "这期还没选规矩;建议是:“{rule}”", "rule_skip": "你这次选择不设新承诺;下次仍可用同一份基线对账。", "rule_snapshot": "这次开场检查先保留结构基线,不强迫设定承诺。", "rule_structural": "这次是开场结构检查:先立基线、不硬塞承诺。已平仓的交易还太少,还看不出你的卖出是不是比买进更伤你;补上对应的卖出纪录就能回答这一题。", diff --git a/skills/fomo-kernel/copy/zh-TW.json b/skills/fomo-kernel/copy/zh-TW.json index 742282f3..7e0c58eb 100644 --- a/skills/fomo-kernel/copy/zh-TW.json +++ b/skills/fomo-kernel/copy/zh-TW.json @@ -42,6 +42,7 @@ "risks": "本期沒有行為診斷:引擎沒有可評分的維度。", "rule": "這次沒有新的規矩承諾;下次復盤可再選一條。", "rule_standing": "這期不改規矩;現行規矩維持:「{rule}」", + "rule_pending": "這期還沒選規矩;建議是:「{rule}」", "rule_skip": "你這次選擇不設新承諾;下次仍可用同一份基線對帳。", "rule_snapshot": "這次開場檢查先保留結構基線,不強迫設定承諾。", "rule_structural": "這次是開場結構檢查:先立基線、不硬塞承諾。已平倉的交易還太少,還看不出你的賣出是不是比買進更傷你;補上對應的賣出紀錄就能回答這一題。", diff --git a/skills/fomo-kernel/engine/card_renderer.py b/skills/fomo-kernel/engine/card_renderer.py index 9056af52..d7033bbe 100644 --- a/skills/fomo-kernel/engine/card_renderer.py +++ b/skills/fomo-kernel/engine/card_renderer.py @@ -3435,10 +3435,22 @@ def _next_block(bundle, copy, facts, state, snapshot): standing = (localized_rule(state.get("rule_dim"), language, cap=state.get("max_position_pct")) if state.get("rule_dim") else None) + # #546: this branch also fires at preview time on a user's very first + # review — ``require_commitment=False`` nulls ``bundle["commitment"]`` + # before the user has ever chosen a rule, and ``state["rule_dim"]`` is + # this period's fresh prescription, never a carried-forward answer. "The + # standing rule remains" is a continuity claim, true only when a prior + # commitment was actually persisted, so it is gated on the same + # predicate ``_reconciliation_lines`` already reads for #292: a genuine + # first review has nothing to restate and gets the pending-choice line + # instead, naming the same recommendation as awaiting the user's choice. + prior_commitment = (((bundle.get("review_plan") or {}).get("state_snapshot") or {}) + .get("prior_commitment") or {}) + standing_key = "rule_standing" if prior_commitment.get("rule") else "rule_pending" text = None - if standing and missing.get("rule_standing"): + if standing and missing.get(standing_key): try: - text = missing["rule_standing"].format(rule=standing) + text = missing[standing_key].format(rule=standing) except (KeyError, IndexError, ValueError): text = None if not text: diff --git a/tests/test_card_html.py b/tests/test_card_html.py index f1f4afaf..ae91be26 100644 --- a/tests/test_card_html.py +++ b/tests/test_card_html.py @@ -1964,11 +1964,19 @@ def test_public_committed_rule_carries_the_user_cap_override(): f"{language}: an invalid override is fail-closed on the public card" -def _pre_commitment_bundle(language, rule_dim="部位 sizing"): +def _pre_commitment_bundle(language, rule_dim="部位 sizing", prior_commitment=None): """A pre-commitment preview bundle: the engine prescribed a rule but the - user has not chosen one yet, so Block 4 falls to the standing-rule - placeholder. ``engine_state.rule`` carries the v1-only zh literal exactly - as ``trade_recap.prescribe`` writes it, and ``rule_dim`` its legacy label.""" + user has not chosen one yet, so Block 4 falls to the placeholder branch. + ``engine_state.rule`` carries the v1-only zh literal exactly as + ``trade_recap.prescribe`` writes it, and ``rule_dim`` its legacy label. + + Default (``prior_commitment=None``) is a genuine first review: nothing was + ever persisted to ``review_plan.state_snapshot.prior_commitment``, so + (#546) Block 4 must use the pending-choice wrapper rather than claim a + standing rule "remains". Pass a dict (e.g. ``{"rule": "..."}"``) to build + a returning user's preview instead, which keeps the standing-rule + continuity wrapper — the same ``prior_commitment.rule`` predicate + ``_reconciliation_lines`` already reads for #292.""" bundle = copy.deepcopy(_session(language)["bundle"]) bundle["commitment"] = None answers = dict(bundle.get("answers") or {}) @@ -1978,17 +1986,26 @@ def _pre_commitment_bundle(language, rule_dim="部位 sizing"): state["rule"] = "單筆部位上限定死 20%,超過就減" state["rule_dim"] = rule_dim state.pop("max_position_pct", None) + plan = bundle["review_plan"] = dict(bundle.get("review_plan") or {}) + snapshot = dict(plan.get("state_snapshot") or {}) + if prior_commitment is None: + snapshot.pop("prior_commitment", None) + else: + snapshot["prior_commitment"] = prior_commitment + plan["state_snapshot"] = snapshot return bundle def test_standing_rule_placeholder_resolves_copy_not_the_v1_literal(): - """#356: before the user picks a rule, Block 4 restates the standing rule. - It used to interpolate ``engine_state.rule`` verbatim — a zh literal - ``trade_recap.prescribe`` hardcodes — so English cards printed a Chinese - sentence inside an English wrapper (found in /fomo-qa dogfood, five of the - thirteen mock personas). The placeholder now resolves the canonical text - from ``copy/.json`` "rules" through ``engine_state.rule_dim``, the - same resolution the committed and public rule paths already use. + """#356: before the user picks a rule, Block 4 states the engine's + prescription through the placeholder branch (which wrapper it picks is + #546's concern, covered below). It used to interpolate ``engine_state.rule`` + verbatim — a zh literal ``trade_recap.prescribe`` hardcodes — so English + cards printed a Chinese sentence inside an English wrapper (found in + /fomo-qa dogfood, five of the thirteen mock personas). The placeholder now + resolves the canonical text from ``copy/.json`` "rules" through + ``engine_state.rule_dim``, the same resolution the committed and public + rule paths already use. Expectations are the literal catalog sentences, not ``load_copy`` lookups: reading the expected value from the same source the renderer reads makes @@ -2001,7 +2018,7 @@ def test_standing_rule_placeholder_resolves_copy_not_the_v1_literal(): block4 = _next_step_text(card_renderer.render_private( _pre_commitment_bundle(language))) assert sentence in block4, \ - f"{language}: the standing-rule placeholder must print the catalog rule" + f"{language}: the placeholder must print the catalog rule" assert "單筆部位上限定死 20%,超過就減" not in block4, \ f"{language}: the v1 engine literal must never reach a v2 card" # The English card carries no CJK at all — the wrapper being translated is @@ -2023,6 +2040,52 @@ def test_standing_rule_placeholder_resolves_copy_not_the_v1_literal(): f"{language}: the fallback must not print the v1 literal either" +def test_first_review_preview_states_the_recommendation_as_pending_not_standing(): + """#546: the placeholder branch above fires on *every* preview — + ``cmd_preview`` always drafts with ``require_commitment=False``, before + the user has chosen a rule — not only on a user's very first review ever. + "The standing rule remains" is a continuity claim, true only when + ``review_plan.state_snapshot.prior_commitment`` actually holds a + persisted rule; ``state.rule_dim`` is this period's fresh prescription, + never a carried-forward answer. Same predicate ``_reconciliation_lines`` + already reads for #292's breach sentence. + + A fresh root (no prior commitment — the common real shape: a genuine + first review, or any later one whose most recent finalize offered no + candidate rule, confirmed against real personas by + ``tests/persona_sweep.py --baseline``) must not claim otherwise; a + returning user with a real persisted commitment may still see the + standing-rule continuity copy. Both renderers share this branch through + ``_card_structure`` (Markdown's compact text-first scan quotes the same + panel via ``_read_first_panels``, and HTML's rule panel is scoped by the + ``sec keystep`` section, the card's last content section).""" + continuity_markers = {"en": ("remain", "keep", "standing"), "zh-TW": ("維持",)} + for language in ("zh-TW", "en"): + markers = continuity_markers[language] + fresh = _pre_commitment_bundle(language) + fresh_md_block4 = _next_step_text(card_renderer.render_private(fresh)) + fresh_html = card_renderer.render_html(fresh) + fresh_html_tail = fresh_html[fresh_html.index('

'):] + for marker in markers: + assert marker not in fresh_md_block4, \ + f"{language}: fresh-root Markdown preview must not claim rule " \ + f"continuity ({marker!r} found in Next step)" + assert marker not in fresh_html_tail, \ + f"{language}: fresh-root HTML preview must not claim rule " \ + f"continuity ({marker!r} found in Next step)" + + returning = _pre_commitment_bundle(language, prior_commitment={"rule": "placeholder"}) + returning_md_block4 = _next_step_text(card_renderer.render_private(returning)) + returning_html = card_renderer.render_html(returning) + returning_html_tail = returning_html[returning_html.index('
'):] + assert any(marker in returning_md_block4 for marker in markers), \ + f"{language}: a returning user with a real prior commitment must keep " \ + f"the standing-rule continuity copy in Markdown" + assert any(marker in returning_html_tail for marker in markers), \ + f"{language}: a returning user with a real prior commitment must keep " \ + f"the standing-rule continuity copy in HTML" + + def test_standing_rule_placeholder_carries_the_user_cap_override(): """#324 applies to the placeholder too: the standing rule the card restates is the same canonical text a candidate would offer, so a valid standing @@ -2098,6 +2161,7 @@ def main(): test_committed_rule_carries_the_user_cap_override, test_public_committed_rule_carries_the_user_cap_override, test_standing_rule_placeholder_resolves_copy_not_the_v1_literal, + test_first_review_preview_states_the_recommendation_as_pending_not_standing, test_standing_rule_placeholder_carries_the_user_cap_override, test_account_gate_sentence_names_the_actual_blocker, test_account_gate_degrades_instead_of_rendering_blank,