mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
两件事。 一、per-key AUTO 链的 UI 之前自己写了一套表格,只有 model + tier 两列, 比全局 AUTO 编辑器弱:没有拖拽排序、没有每槽位的 quota/period/hours。 这是重复实现且必然漂移。改为复用同一套泳道画布: - 抽出 buildSortIndex / buildLanes / lanesToRules / renderSortEditor, 全局编辑器与 per-key 编辑器共用。 - sortState.scope 决定保存目标(null=全局,key=该密钥),persistAuto 按此分支;空 lane 列表即"恢复继承",与后端空 PUT 等价。 - 画布查找走 sortCanvasEl(),两个 id 都能解析,否则拖拽在弹窗里静默失效。 - keyAutoClose 必须清 scope,否则关掉弹窗后全局编辑器会存到这个 key。 - 删掉旧的 keyAutoRow/keyAutoAddRow/keyAutoClear。 判据改为断言"复用"而非断言具体实现,并新增两条: - 关闭弹窗不清 scope(全局编辑器会存错目标) - per-key 编辑器不再挂载共享画布 二、"周视图总量大于月视图"不是 bug:日历周期并非嵌套。"本周"从周一开始, 所以月初会回溯到上月末几天,本周总量合法地大于本月。实测今天 2026-10-03 (周六):周窗口 09-28→10-03(6 天,9.2B tokens),月窗口 10-01→10-03 (3 天,4.0B tokens)。 真正的缺陷是看不见这一点。统计页新增"统计区间:YYYY-MM-DD → YYYY-MM-DD (Nd)",让周 > 月 自解释。判据钉住这个事实,并做变异验证:把周窗口钳制到 月内(一个看起来很自然的"修复")会被两条判据同时抓住。 CDP 实测:周视图显示"2026-09-28 → 2026-10-03 (6d)",月视图显示 "2026-10-01 → 2026-10-03 (3d)";per-key 编辑器渲染出 36 条泳道 / 226 个 块,与全局编辑器同款。 Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
127 lines
5.0 KiB
Go
127 lines
5.0 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)
|
|
}
|
|
} |