fix(runtime): 运行稳定性 - 修复技能种子、任务续租与回归检查

保留社区技能作者与时间戳修复、Agent 参数约束、资源删除 worker 生命周期、PostgreSQL/Redis 测试门禁及相关回归。

Original-Commit: 0d05ff88bbe6c875d01964041788023a662ff7e8
This commit is contained in:
ddCat
2026-09-21 09:48:43 +08:00
parent 15946e5a86
commit 8b8ea29148
21 changed files with 644 additions and 354 deletions

View File

@@ -0,0 +1,34 @@
"""Require named dependency-backed Go tests to run and pass (not merely avoid SKIP)."""
import json
import sys
REQUIRED = {
("database", "TestPostgresAssetIDMigration"),
("canvas", "TestRepairAssetBytesPostgres"),
("repository", "TestCanvasRevision/postgres"),
("platform", "TestRuntimeCoordinatorRedisLeasePreservesLongestTTL"),
("app", "TestClearAdminRuntimeCacheUsesRedisWhitelist"),
}
def missing_tests(events):
ran, passed, failed = set(), set(), set()
for event in events:
key = (event.get("Package", "").rsplit("/", 1)[-1], event.get("Test"))
action = event.get("Action")
if action == "run":
ran.add(key)
elif action == "pass":
passed.add(key)
elif action in {"skip", "fail"}:
failed.add(key)
return REQUIRED - ((ran & passed) - failed)
if __name__ == "__main__":
with open(sys.argv[1], encoding="utf-8") as stream:
missing = missing_tests(json.loads(line) for line in stream if line.strip())
if missing:
sys.exit("Dependency tests missing, skipped or failed: " + repr(sorted(missing)))
print("All five dependency-backed tests ran and passed.")

View File

@@ -0,0 +1,29 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location(
"check", Path(__file__).with_name("check-dependency-tests.py")
)
check = importlib.util.module_from_spec(spec)
spec.loader.exec_module(check)
class DependencyChecks(unittest.TestCase):
def test_requires_run_and_pass_for_every_exact_name(self):
events = [
{"Package": "example/internal/" + package, "Test": name, "Action": action}
for package, name in check.REQUIRED
for action in ("run", "pass")
]
self.assertFalse(check.missing_tests(events))
self.assertTrue(check.missing_tests([]))
self.assertTrue(check.missing_tests(events[1:]))
self.assertTrue(check.missing_tests(events[:-1]))
for action in ("skip", "fail"):
self.assertTrue(check.missing_tests(events + [dict(events[-1], Action=action)]))
self.assertTrue(check.missing_tests([dict(e, Test=e["Test"] + "Renamed") for e in events]))
if __name__ == "__main__":
unittest.main()

View File

@@ -55,14 +55,8 @@ jobs:
- name: Ensure dependency-backed tests were not skipped
shell: bash
run: |
set -o pipefail
go test ./internal/database/ ./internal/canvas/ ./internal/repository/ ./internal/platform/ ./internal/app/ \
-run 'TestPostgresAssetIDMigration|TestRepairAssetBytesPostgres|TestCanvasRevision|TestRuntimeCoordinatorRedisLeasePreservesLongestTTL|TestClearAdminRuntimeCacheUsesRedisWhitelist' \
-count=1 -v | tee "$RUNNER_TEMP/dependency-tests.log"
if grep -q -- "--- SKIP" "$RUNNER_TEMP/dependency-tests.log"; then
echo "::error::依赖外部服务的用例被跳过:请检查 CANVAS_TEST_POSTGRES_DSN 与 redis-server"
exit 1
fi
python3 ../.github/scripts/test_check_dependency_tests.py
python3 ../.github/scripts/check-dependency-tests.py "$RUNNER_TEMP/go-tests.jsonl"
- name: Upload Go test timings
if: always()
uses: actions/upload-artifact@v4

View File

