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) } } } // The stats range line formats its end date from the LAST bucket label. // Bucket labels are hourly for the day view ("2026-10-03T09") and daily for // week/month ("2026-10-01"), so blindly appending "T00:00:00Z" to the hourly // form builds "2026-10-03T09T00:00:00Z" — Date rejects it, toISOString() // throws, and the assignment is lost. The symptom is an empty range line on // exactly the view people look at most, with no error anywhere. func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) { src := uiSource(t) el := readFuncBody(t, src, "paintStats") // It must branch on the label shape instead of concatenating blindly. if !strings.Contains(el, `T\d{2}$`) && !strings.Contains(el, `\d{2}-\d{2}T\d{2}`) { t.Fatal("paintStats must recognise the hourly bucket label shape " + "(YYYY-MM-DDTHH); concatenating a time onto it yields an invalid Date") } // And it must not contain the naive form that broke it. if strings.Contains(el, `bucket + "T00:00:00Z"`) { t.Fatal("paintStats still appends T00:00:00Z to the bucket label — " + "this throws on the day view and leaves the range line empty") } } // A key with no chain of its own must open on a COPY OF THE GLOBAL CHAIN, not // on an empty canvas. The realistic admin task is "this user gets the global chain // minus the sources they must not hit"; starting blank forces retyping every // tier against a 226-model picker. This is deliberately NOT the discovery // fallback (which dumps every model of every source) — it is the same rules // the global chain actually runs. func TestUIKeyAutoSeedsFromGlobalChain(t *testing.T) { src := uiSource(t) body := readFuncBody(t, src, "openKeyAutoModal") if !strings.Contains(body, `api("/api/auto")`) { t.Fatal("openKeyAutoModal must read the global chain to seed the editor") } if !strings.Contains(body, "seededFromGlobal") { t.Fatal("the editor must remember whether its content came from the " + "global chain, so the notice can say so") } // And it must not fall back to the discovery path that fills 226 models. if strings.Contains(body, `buildLanes(idx, [], `) { t.Fatal("seeding from an empty rule list triggers buildLanes discovery") } } // The notice must distinguish "already this key's chain" from "a copy that // only becomes its chain on Save" — those two look identical on the canvas and // behave very differently if the admin hits Cancel. func TestUIKeyAutoNoticeDistinguishesSavedFromCopied(t *testing.T) { src := uiSource(t) body := readFuncBody(t, src, "keyAutoInheritNotice") for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} { if !strings.Contains(body, k) { t.Fatalf("the notice never uses %s — a copied chain and a saved one "+ "would look the same", k) } } // Both languages, or a non-Chinese admin sees raw keys. for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} { if n := strings.Count(src, k+`: "`); n < 2 { t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n) } } }