Skip to content

fix(ui): print the spendable half in the ops member balance column - #277

Merged
argszero merged 1 commit into
mainfrom
fix/ops-balance-column-names-the-spendable-half
Sep 21, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/ops-balance-column-names-the-spendable-half

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The ops members table's balance column is captioned with the site-wide wording for the
spendable balance — ops.users.col.balance = "余额(点数)" / "Balance (pts)" — but its cell
prints only u.balance (the permanent half). The API has been returning both halves for a
long time (/api/ops/users → COALESCE(q.balance,0), COALESCE(q.gift_balance,0)), so with a user
at balance=100, gift_balance=1 the operator sees 100 while that same user sees 101 in
their own sidebar/wallet. The difference is exactly the gift half — which is spendable, expires,
and is really deducted (gift::expire_past_gifts).

Every other place in the app binds the unqualified "点数余额 / Points balance" to the spendable
amount (available = balance + gift_balance); the ops column used the same word but a different
number.

Root cause and provenance — drift, not a deliberate trade-off

Why this is fixed in the value, not in the caption (direction "甲")

Both one-line fixes are self-consistent, so the deciding evidence is an asymmetry:

  1. The caption is not a new coinage — it is the site-wide name for the spendable amount. Three
    places bind the unqualified wording to available (common.balance, dash.balance,
    wallet.balance), and two bind "永久点数 / Permanent points" to balance (wallet.forever,
    admin.emp.col.perm). The ops caption uses the former ⇒ it is the established term.
  2. Therefore what is broken is the value — it did not follow the term's definition. Renaming
    the caption to another equally-existing term would change what the column declares: a design
    change, not a repair.
  3. Same invariant as the C2132 / C2142 / C2145 family: one fact, one word, one number. A user
    seeing 101 while the operator sees 100 is exactly the shape that family keeps fixing.

Counter-evidence, recorded honestly (it is the support for the alternative fix "乙"): the same
#79 sibling table names u.balance "永久点数"; the ops page's job is to grant credit (and it
grants permanent points — ops.users.sub and ops.users.topup.ok both say "永久有效"). If the
product actually wants "the ops page is a permanent-balance ledger", then changing the two captions
to "永久点数 / Permanent points" is equally a one-liner with zero new keys, reusing an existing
canonical name. The instruments for both directions are in hand, so switching costs ≈ nothing —
but this PR ships the direction the evidence supports.

Changes