@@ -165,7 +165,7 @@ func (s *Service) DeleteAdminResources(actor *model.User, req AdminResourceDelet
return nil, err
}
if len(deletionJobs) > 0 {
go s.drainResourceDeletionJobs(len(deletionJobs))
s.runWorkerTask(func() { s.drainResourceDeletionJobs(len(deletionJobs)) })
}
result := &AdminResourceDeleteResult{Deleted: []string{}, Blocked: []AdminResourceDeleteBlocked{}}

View File

@@ -170,7 +170,9 @@ func newAdminStorageDeleteTestService(t *testing.T) (*Service, *gorm.DB, string,
t.Fatal(err)
}
dataDir := t.TempDir()
return New(repository.New(db), dataDir), db, dataDir, admin
svc := New(repository.New(db), dataDir)
startDeletionTestWorkers(t, svc)
return svc, db, dataDir, admin
}
func assertModelCount(t *testing.T, db *gorm.DB, value any, query string, expected int64, args ...any) {

View File

@@ -2,7 +2,7 @@ package app
import (
"encoding/json"
"strings"
"reflect"
"testing"
)
@@ -24,31 +24,37 @@ func TestCloudAgentToolSchemaStaysCompact(t *testing.T) {
tools, raw := platformToolSchema(t)
t.Logf("platform tool schema: %d tools, %d bytes", len(tools), len(raw))
// 顶层 type 枚举 + 服务端按 action 强校验已经表达了"每种操作要哪些字段",
// 再挂一份 oneOf 只是重复:同一个约束会被下发两遍。
// 条件必填不是 type 枚举能表达的约束,压缩描述不能删掉协议语义。
found := false
for _, tool := range tools {
function, _ := tool["function"].(map[string]any)
if function["name"] != "canvas_apply_ops" {
continue
}
found = true
parameters, _ := function["parameters"].(map[string]any)
ops, _ := parameters["properties"].(map[string]any)
items, _ := ops["ops"].(map[string]any)
opItem, _ := items["items"].(map[string]any)
if _, exists := opItem["oneOf"]; exists {
t.Fatal("canvas_apply_ops 的 ops.items 不该再带重复的 oneOf")
want := []map[string]any{
{"properties": map[string]any{"type": map[string]any{"const": "add_node"}}, "required": []string{"nodeType"}},
{"properties": map[string]any{"type": map[string]any{"const": "update_node"}}, "required": []string{"patch"}},
{"properties": map[string]any{"type": map[string]any{"const": "connect_nodes"}}, "required": []string{"fromNodeId", "toNodeId"}},
}
if !reflect.DeepEqual(opItem["oneOf"], want) {
t.Fatalf("操作类型条件必填约束丢失: %#v", opItem["oneOf"])
}
if props, ok := opItem["properties"].(map[string]any); !ok || len(props) == 0 {
t.Fatal("canvas_apply_ops 的 ops.items 必须保留 properties")
}
}
if !found {
t.Fatal("canvas_apply_ops 未暴露")
}
// 体积预算:当前 20 个工具约 22.4 KB,这里留 ~7% 余量;新增工具或字段时请重新测量并
// 有意识地调整这个数字,而不是让 schema 悄悄膨胀(它每一步都要发、还在前缀最前面)。
if len(raw) > 24000 {
t.Fatalf("平台工具 schema 体积 %d 字节超出预算 24000:请压缩描述或显式调整预算", len(raw))
}
if strings.Count(string(raw), "oneOf") > 2 {
t.Fatalf("工具 schema 里出现了过多 oneOf:%s", string(raw[:200]))
}
}

View File

@@ -251,6 +251,11 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
"properties": opProperties,
"required": []string{"type", "id"},
"additionalProperties": false,
"oneOf": []map[string]any{
{"properties": map[string]any{"type": map[string]any{"const": "add_node"}}, "required": []string{"nodeType"}},
{"properties": map[string]any{"type": map[string]any{"const": "update_node"}}, "required": []string{"patch"}},
{"properties": map[string]any{"type": map[string]any{"const": "connect_nodes"}}, "required": []string{"fromNodeId", "toNodeId"}},
},
}
add("canvas_apply_ops", "创建空白节点、修改提示词或建立引用连线,不提交生成任务、不产生生成费用;先读取画布并传 snapshotHash。提交媒体生成使用 generate_media。每次最多20项,禁止删除、任意 metadata 和媒体 URL。每项都需要 type 和 id:add_node 还需要 nodeType,update_node 还需要按节点能力清单填写 patch,connect_nodes 还需要 fromNodeId 与 toNodeId。连线是生成输入关系,不会改变已提交任务的输入;来源须 canSource,目标须 canTarget 且接受来源 inputKind,能力以注册表为准。", map[string]any{"snapshotHash": str("canvas_get_state返回的snapshotHash"), "ops": map[string]any{"type": "array", "maxItems": 20, "items": opItem}}, "snapshotHash", "ops")
}

