Skip to content

test(ui): guard the fallback catalog against drifting from the config - #173

Merged
argszero merged 1 commit into
mainfrom
test/ui-catalog-sync-gate
Sep 12, 2026
Merged

test(ui): guard the fallback catalog against drifting from the config#173
argszero merged 1 commit into
mainfrom
test/ui-catalog-sync-gate

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

ui/js/data.js is the front-end's fallback catalog. Its header comment says it is kept aligned with config/config.example.toml [[models]], and two of its tables are load-bearing:

  • MODELS — the publish form's model picker (and PROVIDERS is derived from it), i.e. a mirror of the config catalog;
  • MARKET — the guest marketplace listing, i.e. a subset of MODELS.

The coupling is not cosmetic. ui/js/app.js renders the detail view by looking the model up by name across the two tables:

D.MODELS.find((x) => x.model === m.model)   // fills the max-tokens cell

When the lookup misses, the cell degrades to "未上架 / not published". Nothing errors, no key goes missing, and no existing test notices — a guest just sees a model name that no longer exists, an outdated price, and an empty max-tokens column.

That is exactly what commit ce6d0db did: it updated config/config.example.toml and "synced" ui/js/data.js, but missed MARKET (leaving glm-5.2, gpt-5.5-pro, claude-opus-4.7 and a stale deepseek-v4-flash price) and added a gemini-3.5-flash-lite row that never existed in any config. A later PR fixed the data; this PR adds the guard that was missing.

Related Issue

None — this is a self-contained test-only addition.

Changes

  • New src/catalog_gate.rs: a tests-only #[cfg(test)] gate that pins the two fallback tables to the config catalog.
  • src/main.rs: one #[cfg(test)] mod catalog_gate; declaration.
  • No production code, no new dependency, no change to ui/.

What it asserts

  1. MODELS name-set == config [[models]] name-set, both directions — so it catches a phantom row and a model added to the config but never synced into the fallback.
  2. Every MARKET model name resolves in MODELS (the app.js join).
  3. PLANS id-set == config [[plans]] id-set (the publish form submits payload.plan straight to the backend).
  4. Every shared MODELS row agrees with the config on provider / input price / output price / context_length / max_output, compared in each row's own currency (USD(x)/CNY(x) is a label; no FX factor is invented).
  5. Panicking positive controls on the real counts (14 / 7 / 12) plus a known truth (deepseek-v4-flash = 1.5/4.5), so a silently broken extractor aborts instead of "passing" on empty sets.
  6. A negative-control test that injects each real defect shape and requires the comparisons to reject them.

Why it is cheap and safe

The config side reuses the production deserializer — toml is already a dependency and config.example.toml is already include_str!-embedded in the binary (main.rs's DEFAULT_CONFIG), so embedding it in a test adds nothing. The JS side reuses the byte-scan approach the existing i18n_pack.rs gate uses on the other two front-end files; the repo has no regex dependency and deliberately no JS toolchain, so neither is introduced.

Tests

  • cargo test 全部通过 — 161 passed / 0 failed (155 existing + 6 new)
  • cargo fmt --check 通过
  • cargo clippy --all-targets -- -D warnings 通过
  • 新增/更新了单元测试

Beyond the suite, I verified the gate has teeth by injecting each real defect form into the actual ui/js/data.js and confirming the right assertion fails with the offending model named:

Injection (all real historical shapes) Result
re-add phantom gemini-3.5-flash-lite FAILEDMODELS 含配置中不存在的模型:["gemini-3.5-flash-lite"]
rename glm-5.3glm-5.2 in MARKET only (counts unchanged) FAILED游客市场模型的名称在 MODELS 中查不到:["glm-5.2"]
stale deepseek-v4-flash price → 1.008/2.016 (counts unchanged) FAILED输入价不一致(兜底 1.008 vs 配置 1.5 CNY) + output line

The second and third are the important ones: every count control stays green, so only the substantive comparison can catch them — the gate is not merely counting rows. ui/js/data.js was restored byte-identically afterwards (md5 ebfeac67aef03fa56b1e64266d3a7acd, verified against the committed blob).

Checklist

  • 分支命名符合约定(test/ 前缀不在 CONTRIBUTING 的列举中,但 test 是其允许的 commit type,且本改动不含任何生产代码 —— 与既有 i18n_pack.rs 门禁同型;如需改为 refactor/chore/ 请告知)
  • Commit message 使用 Conventional Commits 格式
  • 单一职责,改动最小化 —— 只加门禁,不碰 ui/、不改数据、不动既有测试

ui/js/data.js mirrors config.example.toml's model catalog: MODELS feeds the
publish-form picker and MARKET is what guests browse. app.js joins the two by
model NAME, so a rename in one place silently degrades the other to a
'not published' cell -- which is exactly how ce6d0db broke it (it updated the
config, missed MARKET, and left a phantom model in the fallback).

This adds a tests-only gate (no production code, no new dependency) asserting
MODELS == the config catalog, every MARKET row resolves in MODELS, PLANS ids
match, and shared rows agree on provider/prices/context/max in each row's own
currency -- with panicking positive controls so a broken extractor cannot read
as clean.
@argszero
argszero merged commit b7becb5 into main Sep 12, 2026
1 check passed
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