Files
ModelRouter/internal/gateway/ui_key_auto_test.go
JianFeeeee f9cd0770a9 fix(ui): per-key 弹窗复用共享弹窗ID、编辑即落盘、空画布显示226个模型
截图看渲染 + 交互实测后发现的四个问题,都出在我自己新写的代码里。

一、弹窗用了全站共享的 #modal-wrap。
这个 id 是 App 内所有弹窗共用的,且允许多个同时存在("添加档位"选择器就
开在本弹窗之上)。既有代码靠 .closest() 或 closeTopModal() 从按钮往上找,
是有效的——但本弹窗还要从画布侧被查(sortCanvasEl / keyAutoClose /
keyAutoInherit),那里没有按钮可走。实测点"添加档位"后 DOM 里同时有
modal-wrap=1 和 key-auto-modal=1,getElementById 会命中文档里的第一个,
也就是用户没在看的那一个:取消关掉错误的框,画布也可能解析到选择器上。
改为独立 id #key-auto-modal。

二、编辑即落盘,保存/取消形同虚设。
scrAddFromForm / sortScopeSave / scrDelSlot 等 6 处编辑后立刻调
persistAuto()。全局页这样是有意的(改动立即生效),但它和自己页面上那句
"点击保存排序后生效"的提示自相矛盾;在 per-key 弹窗里更严重——加一个槽位
就已经写进那个用户的链了,取消也无法撤销。收敛到 afterChainEdit():
有 scope 时只提示、不落盘,保存是唯一写入口。

三、无独立链时画布显示全部 226 个模型。
注释写的是"显示空画布",实现却是 buildLanes(idx, [], 'chat')——空 rules
会走 discovery 兜底,把每个源的模型全填进去。用户打开弹窗看到 226 个色块,
会以为这些就是这个密钥的链。改为空 rules 直接给 []。

四、"使用全局链"提示不随画布变化。
加完槽位后仍显示"该密钥使用全局 AUTO 链",与旁边的色块直接矛盾。改为由
afterChainEdit 触发、按画布是否有内容决定去留。

另:批量替换时正则的缩进假设错了一次,把 afterChainEdit 自己改成了自调用
(无限递归),node --check 抓到后修掉。这类批量替换必须先过语法检查。

判据两条 + 变异:弹窗回到共享 id → 判红;去掉 scope 分支 → 判红。
CDP 实测:弹窗 id 唯一、添加后画布 0→1 块、添加后服务端 inherits=true
(未落盘)、取消后仍 inherits=true、选择器与弹窗并存互不干扰。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 11:20:29 +08:00

269 lines
11 KiB
Go