# File Change
E1 ui/js/app.js (ops members balance cell) D.fmt(u.balance || 0) → D.fmt((u.balance || 0) + (u.gift_balance || 0)) — the trailing + " " + T("common.points") suffix is preserved; the expression is byte-identical in shape to the sibling admin "available" cell. One line.
E2 src/state_gate.rs new gate the_ops_members_balance_cell_is_the_half_its_caption_names (3 rules) + its ruler self-check test the_ops_balance_half_checkers_have_teeth + synthetic inputs
E3 ui/README.md new section: the ops members table's "balance" column is the spendable amount (the same number the user sees), plus the gate's stated scope
E4 ui/index.html cache-bust the app bundle ?v= by procedure (read the live value with grep -n 'js/app\.js?v=' and write a strictly greater one — do not copy a literal)
E5 — zero new i18n keys (both packs' key sets are unchanged ⇒ the i18n_pack constants and the reachability sunset list are untouched)

The gate (E2) — three rules, each with its own tooth

  • Rule 1 (value): the cell's value expression must read both halves. Reading only the
    permanent half is this defect; reading only the gift half is the axis reversed. Matched by
    identifier token (gift_balance ends with balance, so substring matching would be blind)
    with comments stripped first.
  • Rule 2 (word): in both packs, ops.users.col.balance must still land in the spendable
    family. The markers are derived from the pack's own values for wallet.balance /
    wallet.forever (their longest common substring is the shared noun — points / 点数 — and what
    remains on each side is its marker: balance vs permanent, 余额 vs 永久). This blocks the
    "rename the caption so the two sides agree" escape.
  • Rule 3 (reverse control): the admin table's "Permanent points" column must still read only
    the permanent half — blocking the "widen everything to available" over-correction.

The value extractor anchors on the data-label closing token and "</td>", and fails loudly
(None ⇒ the test errors) instead of silently returning an empty set on which an assertion would
pass vacuously. The marker derivation refuses to degrade to empty markers for the same reason
(an empty marker makes contains always true and rule 2 silently mute).

Evidence — A/B

Gate A/B (c2173_gate_ab.py, 9 trees; re-run this round)

Each tree = the real state_gate.rs with the two fragments spliced at their insertion points,
plus one mutated copy of the UI. The runner counts "which test went red" and "which rule spoke" as
two separate facts, so a red ruler self-check is never scored as "the gate has teeth".
Result: RESULT: ALL TREES AS DECLARED.

Tree Mutation Verdict Rule that spoke
base the real repo today rejected rule 1
fix (甲) value → (balance) + (gift_balance) accepted —
m_gift_only value = gift half only (axis reversed) rejected rule 1
m_comment_only only a comment mentioning gift_balance rejected rule 1 (the #296 tooth)
m_delete_fill the whole cell deleted rejected fill-site uniqueness (the positive control's tooth)
m_rename_zh 甲 + zh caption renamed to the permanent family rejected rule 2
m_rename_both 甲 + both captions renamed (= direction 乙) rejected rule 2
m_cosmetic 甲 + both captions renamed to a word that names neither ("点数 / Points") rejected rule 2
m_admin_perm_wide 甲 + the admin "Permanent points" column widened to available rejected rule 3 (reverse control)

clippy-driver --edition 2021 --test -D warnings on the assembled tree: rc=0.

DOM probe (c2173_probe.js, jsdom, 19 legs; runner c2173_ab.py)

Fixture: one user with balance=100, gift_balance=1, role=ops, with /api/ops/users,
/api/admin/users and /api/wallet answering in the real backend field shapes; any request
that is not explicitly stubbed lands in __fellThrough and fails leg P4 (so the instrument cannot
be silently mute). The two word families' markers are derived from rendered output (the wallet
hero cell's own two labels), so the probe carries no wording constants and cannot be circular.

Result: RESULT: ALL LEGS AS DECLARED

Tree What it is Verdict
base unfixed ui/js/app.js axis legs {A1, A1zh, A2, A2zh} red; controls C1–C6, preconditions P0–P6, measurement M1 green
jia (甲) app.js value → both halves all green
yi (乙) both captions → "永久点数(点数)" / "Permanent points (pts)" all green
en_only only the en caption changed rejected {A1zh, A2zh}
cosmetic caption → "点数 / Points" (names neither) rejected {A1, A1zh}
yi_plus caption says permanent but the value was widened to available rejected {A1, A1zh}

The unfixed tree's own closing measurement line, verbatim:
sidebar=101 opsRow=100 dash=101 walletHero=101 walletForever=100 | admin perm=100 avail=101 | zh sidebar=101 opsRow=100 head="余额(点数)"
— i.e. on the same instrument, eight of the nine balance cells agree and only the ops row is the
other half.

Both instruments agree on the direction (they accept 甲, and reject 乙 / cosmetic /
self-contradictory shapes). They are not redundant: the gate sees three things the probe cannot —
a value that happens to equal the gift half exactly, a deleted fill site (the probe reads it as an
empty table), and the admin column being widened when balance == gift_balance (the probe's C4
would be blind to that). The gate is stricter than the probe; neither can be dropped.

Tests

  • cargo test — 316 passed / 0 failed on this exact head (baseline main = 314; the +2 are
    the new gate test and its ruler self-check).
  • cargo fmt --check — clean (rc=0).
  • cargo clippy --all-targets -- -D warnings — clean (rc=0).
  • Gate A/B re-run this round: RESULT: ALL TREES AS DECLARED (9 trees); DOM probe A/B
    re-run this round: RESULT: ALL LEGS AS DECLARED (baseline red on exactly
    {A1, A1zh, A2, A2zh}).
  • New gate added (E2) with its own ruler self-check test and synthetic inputs.

What this PR deliberately does NOT do

  • It does not reformat or refactor the ops render function; the change is the value expression only.
  • It does not touch the i18n packs at all — both packs' key sets, and therefore the i18n
    reachability gate's constants and its sunset list, are unchanged.
  • It does not change the admin table's three columns (the reverse-control rule pins them).

Related Issue

(No upstream issue — internal consistency fix found by the maintenance pass.)

Checklist

  • Branch name follows the convention (fix/).
  • Commit message uses Conventional Commits (fix(ui): …).
  • Single responsibility, minimal change.

`/api/ops/users` returns both `balance` (permanent points) and
`gift_balance`, and the ops members table rendered only `u.balance`
while its column head carries the site-wide unqualified wording
"余额(点数)" / "Balance (pts)" — the wording the site binds to the
SPENDABLE amount (`available = balance + gift_balance`, the same half
`common.balance` / `dash.balance` / `wallet.balance` name).

So the operator read a smaller number than the user reads for the same
account, and the difference was exactly the gift half (spendable,
expiring, really deducted by the gift sweep).

Fix: fold `gift_balance` into the cell — byte-identical in shape to the
sibling table that already got it right (`admin.emp.col.avail`), zero
new i18n keys. The admin "permanent points" cell (`admin.emp.col.perm`)
deliberately keeps the bare half; it is the reverse control.

CI: `src/state_gate.rs::the_ops_members_balance_cell_is_the_half_its_caption_names`
(three rules, each with an independent tooth: the value expression must
read both `balance` and `gift_balance` by identifier token; both packs'
column head must live in the spendable family, whose marker is derived
from the `wallet.balance` pack value; and the reverse leg pins
`admin.emp.col.perm` to the bare half) plus a teeth test.

Scope note: the gate is lexical — it proves the value READS both fields
and that the label lives in the spendable family; it does not prove the
arithmetic is `+`. That half is pinned by the jsdom instrument, whose
A/B ran the baseline red on exactly {A1,A1zh,A2,A2zh} while both
legitimate fix trees stay green and three near-miss trees are rejected.

Tests: cargo test 316 passed / 0 failed; cargo fmt --check clean;
clippy --all-targets -D warnings clean.
@argszero

Copy link
Copy Markdown
Owner Author

Self-review (Committer, allow_self_merge: true) — the PR is mine, so this is a comment rather
than an approving review.

Verified on this exact head a9fb9e3 (tree e0c4810eda272144b3dcc44bb1eef459f99eff82):

  • cargo test — 316 passed / 0 failed (baseline main = 314; the delta is the new gate test
    the_ops_members_balance_cell_is_the_half_its_caption_names plus its ruler self-check
    the_ops_balance_half_checkers_have_teeth).
  • cargo fmt --check — clean. cargo clippy --all-targets -- -D warnings — clean.
  • Gate A/B (9 trees, real state_gate.rs spliced, each mutation rejected by its own rule):
    RESULT: ALL TREES AS DECLARED.
  • DOM probe A/B (jsdom, 19 legs): RESULT: ALL LEGS AS DECLARED — the baseline tree is red on
    exactly {A1, A1zh, A2, A2zh}, both legitimate fix trees (甲 and 乙) are green, and the three
    near-miss trees are rejected.
  • The unfixed tree's own measurement line, verbatim:
    sidebar=101 opsRow=100 dash=101 walletHero=101 walletForever=100 | admin perm=100 avail=101
    — eight of the nine balance cells agree; only the ops row printed the other half.

Scope, stated honestly: the CI gate is lexical. It proves the cell's value expression reads
both balance and gift_balance (by identifier token, comments stripped) and that both packs'
column head still lives in the spendable family; it does not prove the arithmetic is +.
That half is pinned by the jsdom instrument, which the project's CI cannot run (there is no JS
runner in the pipeline) — the same division of labour the neighbouring state_gate entries use.

@argszero
argszero merged commit c6da6b8 into main Sep 21, 2026
1 check passed
@argszero
argszero deleted the fix/ops-balance-column-names-the-spendable-half branch September 21, 2026 15:40
@argszero argszero mentioned this pull request Sep 24, 2026
10 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant