From 4872deb08e9402038fd2e2c9750433207184f08e Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 9 Oct 2026 11:38:40 +0800 Subject: [PATCH] Align task schedule optional fields with shared schema --- .../dispatcher_integration_test.go | 3 + contracts/schema | 2 +- contracts/task_schedule_schema_test.go | 59 ++++++++ docs/contracts.md | 6 + .../schema-optional-task-schedule-20261009.md | 91 +++++++++++ internal/configread/snapshots_test.go | 141 +++++++++++------- internal/dispatcher/policy_test.go | 61 ++++++++ 7 files changed, 304 insertions(+), 59 deletions(-) create mode 100644 contracts/task_schedule_schema_test.go create mode 100644 docs/evidence/schema-optional-task-schedule-20261009.md diff --git a/cmd/sip-go-agent/dispatcher_integration_test.go b/cmd/sip-go-agent/dispatcher_integration_test.go index ead2947..daa783b 100644 --- a/cmd/sip-go-agent/dispatcher_integration_test.go +++ b/cmd/sip-go-agent/dispatcher_integration_test.go @@ -210,6 +210,9 @@ func TestDispatcherCommandStartsWithIsolatedMQHTTPAndAgent(t *testing.T) { taskSchedule := taskConfig["schedule"].(map[string]any) taskSchedule["starts_at"] = "2000-01-01T00:00:00+08:00" taskSchedule["weekly_windows"] = weekly + // Exercise the current contract through HTTP, persistence, MQ and mTLS. + delete(taskSchedule, "ends_at") + delete(taskSchedule, "excluded_dates") if examples["config-read-task-asr.json"], err = json.Marshal(taskConfig); err != nil { t.Fatal(err) } diff --git a/contracts/schema b/contracts/schema index b502ad2..adc49ac 160000 --- a/contracts/schema +++ b/contracts/schema @@ -1 +1 @@ -Subproject commit b502ad2d47629fece4542b117c90509f005eaedb +Subproject commit adc49ace34a0cdfd21c4de5a20dc6dfc1ab9b9df diff --git a/contracts/task_schedule_schema_test.go b/contracts/task_schedule_schema_test.go new file mode 100644 index 0000000..f931d5b --- /dev/null +++ b/contracts/task_schedule_schema_test.go @@ -0,0 +1,59 @@ +package contracts + +import ( + "encoding/json" + "testing" +) + +func TestTaskScheduleOptionalFieldsSchema(t *testing.T) { + schema, err := CompileCurrent("http-task-detail.schema.json") + if err != nil { + t.Fatal(err) + } + for _, mode := range []string{"asr", "full"} { + fixture, err := Files.ReadFile("schema/examples/config-read-task-" + mode + ".json") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + omit []string + set map[string]any + valid bool + }{ + {name: "explicit null end and empty exclusions", valid: true}, + {name: "omitted end", omit: []string{"ends_at"}, valid: true}, + {name: "omitted exclusions", omit: []string{"excluded_dates"}, valid: true}, + {name: "both omitted", omit: []string{"ends_at", "excluded_dates"}, valid: true}, + {name: "explicit end and exclusions", set: map[string]any{"ends_at": "2026-12-31T20:00:00+08:00", "excluded_dates": []any{"2026-10-01"}}, valid: true}, + {name: "missing start", omit: []string{"starts_at"}}, + {name: "missing time zone", omit: []string{"time_zone"}}, + {name: "missing weekly windows", omit: []string{"weekly_windows"}}, + {name: "null exclusions", set: map[string]any{"excluded_dates": nil}}, + {name: "wrong exclusions type", set: map[string]any{"excluded_dates": "2026-10-01"}}, + {name: "invalid excluded date", set: map[string]any{"excluded_dates": []any{"2026-02-30"}}}, + {name: "duplicate exclusions", set: map[string]any{"excluded_dates": []any{"2026-10-01", "2026-10-01"}}}, + {name: "invalid end", set: map[string]any{"ends_at": "not-a-time"}}, + {name: "empty end", set: map[string]any{"ends_at": ""}}, + {name: "numeric end", set: map[string]any{"ends_at": 123}}, + {name: "unknown schedule property", set: map[string]any{"unknown": true}}, + } { + t.Run(mode+"/"+tc.name, func(t *testing.T) { + var doc map[string]any + if err := json.Unmarshal(fixture, &doc); err != nil { + t.Fatal(err) + } + schedule := doc["schedule"].(map[string]any) + for _, field := range tc.omit { + delete(schedule, field) + } + for field, value := range tc.set { + schedule[field] = value + } + if err := schema.Validate(doc); (err == nil) != tc.valid { + t.Fatalf("valid=%t, want %t: %v", err == nil, tc.valid, err) + } + }) + } + } +} diff --git a/docs/contracts.md b/docs/contracts.md index 10a1101..46b56dd 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -83,3 +83,9 @@ Mock、hash 或离线验证不替代 SaaS、供应商或生产签收。 AI 连接的 HTTP 与 WebSocket 地址按调用用途分别使用,缺失时明确拒绝、不互相回退。字段定义仍只在共享仓库维护。 本轮本地验证与外部边界见 [`evidence/schema-task-trunk-concurrency-20261008.md`](evidence/schema-task-trunk-concurrency-20261008.md)。没有部署、真实拨号或真实 AI 请求。 + +## 2026-10-09 本地适配 + +共享版本 `adc49ace34a0cdfd21c4de5a20dc6dfc1ab9b9df` 的任务计划可选字段变更已用于本项目合同引用;现有调度行为与新合同一致,无需改写业务逻辑。合同正反例、配置读取、选线边界及省略字段的隔离端到端验证已补齐。 + +本轮差异、验证结果及 A01–A12/K01–K16 对照见 [`evidence/schema-optional-task-schedule-20261009.md`](evidence/schema-optional-task-schedule-20261009.md)。仅暂存了用户明确批准的合同指针,没有提交、推送、部署、真实拨号或真实服务请求。 diff --git a/docs/evidence/schema-optional-task-schedule-20261009.md b/docs/evidence/schema-optional-task-schedule-20261009.md new file mode 100644 index 0000000..6624dc3 --- /dev/null +++ b/docs/evidence/schema-optional-task-schedule-20261009.md @@ -0,0 +1,91 @@ +# 任务计划可选字段:本地合同适配与验证(2026-10-09) + +## 基线与唯一差异 + +- 起始主仓库 `main`:`6e028d4`,更新前工作区干净,已执行远程同步,未发生主仓库快进。 +- 验证时主仓库另有独立提交 `ef1c625`,仅调整 `AGENTS.md` 和独立 sip-call 部署证据,与本次修改文件无交叠;该提交及文件原样保留,本次未创建提交。 +- 共享合同:`b502ad2` → `adc49ace34a0cdfd21c4de5a20dc6dfc1ab9b9df`;`make contracts-update` 成功。 +- 唯一变化:`http-task-detail.schema.json` 的任务计划不再要求提供 `ends_at`、`excluded_dates`。属性类型、其他必填项、任务/线路时段、授权、并发及消息规则均未改变。 +- 定义仍仅由 [`contracts/schema/`](../../contracts/schema/README.md) 提供,本项目没有修改共享仓库文件或添加平行 Schema。 +- 用户追加批准:**仅暂存 `contracts/schema` 指针**,以满足现有合同验收要求;其他改动未暂存,不提交、不推送。此前未暂存指针时 `make check` 明确失败,未更改或绕过检查规则。 + +## 差异对应处理 + +| 变更或影响 | 处理结果与依据 | +| --- | --- | +| 两个字段分别省略或同时省略 | `contracts/task_schedule_schema_test.go` 分别核验 ASR-only 与完整 AI 任务;显式 null 结束时间和空排除日期继续有效。 | +| HTTP 读取与冻结快照 | `TestReadTaskSnapshot` 覆盖两种任务的四种字段组合,核验原始响应原样保存,不补结束时间或排除日期。 | +| 实际选线与通话期限 | `TestSelectTrunkOptionalTaskSchedule` 核验省略字段后的准入与期限;开始时间、每周窗口、显式结束时间及排除日期仍生效,SIP 加载和线路并发仍约束执行。 | +| 非法输入与边界 | 缺失其他必填项、非法结束时间、错误日期、重复日期、错误类型和未知字段仍拒绝;结束早于开始、到达结束边界等由现有调度测试核验。 | +| 隔离完整链路 | `TestDispatcherCommandStartsWithIsolatedMQHTTPAndAgent` 的模拟 SaaS 任务同时省略两个字段,经配置读取、持久化、RabbitMQ、双向 TLS Agent 和模拟 HTTPS 对象上传实际取得最终结果。 | +| 业务实现 | `internal/callwindow/policy.go` 已将未提供结束时间视为无额外结束限制、未提供排除日期视为无排除日期;读取层原样保留计划,Dispatcher 已调用该逻辑。无需修改运行逻辑,不新增兼容、兜底、默认值或数据迁移。 | +| 文档 | `docs/contracts.md` 增加本次适配入口;既有日期证据保留历史事实,不把旧合同路径或旧覆盖率当作本次依据。 | + +## 测试先失败再通过 + +新增 `TestTaskScheduleOptionalFieldsSchema` 后,在独立临时测试目录中仅用旧提交的任务 Schema 执行测试:ASR-only 与完整 AI 的“省略结束时间”“省略排除日期”“同时省略”六项均因缺失必填属性失败。 + +同一临时测试目录换成已更新的任务 Schema 后,该测试全部通过。真实工作区的共享合同未回退、未被临时改写;临时目录仅用于负例验证,完成后删除,不构成运行时备用合同。 + +## 本地验证结果 + +| 检查 | 结果 | +| --- | --- | +| `go test ./contracts ./internal/configread ./internal/callwindow ./internal/dispatcher -count=1` | PASS | +| `make check` | PASS:格式、Proto 生成及 7 文件清单、合同来源/离线引用/历史来源、`go mod verify`、`go vet ./...`、`go test -race ./...`、构建及实际隔离 MQ 测试。 | +| `make coverage` | PASS:排除生成文件后的业务语句覆盖率 **70.6%**,达到 ≥65%。 | +| `make release-check-local` | PASS:当前合同固定提交与本地制品/打包检查;`source_dirty=true`、`production_approval=false`。 | + +本次实际运行并通过的隔离 MQ 测试: + +- `TestBrokerSharedResultQueueAndNoConfigure` +- `TestRuntimeIsolatedControlBacklogExecuteAndSharedResult` +- `TestDispatcherCommandStartsWithIsolatedMQHTTPAndAgent` +- `TestSaaSMockPublishesOneCommandOnProvisionedRabbitMQ` +- `TestSaaSMockAwaitsExactResultBeforeStoppingCapture` + +本地发布检查报告制品 SHA-256:`025018369e0f2e3d50aecae3edd35e9dcf21e7f601e8200c1acdfefb50b1590b`。此检查制品和临时打包产物由检查脚本删除,未安装或部署。 + +## A01–A12 本次对照 + +| 项目 | 受影响范围与本次依据 | +| --- | --- | +| A01 合同一致性 | 直接影响;新 Schema 正反例及 `TestCurrentContractExamples` 通过,固定共享提交,无重复定义。 | +| A02 代次名称 | 不适用新增变更:仅消费字段可选性变化,未引入新运行入口、协议代次或旧路径。 | +| A03 固定通信名称 | 无业务变更;`TestCurrentContractTopology` 与实际隔离 MQ 回归通过。 | +| A04 来源与生成一致性 | 直接影响合同提交;`make check` 的共享来源、引用及 Proto 检查通过。 | +| A05 配置读取与失效 | 直接影响;`TestReadTaskSnapshot` 的八种组合通过;`TestReadTaskDoesNotReuseConfigAfterHTTPFailure` 随完整回归通过,无旧快照回退。 | +| A06 队列归属与路由 | 无业务变更;实际 `TestBrokerSharedResultQueueAndNoConfigure` 通过,未赋予 Dispatcher 建队列权限。 | +| A07 控制与发现竞态 | 无业务变更;`TestControlStopCannotResumeOrDispatchPending`、`TestSIPNotificationPersistsBarrierAndWaitsForLoadedFullSnapshot` 等随完整回归通过。 | +| A08 执行与 AI | 直接影响任务计划;新选线测试与省略字段的隔离完整链路通过。AI 连接、参数及对话行为未改。 | +| A09 结果与录音 | 无业务变更;省略字段的完整链路取得最终结果;`TestFinalResultNeedsConfirmedEndAndRemainsExactlyOne` 等随完整回归通过。 | +| A10 数据保护与恢复 | 无业务变更;`TestRecordingRecoveryUnknownPUTNeverRetriesAfterRestart` 等随完整回归通过;未改业务表、恢复文件或未知占用。 | +| A11 测试与构建 | 直接影响;新合同测试先失败再通过;完整本地验收、70.6% 覆盖率、发布检查通过。 | +| A12 交付边界 | 仅本地改动与验证,未部署或调用真实业务服务,未签收生产。 | + +## K01–K16 本次对照 + +| 项目 | 受影响范围与本次依据 | +| --- | --- | +| K01 队列拓扑与所有权 | 无业务变更;实际隔离 MQ 与 `TestCurrentContractTopology` 通过。 | +| K02 归属与事件身份 | 无业务变更;`TestCurrentContractExamples`、原身份持久执行测试随完整回归通过。 | +| K03 分页发现 | 无业务变更;`TestReadAllTasksContinuesAfterShortPageUntilEmptyPage` 随完整回归通过。 | +| K04 停止不可恢复 | 无业务变更;`TestControlStopCannotResumeOrDispatchPending` 随完整回归通过。 | +| K05 暂停控制 | 无业务变更;`TestControlPauseDefaultHangupAckAfterAgentDispatch` 随完整回归通过。 | +| K06 派发与通话事实 | 无业务变更;`TestExecuteWaitsForRulesThenDispatchesOriginalIdentity` 随完整回归通过;隔离完整链路取得原事件最终结果。 | +| K07 SIP 加载屏障 | 无业务变更;新选线测试确认未加载时不放行;SIP 通知持久屏障测试随完整回归通过。 | +| K08 AI 授权绑定 | 无业务变更;`TestBindCurrentRejectsUnauthorizedProviderBeforeCall` 随完整回归通过,未发真实 AI 请求。 | +| K09 关键词结束 | 无业务变更;`TestKeywordHangupOnlyFinalUserLiteralAndOrderedGroups` 随完整回归通过。 | +| K10 上传恢复 | 无业务变更;`TestRecordingRecoveryExpiresAfterExactly48HoursWithoutResult` 随完整回归通过;未更改恢复窗口。 | +| K11 SIP 事实 | 无业务变更;`TestFinalResultPayloadRejectsMissingOrInventedCallFacts` 随完整回归通过,未新增或伪造真实 SIP 证据。 | +| K12 呼出规则 | 直接影响;`TestSelectTrunkOptionalTaskSchedule` 核验省略字段、开始/结束边界、每周时段、排除日期及并发/加载门槛;缺省不等于绕过其他规则。 | +| K13 单号拒绝 | 无业务变更;`TestExecuteRejectsInvalidCalleeWithoutStoppingTask` 随完整回归通过。 | +| K14 通话结果与未知执行 | 无业务变更;`TestFinalResultPayloadNoAnswerHasNoFabricatedTranscriptOrAsset` 随完整回归通过,未知占用未清除。 | +| K15 录音生成失败 | 无业务变更;`TestRecordingDeliveryCaptureFailureDescribesFailureWithoutInventingUpload` 随完整回归通过。 | +| K16 双文件保存失败 | 无业务变更;`TestRecordingDeliveryRecoveryDiskFailureNeverReportsUploaded` 随完整回归通过。 | + +## 未验证范围 + +没有真实 SaaS/management 的配置签收、真实运营商 SIP 接通、Asterisk 实际加载、真实 ASR/LLM/TTS、OSS、主机重启、容量或生产验收。本次 RabbitMQ、HTTP/HTTPS、双向 TLS、上传与通话均为本机隔离测试,不授权或证明任何真实拨号。 + +未操作测试机,未读取私有配置,未清理或迁移既有数据,未修改其他项目。共享仓库本次仅同步读取,未替 SaaS 开发人员确认应用已同步或已完成联调。 diff --git a/internal/configread/snapshots_test.go b/internal/configread/snapshots_test.go index 7b9d854..3dc970e 100644 --- a/internal/configread/snapshots_test.go +++ b/internal/configread/snapshots_test.go @@ -37,68 +37,93 @@ func approvedSIP(t *testing.T) SIP { func TestReadTaskSnapshot(t *testing.T) { for _, mode := range []string{"asr", "full"} { - t.Run(mode, func(t *testing.T) { - responses := map[string][]byte{ - "/internal/v1/dispatcher/sip": example(t, "config-read-sip"), - "/internal/v1/dispatcher/ai-providers": example(t, "config-read-providers"), - "/internal/v1/dispatcher/task/task-" + mode: []byte(strings.Replace(string(example(t, "config-read-task-"+mode)), `"temperature":0.7`, `"temperature":0`, 1)), - "/internal/v1/dispatcher/tenant/1001/quota": example(t, "config-read-quota"), - } - calls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls++ - if r.Method != http.MethodGet || r.Header.Get("X-DISPATCHER-ID") != "c046b893-8628-4589-ae50-619d049248a6" || r.Header.Get("X-DISPATCHER-SECRET-KEY") != "test-secret" { - t.Errorf("unexpected method/headers: %s", r.URL.Path) - w.WriteHeader(http.StatusBadRequest) - return + for _, tc := range []struct { + name string + omit []string + }{ + {"explicit schedule fields", nil}, + {"omitted end", []string{"ends_at"}}, + {"omitted exclusions", []string{"excluded_dates"}}, + {"both omitted", []string{"ends_at", "excluded_dates"}}, + } { + t.Run(mode+"/"+tc.name, func(t *testing.T) { + var task map[string]any + if err := json.Unmarshal(example(t, "config-read-task-"+mode), &task); err != nil { + t.Fatal(err) } - body, ok := responses[r.URL.Path] - if !ok { - t.Errorf("unexpected path %s", r.URL.Path) - w.WriteHeader(http.StatusNotFound) - return + for _, field := range tc.omit { + delete(task["schedule"].(map[string]any), field) } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(body) - })) - defer server.Close() - client, err := NewClient(server.URL, "c046b893-8628-4589-ae50-619d049248a6", "test-secret", server.Client()) - if err != nil { - t.Fatal(err) - } - providers, err := client.ReadProviders(context.Background()) - if err != nil { - t.Fatal(err) - } - snapshot, err := client.ReadTask(context.Background(), "task-"+mode, 1001, approvedSIP(t), providers) - if err != nil { - t.Fatal(err) - } - if calls != 3 || snapshot.SIP.Revision != 8 || snapshot.Task.TenantID != 1001 || snapshot.Quota.TenantID != 1001 { - t.Fatalf("unexpected request count or identity: calls=%d snapshot=%+v", calls, snapshot) - } - if got := snapshot.Providers["volcengine"].Credential; got != "sk-******" { - t.Fatalf("credential not passed unchanged: %q", got) - } - if mode == "asr" && (snapshot.Task.Agent.Mode != "asr_only" || strings.Contains(string(snapshot.Task.Agent.Raw), `"llm"`)) { - t.Fatalf("ASR-only Agent picked up an LLM: %s", snapshot.Task.Agent.Raw) - } - if mode == "full" { - var agent struct { - LLM struct { - Params struct { - Temperature *float64 `json:"temperature"` - } `json:"params"` - } `json:"llm"` - Conversation struct { - AllowInterrupt *bool `json:"allow_interrupt"` - } `json:"conversation"` + taskBody, err := json.Marshal(task) + if err != nil { + t.Fatal(err) } - if err := json.Unmarshal(snapshot.Task.Agent.Raw, &agent); err != nil || agent.LLM.Params.Temperature == nil || *agent.LLM.Params.Temperature != 0 || agent.Conversation.AllowInterrupt == nil || *agent.Conversation.AllowInterrupt { - t.Fatalf("explicit zero/false lost: %v, %s", err, snapshot.Task.Agent.Raw) + taskBody = []byte(strings.Replace(string(taskBody), `"temperature":0.7`, `"temperature":0`, 1)) + responses := map[string][]byte{ + "/internal/v1/dispatcher/sip": example(t, "config-read-sip"), + "/internal/v1/dispatcher/ai-providers": example(t, "config-read-providers"), + "/internal/v1/dispatcher/task/task-" + mode: taskBody, + "/internal/v1/dispatcher/tenant/1001/quota": example(t, "config-read-quota"), } - } - }) + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != http.MethodGet || r.Header.Get("X-DISPATCHER-ID") != "c046b893-8628-4589-ae50-619d049248a6" || r.Header.Get("X-DISPATCHER-SECRET-KEY") != "test-secret" { + t.Errorf("unexpected method/headers: %s", r.URL.Path) + w.WriteHeader(http.StatusBadRequest) + return + } + body, ok := responses[r.URL.Path] + if !ok { + t.Errorf("unexpected path %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer server.Close() + client, err := NewClient(server.URL, "c046b893-8628-4589-ae50-619d049248a6", "test-secret", server.Client()) + if err != nil { + t.Fatal(err) + } + providers, err := client.ReadProviders(context.Background()) + if err != nil { + t.Fatal(err) + } + snapshot, err := client.ReadTask(context.Background(), "task-"+mode, 1001, approvedSIP(t), providers) + if err != nil { + t.Fatal(err) + } + if string(snapshot.Task.Raw) != string(taskBody) { + t.Fatal("task snapshot changed the HTTP response or inserted omitted schedule fields") + } + if calls != 3 || snapshot.SIP.Revision != 8 || snapshot.Task.TenantID != 1001 || snapshot.Quota.TenantID != 1001 { + t.Fatalf("unexpected request count or identity: calls=%d snapshot=%+v", calls, snapshot) + } + if got := snapshot.Providers["volcengine"].Credential; got != "sk-******" { + t.Fatalf("credential not passed unchanged: %q", got) + } + if mode == "asr" && (snapshot.Task.Agent.Mode != "asr_only" || strings.Contains(string(snapshot.Task.Agent.Raw), `"llm"`)) { + t.Fatalf("ASR-only Agent picked up an LLM: %s", snapshot.Task.Agent.Raw) + } + if mode == "full" { + var agent struct { + LLM struct { + Params struct { + Temperature *float64 `json:"temperature"` + } `json:"params"` + } `json:"llm"` + Conversation struct { + AllowInterrupt *bool `json:"allow_interrupt"` + } `json:"conversation"` + } + if err := json.Unmarshal(snapshot.Task.Agent.Raw, &agent); err != nil || agent.LLM.Params.Temperature == nil || *agent.LLM.Params.Temperature != 0 || agent.Conversation.AllowInterrupt == nil || *agent.Conversation.AllowInterrupt { + t.Fatalf("explicit zero/false lost: %v, %s", err, snapshot.Task.Agent.Raw) + } + } + }) + } } } diff --git a/internal/dispatcher/policy_test.go b/internal/dispatcher/policy_test.go index 0a06da5..355d305 100644 --- a/internal/dispatcher/policy_test.go +++ b/internal/dispatcher/policy_test.go @@ -71,6 +71,67 @@ func TestSelectTrunkSaaSEventCalleeScheduleAndCaller(t *testing.T) { } } +func TestSelectTrunkOptionalTaskSchedule(t *testing.T) { + for _, tc := range []struct { + name string + set map[string]any + at time.Time + deadline time.Time + wantErr error + }{ + {name: "both omitted", at: monday(9, 30), deadline: monday(9, 32)}, + {name: "null end", set: map[string]any{"ends_at": nil}, at: monday(9, 30), deadline: monday(9, 32)}, + {name: "empty exclusions", set: map[string]any{"excluded_dates": []string{}}, at: monday(9, 30), deadline: monday(9, 32)}, + {name: "weekly end still bounds call", at: monday(19, 59), deadline: monday(20, 0)}, + {name: "weekly end remains exclusive", at: monday(20, 0), wantErr: ErrRuleWait}, + {name: "before start", set: map[string]any{"starts_at": "2026-09-21T10:00:00+08:00"}, at: monday(9, 30), wantErr: ErrRuleWait}, + {name: "at start", set: map[string]any{"starts_at": "2026-09-21T09:30:00+08:00"}, at: monday(9, 30), deadline: monday(9, 32)}, + {name: "explicit end bounds call", set: map[string]any{"ends_at": "2026-09-21T09:31:00+08:00"}, at: monday(9, 30), deadline: monday(9, 31)}, + {name: "at explicit end", set: map[string]any{"ends_at": "2026-09-21T09:30:00+08:00"}, at: monday(9, 30), wantErr: ErrRuleWait}, + {name: "explicit excluded date", set: map[string]any{"excluded_dates": []string{"2026-09-21"}}, at: monday(9, 30), wantErr: ErrRuleWait}, + {name: "other date not excluded", set: map[string]any{"excluded_dates": []string{"2026-09-22"}}, at: monday(9, 30), deadline: monday(9, 32)}, + {name: "invalid end", set: map[string]any{"ends_at": "not-a-time"}, at: monday(9, 30), wantErr: ErrRuleInvalid}, + {name: "end before start", set: map[string]any{"ends_at": "2026-09-20T20:00:00+08:00"}, at: monday(9, 30), wantErr: ErrRuleInvalid}, + {name: "invalid excluded date", set: map[string]any{"excluded_dates": []string{"2026-02-30"}}, at: monday(9, 30), wantErr: ErrRuleInvalid}, + {name: "duplicate excluded dates", set: map[string]any{"excluded_dates": []string{"2026-09-22", "2026-09-22"}}, at: monday(9, 30), wantErr: ErrRuleInvalid}, + } { + t.Run(tc.name, func(t *testing.T) { + snapshot := policySnapshot(t) + var schedule map[string]any + if err := json.Unmarshal(snapshot.Task.Schedule, &schedule); err != nil { + t.Fatal(err) + } + delete(schedule, "ends_at") + delete(schedule, "excluded_dates") + for field, value := range tc.set { + schedule[field] = value + } + var err error + snapshot.Task.Schedule, err = json.Marshal(schedule) + if err != nil { + t.Fatal(err) + } + loaded := map[string]int64{"trunk-mock": 8} + selected, err := SelectTrunk(snapshot, "15003164745", tc.at, nil, nil, loaded) + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("schedule must be rejected with %v, got %v", tc.wantErr, err) + } + return + } + if err != nil || !selected.Deadline.Equal(tc.deadline) { + t.Fatalf("deadline=%v, want %v, err=%v", selected.Deadline, tc.deadline, err) + } + if _, err := SelectTrunk(snapshot, "15003164745", tc.at, nil, nil, nil); !errors.Is(err, ErrRuleWait) { + t.Fatalf("omitted schedule fields bypassed SIP loading: %v", err) + } + if _, err := SelectTrunk(snapshot, "15003164745", tc.at, nil, map[string]int64{"trunk-mock": 2}, loaded); !errors.Is(err, ErrRuleWait) { + t.Fatalf("omitted schedule fields bypassed task-line concurrency: %v", err) + } + }) + } +} + func TestSelectTrunkUsesEachLinesOwnCaller(t *testing.T) { snapshot := policySnapshot(t) var trunks []trunkConfig