View File

@@ -146,7 +146,8 @@ func (s *Service) deleteUserAssetWithResources(userID string, assetID string) er
return fmt.Errorf("素材记录删除失败,请重试:%w", err)
}
if len(deletionJobs) > 0 {
go s.drainResourceDeletionJobs(len(deletionJobs))
// 纳入停机等待;排空期间拒绝新 worker 时,已提交的 outbox 留待下次启动处理。
s.runWorkerTask(func() { s.drainResourceDeletionJobs(len(deletionJobs)) })
}
return nil
}

View File

@@ -513,5 +513,7 @@ func newResourceDeletionTestService(t *testing.T) (*Service, *gorm.DB, string) {
t.Fatal(err)
}
dataDir := t.TempDir()
return New(repository.New(db), dataDir), db, dataDir
svc := New(repository.New(db), dataDir)
startDeletionTestWorkers(t, svc)
return svc, db, dataDir
}

View File

@@ -126,7 +126,7 @@ func (s *Service) cleanupDetachedUserResources(userID string, candidates []model
}
log.Printf("detached resource cleanup: removed %d resource rows for user %s", len(detached), userID)
if len(deletionJobs) > 0 {
go s.drainResourceDeletionJobs(len(deletionJobs))
s.runWorkerTask(func() { s.drainResourceDeletionJobs(len(deletionJobs)) })
}
return nil
}

View File

@@ -227,16 +227,11 @@ func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task, globalSlot
if code, _ := ChannelSlotFailureDetails(err); code != "" {
channelSlotFailedBeforeRequest = true
}
// 我方执行时限到点不是"租约丢失":续租请求可能正好被同一个时限打断,
// 此时必须继续走失败收尾,否则任务停在 running、租约过期后被反复重跑。
deadlineExpired := errors.Is(ctx.Err(), context.DeadlineExceeded)
// 续租使用独立 context;即使执行同时超时,真实租约失效仍必须阻止写入。
select {
case leaseErr := <-leaseLost:
if !deadlineExpired {
_ = s.log(task.UserID, task.ID, "warn", "任务租约失效,等待其他 worker 恢复", leaseErr.Error())
return leaseErr
}
_ = s.log(task.UserID, task.ID, "warn", "任务执行时限到点,按失败收尾(不视为租约失效)", leaseErr.Error())
_ = s.log(task.UserID, task.ID, "warn", "任务租约失效,等待其他 worker 恢复", leaseErr.Error())
return leaseErr
default:
}
decryptedInput, decryptErr := s.decryptTaskInputJSON(task.InputJSON)

View File