package gateway
import (
"strings"
"testing"
)
// Per-key AUTO chain UI contract.
//
// The per-key editor deliberately REUSES the global lane canvas instead of
// shipping a second, simpler editor. That is a design choice with teeth: a
// reduced copy would lack drag ordering and per-slot quota/period/hours, and
// the two would drift apart silently. These checks pin the sharing, because
// the only symptom of un-sharing is "the per-key editor got worse" — no error,
// just a smaller feature set that nobody notices until they use it.
func TestUIPerKeyAutoWiring(t *testing.T) {
src := uiSource(t)
// The admin key list must offer the editor, otherwise the whole feature is
// unreachable from the UI. Assert the *call site* inside keyCanvasHtml,
// not just that the functions exist: removing the button leaves every
// definition intact, and that is exactly the regression to catch.
kcStart := strings.Index(src, "function keyCanvasHtml")
if kcStart < 0 {
t.Fatal("keyCanvasHtml is gone — cannot check the AUTO chain button")
}
kcEnd := strings.Index(src[kcStart:], "\n }")
if kcEnd < 0 {
t.Fatal("could not delimit keyCanvasHtml")
}
if !strings.Contains(src[kcStart:kcStart+kcEnd], "openKeyAutoModal(") {
t.Fatal("per-key AUTO chain button is missing from the key card header")
}
for _, fn := range []string{
"function openKeyAutoModal",
"function keyAutoInherit",
"function keyAutoSave",
"function keyAutoClose",
} {
if !strings.Contains(src, fn) {
t.Fatalf("UI is missing %s — the editor cannot function", fn)
}
}
}
// The per-key editor must render the SAME canvas the global editor uses.
// If this regresses, the per-key chain silently loses drag ordering and
// per-slot quota/period/hours while still looking like it works.
func TestUIPerKeyAutoReusesGlobalCanvas(t *testing.T) {
src := uiSource(t)
// One canvas implementation, two mount ids.
if !strings.Contains(src, "function renderSortEditor(") {
t.Fatal("no shared canvas renderer — the per-key editor must reuse renderSortEditor")
}
if !strings.Contains(src, `canvasId: "scr-canvas"`) {
t.Fatal("the global editor no longer mounts the shared canvas")
}
if !strings.Contains(src, `canvasId: "key-auto-canvas"`) {
t.Fatal("the per-key editor does not mount the shared canvas")
}
// Canvas lookup must consider both ids, or drag/drop silently no-ops on
// whichever surface is not #scr-canvas.
if !strings.Contains(src, "function sortCanvasEl(") ||
!strings.Contains(src, `getElementById("key-auto-canvas")`) {
t.Fatal("sortCanvasEl must resolve both canvas ids")
}
// The rules<->lanes conversion is shared too, so the per-key editor sends
// exactly the fields the server understands (quota/period/hours included).
for _, fn := range []string{"function buildLanes(", "function lanesToRules("} {
if !strings.Contains(src, fn) {
t.Fatalf("%s must be shared between the global and per-key editors", fn)
}
}
}
// Saving must route through persistAuto's scope switch, not a hand-rolled
// request, or the two editors will serialize rules differently.
func TestUIPerKeyAutoSavesThroughScope(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "sortState.scope = key") {
t.Fatal("openKeyAutoModal must set sortState.scope so the save targets the key")
}
if !strings.Contains(src, "sortState.scope = null") {
t.Fatal("closing the modal must clear sortState.scope, or the GLOBAL editor would save to this key")
}
if !strings.Contains(src, "if (sortState.scope)") {
t.Fatal("persistAuto must branch on sortState.scope")
}
if !strings.Contains(src, `"/auto"`) {
t.Fatal("per-key editor must call the /api/keys/{key}/auto endpoint")
}
// An empty lane list is how "inherit the global chain" is expressed.
if !strings.Contains(src, "JSON.stringify({ auto: lanesToRules(sortState.lanes) })") {
t.Fatal("keyAutoSave must PUT lanesToRules(sortState.lanes) verbatim")
}
}
// Both languages need the strings, otherwise a non-Chinese admin sees raw
// i18n keys in the modal.
func TestUIPerKeyAutoHasBothLanguages(t *testing.T) {
src := uiSource(t)
for _, k := range []string{
"kAutoChainTitle", "kAutoChainHint", "kAutoChainInherit",
"kAutoChainEmpty", "kAutoChainGlobal", "kAutoChainBad",
} {
n := strings.Count(src, k+`: "`)
if n < 2 {
t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n)
}
}
}
// The statistics dashboard must print the window it actually covers. Calendar
// periods are not nested — "this week" starts on its Monday and therefore
// reaches into the previous month early in a month, making its total LARGER
// than "this month". That is correct arithmetic, but without the dates on
// screen it reads as a bug, and it has been reported as one.
func TestUIStatsShowsPeriodWindow(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, `id="period-range"`) {
t.Fatal("the stats dashboard has no element for the period window range")
}
if n := strings.Count(src, "periodRange"); n < 2 {
t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n)
}
}
// Both canvases can exist in the DOM at once: the modal is mounted on top of
// the settings tab, whose #scr-canvas stays alive behind it. Resolving the
// canvas by document order (whichever comes first) painted the per-key chain
// into the hidden background tab and left the dialog empty — and worse,
// overwrote the global chain the user returns to. The lookup must follow
// sortState.scope instead.
func TestUISortCanvasResolvesByScope(t *testing.T) {
src := uiSource(t)
el := readFuncBody(t, src, "sortCanvasEl")
if !strings.Contains(el, "if (sortState.scope)") {
t.Fatal("sortCanvasEl must branch on sortState.scope, not document order")
}
if strings.Contains(el, `getElementById("scr-canvas")`) &&
strings.Index(el, `getElementById("scr-canvas")`) < strings.Index(el, "sortState.scope") {
t.Fatal("sortCanvasEl returns #scr-canvas before checking scope — the " +
"per-key editor paints into the global canvas")
}
}
// A successful save must close the dialog even when the slots do not resolve.
// The write DID succeed; leaving the modal open makes the user click Save
// again and again. This regressed once: the !slots branch returned early.
func TestUIKeyAutoSaveClosesOnSuccess(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "keyAutoSave")
// keyAutoClose() must be reached on every success path, not only in the
// happy branch.
iClose := strings.Index(body, "keyAutoClose()")
if iClose < 0 {
t.Fatal("keyAutoSave never calls keyAutoClose — the dialog would stay open")
}
iToast := strings.Index(body, "toast(")
if iToast >= 0 && iClose < iToast {
t.Fatal("keyAutoClose is called before the toast; the warning must be " +
"shown first or it is lost with the modal")
}
// An early `return` inside a branch bypasses keyAutoClose entirely, so
// merely finding the call is not enough: every `return` that sits between
// entering the try block and the close call is a path that leaves the
// dialog open after a successful write.
tryStart := strings.Index(body, "try {")
seg := body[tryStart:iClose]
for _, line := range strings.Split(seg, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "return;" || strings.HasPrefix(trimmed, "return ") {
t.Fatal("a `return` before keyAutoClose leaves the dialog open after " +
"a successful save: " + trimmed)
}
}
}
// readFuncBody returns the source of a top-level function by name, using
// brace matching so nested blocks do not truncate it.
func readFuncBody(t *testing.T, src, name string) string {
t.Helper()
idx := strings.Index(src, "function "+name+"(")
if idx < 0 {
t.Fatalf("function %s not found", name)
}
start := strings.Index(src[idx:], "{")
if start < 0 {
t.Fatalf("function %s has no body", name)
}
i := idx + start
depth := 0
for j := i; j < len(src); j++ {
switch src[j] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return src[i : j+1]
}
}
}
t.Fatalf("function %s body is unterminated", name)
return ""
}
// The per-key dialog must not share the app-wide #modal-wrap id.
//
// Every dialog in this file uses that id, and more than one can be open at
// once (the "add slot" picker opens ON TOP of this one). Handlers resolve
// their own dialog via `.closest()` from their button, which works — but this
// dialog is also looked up from the CANVAS side (sortCanvasEl, keyAutoClose,
// keyAutoInherit), where there is no button to walk up from.
// getElementById("#modal-wrap") then returns whichever dialog comes first in
// the document, i.e. not the one being edited: Cancel closed the wrong box and
// the canvas could resolve to the picker's.
func TestUIKeyAutoModalHasOwnID(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "openKeyAutoModal")
if !strings.Contains(body, `wrap.id = "key-auto-modal"`) {
t.Fatal("openKeyAutoModal must give its dialog its own id, not the shared " +
"#modal-wrap (another dialog may be open on top of it)")
}
close := readFuncBody(t, src, "keyAutoClose")
if strings.Contains(close, `"modal-wrap"`) {
t.Fatal("keyAutoClose resolves the shared #modal-wrap; it would close " +
"whichever dialog comes first in the document")
}
if !strings.Contains(close, `"key-auto-modal"`) {
t.Fatal("keyAutoClose must resolve the per-key dialog by its own id")
}
}
// Editing a chain inside the per-key dialog must NOT persist it. That dialog
// has Save and Cancel, so auto-writing on add/drag/delete meant the change was
// already committed before Save and Cancel could not undo it.
//
// The global page is the opposite: it has always applied edits immediately.
func TestUIKeyAutoEditsDoNotAutoPersist(t *testing.T) {
src := uiSource(t)
body := readFuncBody(t, src, "afterChainEdit")
if !strings.Contains(body, "if (sortState.scope)") {
t.Fatal("afterChainEdit must branch on sortState.scope")
}
// The scoped branch must return before reaching persistAuto.
iScope := strings.Index(body, "if (sortState.scope)")
iReturn := strings.Index(body[iScope:], "return")
iPersist := strings.Index(body, "persistAuto()")
if iScope < 0 || iReturn < 0 || iPersist < 0 {
t.Fatal("afterChainEdit no longer has the expected shape")
}
if iScope+iReturn > iPersist {
t.Fatal("the scoped branch reaches persistAuto() — a per-key edit would " +
"be written before Save")
}
// And it must not call itself (a regex-driven rewrite did exactly that).
if strings.Contains(body, "afterChainEdit()") {
t.Fatal("afterChainEdit calls itself: infinite recursion")
}
// Every in-canvas edit goes through it, so none of them bypass the check.
for _, fn := range []string{"scrAddFromForm", "sortScopeSave", "scrDelSlot"} {
b := readFuncBody(t, src, fn)
if strings.Contains(b, "persistAuto()") {
t.Fatalf("%s calls persistAuto() directly, bypassing afterChainEdit", fn)
}
}
}