Files
openclaw/apps/ios/Tests/AgentAutomationModelsTests.swift
Vincent Koc 8a12343efe fix(ios): stabilize Apple UI validation across runtimes (#116487)
* test(ios): align source guards with current contracts

* test(ios): stabilize cross-version UI automation

* test(ios): harden typography boundary audit

* test(ios): synchronize deterministic UI launches
2026-07-31 01:30:46 +08:00

276 lines
12 KiB
Swift

import Foundation
import OpenClawProtocol
import Testing
@testable import OpenClaw
struct AgentAutomationModelsTests {
@Test func `draft decodes editable gateway fields`() throws {
let draft = try #require(AgentAutomationDraft(job: Self.job()))
#expect(draft.name == "Release briefing")
#expect(draft.sessionTarget == "isolated")
#expect(draft.schedule == .every(everyMs: "86400000", anchorMs: "1783468800000"))
#expect(draft.payload == .agentTurn(
message: "Summarize release readiness.",
model: "openai/gpt-5.2",
thinking: ""))
}
@Test func `update includes exact revision and only normalized changes`() throws {
let job = Self.job()
var draft = try #require(AgentAutomationDraft(job: job))
draft.name = " Release briefing v2 "
draft.payload = .agentTurn(
message: "Summarize release readiness.",
model: "",
thinking: "")
let json = try buildAgentAutomationUpdateParams(job: job, draft: draft)
let root = try #require(try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any])
let patch = try #require(root["patch"] as? [String: Any])
let payload = try #require(patch["payload"] as? [String: Any])
#expect(root["id"] as? String == job.id)
#expect(root["expectedConfigRevision"] as? String == "sha256:test-revision")
#expect(patch["name"] as? String == "Release briefing v2")
#expect(payload["kind"] as? String == "agentTurn")
#expect(payload["model"] is NSNull)
#expect(payload["message"] == nil)
}
@Test func `semantic command formatting is not a change`() throws {
let job = Self.job(
payload: AnyCodable([
"kind": AnyCodable("command"),
"argv": AnyCodable([AnyCodable("openclaw"), AnyCodable("status")]),
"cwd": AnyCodable("/tmp"),
]))
var draft = try #require(AgentAutomationDraft(job: job))
draft.payload = .command(argvJSON: "[ \"openclaw\", \"status\" ]", cwd: "/tmp")
#expect(throws: AgentAutomationEditError.self) {
_ = try buildAgentAutomationUpdateParams(job: job, draft: draft)
}
}
@Test func `enable update is revision safe`() throws {
let root = try #require(
try JSONSerialization.jsonObject(
with: Data(buildAgentAutomationEnabledParams(job: Self.job(), enabled: false).utf8)) as? [String: Any])
let patch = try #require(root["patch"] as? [String: Any])
#expect(root["expectedConfigRevision"] as? String == "sha256:test-revision")
#expect(patch["enabled"] as? Bool == false)
}
@Test func `tracked run outcome preserves failures and skips`() {
#expect(agentAutomationRunOutcome(status: "ok", error: nil) == .success)
#expect(agentAutomationRunOutcome(status: "skipped", error: nil) == .skipped)
#expect(agentAutomationRunOutcome(status: "error", error: nil) == .failure)
#expect(agentAutomationRunOutcome(status: "ok", error: "delivery failed") == .failure)
#expect(agentAutomationRunOutcome(status: nil, error: nil) == .unknown)
#expect(agentAutomationRunOutcome(status: "future-status", error: nil) == .unknown)
}
@Test func `invalid spec skip refreshes persisted diagnostics`() {
#expect(agentAutomationRunSkipShouldRefresh(reason: "invalid-spec"))
#expect(!agentAutomationRunSkipShouldRefresh(reason: "already-running"))
#expect(!agentAutomationRunSkipShouldRefresh(reason: nil))
}
@Test @MainActor func `queued run registry keeps exact reservation until terminal release`() {
let registry = AgentAutomationPendingRunRegistry()
#expect(registry.reserve(jobID: "job-1", runID: "run-1"))
#expect(!registry.reserve(jobID: "job-1", runID: "run-2"))
#expect(registry.runID(for: "job-1") == "run-1")
registry.release(jobID: "job-1", runID: "run-2")
#expect(registry.runID(for: "job-1") == "run-1")
registry.release(jobID: "job-1", runID: "run-1")
#expect(registry.runID(for: "job-1") == nil)
}
@Test func `successful delete-after-run one-shot dismisses`() {
let oneShot = Self.job(
schedule: AnyCodable(["kind": AnyCodable("at"), "at": AnyCodable("2026-07-14T16:00:00Z")]),
deleteAfterRun: true)
#expect(agentAutomationDeletesAfterSuccessfulRun(job: oneShot, outcome: .success))
#expect(!agentAutomationDeletesAfterSuccessfulRun(job: oneShot, outcome: .failure))
#expect(!agentAutomationDeletesAfterSuccessfulRun(job: Self.job(), outcome: .success))
}
@Test func `semantic dirty state ignores normalized no-op edits`() throws {
let job = Self.job()
var draft = try #require(AgentAutomationDraft(job: job))
draft.name = " Release briefing "
#expect(!agentAutomationHasSemanticChanges(job: job, draft: draft))
draft.name = "Release briefing v2"
#expect(agentAutomationHasSemanticChanges(job: job, draft: draft))
}
@Test func `automation pagination requires a forward offset`() {
let page = CronJobsListLite(jobs: [], total: 201, hasMore: true, nextOffset: 200)
#expect(nextCronJobsListOffset(page: page, currentOffset: 0) == 200)
#expect(nextCronJobsListOffset(page: page, currentOffset: 200) == nil)
}
@Test func `legacy cron list defaults to a single page`() throws {
let page = try JSONDecoder().decode(
CronJobsListLite.self,
from: Data(#"{"jobs":[],"total":0}"#.utf8))
#expect(page.snapshotRevision == nil)
#expect(!page.hasMore)
#expect(page.nextOffset == nil)
}
@Test func `automation pagination pins total and snapshot revision`() throws {
let first = try JSONDecoder().decode(
CronJobsListLite.self,
from: Data(#"{"jobs":[],"snapshotRevision":" rev-1 ","total":201,"hasMore":true,"nextOffset":200}"#.utf8))
let changedRevision = CronJobsListLite(
jobs: [],
snapshotRevision: "rev-2",
total: 201,
hasMore: false,
nextOffset: nil)
let changedTotal = CronJobsListLite(
jobs: [],
snapshotRevision: "rev-1",
total: 200,
hasMore: false,
nextOffset: nil)
let identity = try #require(cronJobsSnapshotIdentity(page: first, maximumCount: 20000))
#expect(identity == CronJobsSnapshotIdentity(total: 201, revision: "rev-1"))
#expect(cronJobsSnapshotIdentity(page: changedRevision, maximumCount: 20000) != identity)
#expect(cronJobsSnapshotIdentity(page: changedTotal, maximumCount: 20000) != identity)
let legacyTerminal = try JSONDecoder().decode(
CronJobsListLite.self,
from: Data(#"{"jobs":[]}"#.utf8))
#expect(cronJobsSnapshotIdentity(page: legacyTerminal, maximumCount: 20000) ==
CronJobsSnapshotIdentity(total: nil, revision: nil))
}
@Test func `automation editor selection preserves tapped snapshot`() {
let job = Self.job()
let selection = AgentProTab.AutomationEditorSelection(
initialJob: job,
sourceGatewayID: "gateway-a")
#expect(selection.id == job.id)
#expect(selection.initialJob.name == job.name)
#expect(selection.sourceGatewayID == "gateway-a")
}
@Test func `detail source guards route and exact queued run`() throws {
let source = try String(
contentsOf: Self.sourceURL("Design/AgentAutomationDetailScreen.swift"),
encoding: .utf8)
let models = try String(
contentsOf: Self.sourceURL("Design/AgentAutomationModels.swift"),
encoding: .utf8)
#expect(source.contains("distinguishPreDispatchRouteChange: true"))
#expect(source.contains("currentRoute() == route"))
#expect(source.contains("gatewayChangedAfterDispatch"))
#expect(source.contains("ifGatewayID: self.sourceGatewayID"))
#expect(source.contains("\"runId\": runID"))
#expect(source.contains("pendingRunRegistry"))
#expect(!source.contains("self.pendingRunID = nil"))
#expect(source.contains("self.pendingRunRegistry.release(jobID: self.job.id, runID: runID)"))
#expect(Self.containsDictionaryAssignment(
dictionary: "runParams",
key: "expectedProcessInstanceId",
value: "processInstanceID",
in: source))
#expect(source.contains("guard self.pendingRunID == runID else { return }"))
#expect(models.contains("expectedConfigRevision"))
#expect(source.contains("Delete Automation"))
#expect(source.contains("OpenClawType.subheadSemiBold"))
#expect(source.contains("!self.hasUnsavedChanges"))
let tabSource = try String(
contentsOf: Self.sourceURL("Design/AgentProTab.swift"),
encoding: .utf8)
let cronSource = try String(
contentsOf: Self.sourceURL("Design/AgentProTab+Cron.swift"),
encoding: .utf8)
#expect(tabSource.contains("initialJob: selection.initialJob"))
#expect(!tabSource.contains("overview.cronJobs.first(where:"))
#expect(cronSource.contains("sourceGatewayID: sourceGatewayID"))
#expect(cronSource.contains("guard pendingCronRuns.runID(for: job.id) == nil else { return }"))
#expect(cronSource.contains("self.pendingCronRuns.reserve(jobID: jobID, runID: runID)"))
#expect(cronSource.contains("entries.contains(where: { $0.runid == runID })"))
#expect(cronSource.contains("method: \"system.info\""))
#expect(Self.containsDictionaryAssignment(
dictionary: "runParams",
key: "expectedProcessInstanceId",
value: "processInstanceID",
in: cronSource))
#expect(cronSource.contains("guard currentInstanceID == processInstanceID else"))
#expect(cronSource
.contains(
"presentAutomationEditor(\n job: job,\n sourceGatewayID: sourceGatewayID"))
}
private static func job(
payload: AnyCodable? = nil,
schedule: AnyCodable? = nil,
deleteAfterRun: Bool = false) -> CronJob
{
CronJob(
id: "release-briefing",
name: "Release briefing",
description: "Daily mobile release overview",
enabled: true,
deleteafterrun: deleteAfterRun,
createdatms: 1_783_468_800_000,
updatedatms: 1_783_555_200_000,
configrevision: "sha256:test-revision",
schedule: schedule ?? AnyCodable([
"kind": AnyCodable("every"),
"everyMs": AnyCodable(86_400_000),
"anchorMs": AnyCodable(1_783_468_800_000),
]),
sessiontarget: AnyCodable("isolated"),
wakemode: AnyCodable("now"),
payload: payload ?? AnyCodable([
"kind": AnyCodable("agentTurn"),
"message": AnyCodable("Summarize release readiness."),
"model": AnyCodable("openai/gpt-5.2"),
]),
state: [:],
nextrunatms: 1_783_641_600_000)
}
private static func sourceURL(_ path: String) -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources")
.appendingPathComponent(path)
}
private static func containsDictionaryAssignment(
dictionary: String,
key: String,
value: String,
in source: String) -> Bool
{
let pattern = [
#"\b"#,
NSRegularExpression.escapedPattern(for: dictionary),
#"\s*\[\s*""#,
NSRegularExpression.escapedPattern(for: key),
#""\s*\]\s*=\s*"#,
NSRegularExpression.escapedPattern(for: value),
#"\b"#,
].joined()
return source.range(of: pattern, options: .regularExpression) != nil
}
}