@@ -2,8 +2,12 @@ package app
import (
"context"
"errors"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
// 续租必须比"这条任务的执行时限"活得更久:否则父 context 到点时,续租请求会被自己的
@@ -39,3 +43,66 @@ func TestTaskLeaseRenewContextSurvivesExecutionDeadline(t *testing.T) {
t.Fatalf("父 context 取消不该连带取消续租 context:%v", err)
}
}
func TestTaskDeadlineRenewalAndTerminalFencing(t *testing.T) {
for _, lost := range []bool{false, true} {
name := "deadline_with_valid_lease"
if lost {
name = "deadline_with_reclaimed_lease"
}
t.Run(name, func(t *testing.T) {
db := newSQLiteTestDB(t)
if err := db.AutoMigrate(&model.Task{}); err != nil {
t.Fatal(err)
}
repo := repository.New(db)
if err := db.Create(&model.Task{ID: "deadline-task", UserID: "user", Type: "canvas_text", Status: model.TaskStatusQueued}).Error; err != nil {
t.Fatal(err)
}
task, err := repo.ClaimNextTask("worker-a", time.Minute)
if err != nil || task == nil {
t.Fatalf("claim: %v %v", task, err)
}
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
renew, cancelRenew := taskLeaseRenewContext(ctx)
defer cancelRenew()
if lost {
if err := db.Model(&model.Task{}).Where("id = ?", task.ID).Update("lease_expires_at", time.Now().Add(-time.Second)).Error; err != nil {
t.Fatal(err)
}
reclaimed, err := repo.ClaimNextTask("worker-b", time.Minute)
if err != nil || reclaimed == nil {
t.Fatalf("reclaim: %v %v", reclaimed, err)
}
}
err = repo.WithContext(renew).RenewTaskLease(task.ID, task.LeaseOwner, time.Minute)
if (err != nil) != lost {
t.Fatalf("renew lost=%v: %v", lost, err)
}
billing, replay := &taskTerminalBillingStub{}, &taskTerminalReplayStub{}
terminal := newTaskTerminalCoordinatorForTest(repo, billing, replay, &taskTerminalLoggerStub{}, &taskTerminalOutputStub{})
err = terminal.handleExecutionFailure(task, ctx.Err(), false, false)
stored, readErr := repo.Task(task.ID)
if readErr != nil {
t.Fatal(readErr)
}
if lost {
if !errors.Is(err, repository.ErrTaskStateConflict) || stored.Status != model.TaskStatusRunning || stored.LeaseOwner != "worker-b" {
t.Fatalf("stale terminal write: %v %#v", err, stored)
}
if len(billing.refund)+len(billing.uncertain)+len(replay.statuses) != 0 {
t.Fatal("stale worker emitted terminal side effects")
}
} else {
if !errors.Is(err, context.DeadlineExceeded) || stored.Status != model.TaskStatusFailed {
t.Fatalf("deadline not finalized: %v %#v", err, stored)
}
next, err := repo.ClaimNextTask("worker-b", time.Minute)
if err != nil || next != nil {
t.Fatalf("terminal task reclaimed: %v %v", next, err)
}
}
})
}
}

View File

@@ -1,8 +1,8 @@
package app
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
@@ -21,27 +21,43 @@ import (
// 而当前驱动没有启用。实测并发读写 400 次能撞到 70+ 次,CI 上因此偶发失败
// (TestDeleteGeneratedAssetTaskReferences/cancelled_output 就是这样挂的)。
//
// 因此凡是会触发后台删除 outbox(`go s.drainResourceDeletionJobs`)的用例,统一用
// 因此凡是会触发后台删除 outbox 的用例,统一用
// 文件库 + WAL + busy_timeout:WAL 允许"一写多读"并发,写冲突退化成 SQLITE_BUSY 并由
// busy_timeout 重试。
//
// 库文件刻意放在 `os.MkdirTemp` 而不是 `t.TempDir()` 下:outbox goroutine 可能在用例返回后
// 仍在写库,`t.TempDir()` 的清理遇到这种写入会以 "directory not empty" 直接判失败,
// 而它本身与用例断言无关。
// 清理顺序为等待 service worker、关闭 SQL 连接、删除临时目录,不忽略清理错误。
func newSQLiteTestDB(t *testing.T) *gorm.DB {
t.Helper()
directory, err := os.MkdirTemp("", "canvas-test-db-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(directory) })
directory := t.TempDir()
db, err := gorm.Open(sqlite.Open(filepath.Join(directory, "test.db")+"?_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close test database: %v", err)
}
})
return db
}
func startDeletionTestWorkers(t *testing.T, svc *Service) {
t.Helper()
// 只启用生命周期跟踪,不启动生成、支付等无关的周期 worker。
svc.backgroundWorkers().Start()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := svc.StopWorker(ctx); err != nil {
t.Errorf("stop deletion workers: %v", err)
}
})
}
// TestSQLiteTestDBAllowsConcurrentReadWrite 守的是 newSQLiteTestDB 的选型:并发读写不得报锁。
// 换回 `mode=memory&cache=shared` 会立刻变红(见上面的机制说明)。
func TestSQLiteTestDBAllowsConcurrentReadWrite(t *testing.T) {

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,86 @@
package skills
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestBuiltinSeedSyncPreservesUserState(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "skills.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := sqlDB.Close(); err != nil {
t.Error(err)
}
})
if err := db.AutoMigrate(&model.Skill{}, &model.UserSkillState{}); err != nil {
t.Fatal(err)
}
svc := New(repository.New(db), t.TempDir(), nil)
var definitions []builtinSkillDefinition
if err := json.Unmarshal(builtinSkillsJSON, &definitions); err != nil {
t.Fatal(err)
}
community := 0
for _, def := range definitions {
if def.OwnerUID == "" || def.OwnerUID != def.EffectiveUser.UID {
t.Fatalf("invalid author identity: %s", def.SkillID)
}
for _, stamp := range []int64{def.CreateTime, def.UpdateTime} {
if time.UnixMilli(stamp).Year() < 2020 || time.UnixMilli(stamp).After(time.Now().Add(24*time.Hour)) {
t.Fatalf("invalid millisecond timestamp for %s: %d", def.SkillID, stamp)
}
}
if def.OwnerUID == "community-itswyatt-k" {
community++
}
}
if community != 72 {
t.Fatalf("community skills = %d, want 72", community)
}
if err := svc.EnsureBuiltinSkills(); err != nil {
t.Fatal(err)
}
state := model.UserSkillState{ID: "test-state", UserID: "test-user", SkillID: "16000000000001", Added: true, Liked: true}
if err := db.Create(&state).Error; err != nil {
t.Fatal(err)
}
if err := svc.EnsureBuiltinSkills(); err != nil {
t.Fatal(err)
}
var count int64
if err := db.Model(&model.Skill{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if want := int64(len(definitions) + len(builtinImageEditingSkillDefinitions())); count != want {
t.Fatalf("seed count = %d, want %d", count, want)
}
var saved model.UserSkillState
if err := db.First(&saved, "id = ?", state.ID).Error; err != nil {
t.Fatal(err)
}
if !saved.Added || !saved.Liked {
t.Fatalf("user state lost: %#v", saved)
}
var skill model.Skill
if err := db.First(&skill, "id = ?", state.SkillID).Error; err != nil {
t.Fatal(err)
}
if skill.OwnerID != "community-itswyatt-k" || skill.CreatedAt.Year() != 2026 {
t.Fatalf("invalid persisted seed: %#v", skill)
}
}

View File

@@ -5,6 +5,12 @@ description: 在宿主机启动影策前后端开发服务
# 本地开发
## Compose 公网资源地址
`docker-compose.yml`、`docker-compose.dev.yml`、`docker-compose.local.yml`、`docker-compose.server.yml` 和 `docker-compose.deploy.yml` 均将 `.env` 中的 `CANVAS_PUBLIC_BASE_URL` 传入后端。使用本地文件存储且上游模型需要读取参考素材时,将它设为上游能访问的 HTTPS 站点根地址(例如 `https://canvas.example.com`,不要附加 `/api`);存储设置中的公网访问地址优先于该环境变量。
修改后需要重新创建后端容器,单纯重启旧容器不会更新环境变量。未配置时保持空值并由资源请求强校验,不会自动采用容器内网地址;这项设置不放宽出站 SSRF 校验,也不应为此开放数据库或后端端口。只做本地界面开发且不向模型提交资源时可以不设置。部署后仍需验证真实资源 URL 能被目标供应商读取,健康检查不能替代该验证。
## 一键启动(Windows PowerShell)
在仓库根目录执行:

View File

@@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## PR #554–#571 批量整合
- 合入除 #557 外的 17 个 PR;注册协议正文未确认,暂不引入空协议勾选、相关 API 或数据库版本变更。
- 修正社区技能的 72 组空作者 ID 和秒级时间戳;使用独立外部作者标识 `community-itswyatt-k`(不是登录账号或权限兜底),保留原作者署名与来源。增加完整种子落库及重复同步保留收藏/加入状态的回归。
- 保留 Agent 操作 Schema 的按类型条件必填规则,并保持体积预算;续租独立于任务执行时限,但真实租约失效仍拒绝写终态。资源删除即时 outbox 处理纳入 worker 生命周期,测试先等待 worker,再关闭数据库和删除目录。
- CI 从 Go JSON 事件验证五个 PostgreSQL/Redis 指定测试确实运行且通过,缺失、改名、跳过均失败,不再只搜索 SKIP 文本。
- [ ] 本批仍需真实页面验收:设置页导航后的浮层、Tooltip 鼠标与键盘焦点、移动端 dock、模型价格与选择、站点图标;真实上游 SSE、视频音频参数、视频资源转存和公网素材访问不由单元测试代替。既有各 PR 的部署记录不代表本批整合已经部署。
## Agent 工具自动纠错与折叠展示
- [x] 对尚未产生副作用的参数、画布操作及快照错误增加按工具计数的有限自动纠错:首次调用加两次修正机会,中途读取画布不重置计数;第三次仍失败才以 `tool_retry_exhausted` 终止。权限、安全、存储错误和已提交收费任务保持不可自动重试。

View File

@@ -18,9 +18,14 @@ export function creationVideoConfig(config: AiConfig, model: string, settings: P
size: normalized.ratio,
videoSeconds: normalized.seconds,
vquality: normalized.resolution.replace(/p$/i, ""),
...resolveModelVideoBooleanOptions(config, model, {}, {
videoGenerateAudio: config.videoGenerateAudio,
videoWatermark: config.videoWatermark,
}),
...resolveModelVideoBooleanOptions(
config,
model,
{},
{
videoGenerateAudio: config.videoGenerateAudio,
videoWatermark: config.videoWatermark,
},
),
};
}

View File

@@ -15,11 +15,18 @@ function videoConfig(generateAudio: { supported: boolean; default: boolean }): A
videoModel: "gravity::minimax-h3-a",
videoGenerateAudio: "true",
videoWatermark: "true",
channels: [{
id: "gravity", name: "万有引力", baseUrl: "/api", apiKey: "", apiFormat: "openai", scope: "system",
models: ["minimax-h3-a"],
modelCosts: [{ model: "minimax-h3-a", capability: "video", billingMode: "per_second", unitPriceMicrocredits: 1, capabilityConfig }],
}],
channels: [
{
id: "gravity",
name: "万有引力",
baseUrl: "/api",
apiKey: "",
apiFormat: "openai",
scope: "system",
models: ["minimax-h3-a"],
modelCosts: [{ model: "minimax-h3-a", capability: "video", billingMode: "per_second", unitPriceMicrocredits: 1, capabilityConfig }],
},
],
};
}

View File

@@ -1,5 +1,8 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { FloatingDock } from "../src/components/ui/aceternity/floating-dock";
// 指针能力探测是纯增强:`window` 存在不代表 `matchMedia` 存在(SSR 垫片、嵌入式 webview、
// 部分 DOM polyfill 下它是 undefined),而 FloatingDock 在**渲染期**就取
@@ -15,4 +18,17 @@ describe("floating dock pointer capability probe", () => {
expect(source).not.toMatch(/window\.matchMedia\("\(pointer: coarse\)"\)\.matches/);
expect(source).not.toMatch(/const media = window\.matchMedia/);
});
test("renders the real dock with a window shim lacking matchMedia", () => {
const original = Object.getOwnPropertyDescriptor(globalThis, "window");
try {
Object.defineProperty(globalThis, "window", { configurable: true, value: new EventTarget() });
const markup = renderToStaticMarkup(<FloatingDock items={[{ id: "probe", label: "操作", icon: <span>+</span> }]} />);
expect(markup).toContain("操作");
expect(markup).toContain("button");
} finally {
if (original) Object.defineProperty(globalThis, "window", original);
else Reflect.deleteProperty(globalThis, "window");
}
});
});

View File

@@ -8,16 +8,31 @@ import { createModelChannel, defaultConfig, normalizeConfigSnapshot } from "../s
function fixture() {
const channel = createModelChannel({
id: "comfy", name: "Comfy", scope: "system", models: ["h3-multi"],
modelCosts: [{
model: "h3-multi", displayName: "MiniMax H3", channelLabel: "多图一致性", capability: "video",
pricePolicy: "channel", billingMode: "per_second", unitPriceMicrocredits: 100_000,
logicalPriceTiers: [480, 720].map((resolution, index) => ({
selector: { vquality: resolution + "p", videoSeconds: "6" }, resolution: resolution + "p", videoSeconds: 6,
billingMode: "per_second", unitPriceMicrocredits: (index + 1) * 100_000,
inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 0, cachedTokenPriceMicrocredits: 0,
})),
}],
id: "comfy",
name: "Comfy",
scope: "system",
models: ["h3-multi"],
modelCosts: [
{
model: "h3-multi",
displayName: "MiniMax H3",
channelLabel: "多图一致性",
capability: "video",
pricePolicy: "channel",
billingMode: "per_second",
unitPriceMicrocredits: 100_000,
logicalPriceTiers: [480, 720].map((resolution, index) => ({
selector: { vquality: resolution + "p", videoSeconds: "6" },
resolution: resolution + "p",
videoSeconds: 6,
billingMode: "per_second",
unitPriceMicrocredits: (index + 1) * 100_000,
inputTokenPriceMicrocredits: 0,
outputTokenPriceMicrocredits: 0,
cachedTokenPriceMicrocredits: 0,
})),
},
],
});
return normalizeConfigSnapshot({ config: { ...defaultConfig, channels: [channel], model: "comfy::h3-multi", videoModel: "comfy::h3-multi" } }).config;
}
@@ -27,8 +42,7 @@ const requirements: ModelRequirements = { capability: "video", videoSeconds: "5"
test("candidate rows show their configured prices instead of applying the previous model's specifications", () => {
const config = fixture();
for (const theme of [canvasThemes.light, canvasThemes.dark]) {
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={theme}
creationVariant showConfiguredModelName={false} requirements={requirements} showPrice showDescription />);
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={theme} creationVariant showConfiguredModelName={false} requirements={requirements} showPrice showDescription />);
expect(markup).toContain("0.1-0.2 积分/秒");
expect(markup).not.toContain("当前规格无报价");
}
@@ -37,19 +51,16 @@ test("candidate rows show their configured prices instead of applying the previo
test("candidate rows do not disguise missing prices as zero", () => {
const config = fixture();
config.channels[0].modelCosts![0].logicalPriceTiers = [];
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.dark}
creationVariant showConfiguredModelName={false} requirements={requirements} showPrice showDescription />);
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.dark} creationVariant showConfiguredModelName={false} requirements={requirements} showPrice showDescription />);
expect(markup).toContain("未配置");
expect(markup).not.toContain("0 积分");
});
test("the selected model and request estimate still require an exact specification match", () => {
const config = fixture();
const render = (value: ModelRequirements) => renderToStaticMarkup(<ModelPicker config={config} value={config.model} capability="video"
requirements={value} onChange={() => {}} />);
const render = (value: ModelRequirements) => renderToStaticMarkup(<ModelPicker config={config} value={config.model} capability="video" requirements={value} onChange={() => {}} />);
expect(render(requirements)).toContain("当前规格无报价");
const cost = (value: ModelRequirements) => requestCreditCost({ channelMode: "remote", modelCosts: config.channels[0].modelCosts,
model: "h3-multi", capability: "video", config, requirements: value, seconds: "6" });
const cost = (value: ModelRequirements) => requestCreditCost({ channelMode: "remote", modelCosts: config.channels[0].modelCosts, model: "h3-multi", capability: "video", config, requirements: value, seconds: "6" });
expect(cost(requirements)).toBeNull();
const matching = { ...requirements, videoSeconds: "6", options: { vquality: "720p" } };
expect(render(matching)).toContain("0.2 积分/秒");