import XCTest @testable import RouterOSAssistant @MainActor final class ExpertViewModelTests: XCTestCase { private func makeSchema() -> RouterOSMenuSchema { RouterOSMenuSchema( menuPath: "/ip route", restPath: "ip/route", category: .routing, displayName: "Test", summary: "", explanation: "", fields: [ RouterOSFieldSchema(key: "comment", label: "Kommentar", kind: .text, help: ""), RouterOSFieldSchema(key: "distance", label: "Distanz", kind: .int, help: "") ] ) } func testNonClearableFieldNeverSendsExplicitEmptyValue() { let schema = RouterOSMenuSchema( menuPath: "/system routerboard mode-button", restPath: "system/routerboard/mode-button", category: .system, displayName: "Test", summary: "", explanation: "", fields: [ RouterOSFieldSchema(key: "enabled", label: "Aktiviert", kind: .bool, help: "", defaultValue: "no"), RouterOSFieldSchema(key: "hold-time", label: "Haltedauer", kind: .text, help: "", clearable: false) ], isSingleton: true ) let viewModel = ExpertViewModel(connectionService: ConnectionService()) viewModel.selectedSchema = schema // A previously non-empty "hold-time" that the user's form now shows blank (whatever the // cause) must still never be sent as an explicit clear — only this field's `clearable: // false` should suppress it; "enabled" (clearable, unaffected) still sends normally. viewModel.startEditing(RouterOSMenuItem(id: "singleton", fields: ["enabled": "no", "hold-time": "3s..5s"])) viewModel.formValues["enabled"] = "yes" viewModel.formValues["hold-time"] = "" XCTAssertNil(viewModel.pendingCommand?.arguments["hold-time"]) XCTAssertEqual(viewModel.pendingCommand?.arguments["enabled"], "yes") } func testClearingACuratedFieldSendsExplicitEmptyValue() { let viewModel = ExpertViewModel(connectionService: ConnectionService()) viewModel.selectedSchema = makeSchema() viewModel.startEditing(RouterOSMenuItem(id: "*1", fields: ["comment": "bridge"])) viewModel.formValues["comment"] = "" XCTAssertEqual(viewModel.pendingCommand?.arguments["comment"], "") } func testUnchangedUncuratedFieldIsNotResent() { let viewModel = ExpertViewModel(connectionService: ConnectionService()) viewModel.selectedSchema = makeSchema() viewModel.startEditing(RouterOSMenuItem(id: "*1", fields: ["comment": "", "immediate-gw": "192.168.88.1"])) viewModel.formValues["comment"] = "bridge" XCTAssertNil(viewModel.pendingCommand?.arguments["immediate-gw"]) XCTAssertEqual(viewModel.pendingCommand?.arguments["comment"], "bridge") } func testChangedUncuratedFieldIsSent() { let viewModel = ExpertViewModel(connectionService: ConnectionService()) viewModel.selectedSchema = makeSchema() viewModel.startEditing(RouterOSMenuItem(id: "*1", fields: ["comment": "", "note": "old"])) viewModel.extraFields = [.init(key: "note", value: "new")] XCTAssertEqual(viewModel.pendingCommand?.arguments["note"], "new") } /// A dynamic/connected route's distance=0 is only valid as-is — RouterOS rejects it if /// resent verbatim on a `set` ("value of distance out of range (1...255)"), even when the /// value itself hasn't changed. Editing only the comment must not resend it. func testUnchangedCuratedFieldIsNotResentEvenIfOutOfNormalRange() { let viewModel = ExpertViewModel(connectionService: ConnectionService()) viewModel.selectedSchema = makeSchema() viewModel.startEditing(RouterOSMenuItem(id: "*1", fields: ["comment": "", "distance": "0"])) viewModel.formValues["comment"] = "bridge" XCTAssertNil(viewModel.pendingCommand?.arguments["distance"]) XCTAssertEqual(viewModel.pendingCommand?.arguments["comment"], "bridge") } /// Regression tests for the Experte-tab port-conflict check (per explicit request: "die /// Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen Warnungen"). No live /// connection is set up here, so `checkInterfacePortConflict()`'s actual network call always /// fails/is skipped — these only cover the surrounding logic that doesn't need one: schemas /// without an `.interfacePick` field are correctly ignored, and the unresolved-conflict gate /// starts clear. private func makeSchemaWithInterfaceField() -> RouterOSMenuSchema { RouterOSMenuSchema( menuPath: "/ip address", restPath: "ip/address", category: .ipAddressing, displayName: "Test", summary: "", explanation: "", fields: [ RouterOSFieldSchema(key: "address", label: "Adresse", kind: .text, help: ""), RouterOSFieldSchema(key: "interface", label: "Interface", kind: .interfacePick, help: "") ] ) } func testCheckInterfacePortConflictNoOpsForSchemaWithoutInterfaceField() { let viewModel = ExpertViewModel(connectionService: ConnectionService()) viewModel.selectedSchema = makeSchema() // no .interfacePick field viewModel.startNewItem() viewModel.checkInterfacePortConflict() XCTAssertFalse(viewModel.isCheckingInterfacePortConflict) XCTAssertNil(viewModel.interfacePortConflict) XCTAssertFalse(viewModel.hasUnresolvedInterfacePortConflict) } func testCheckInterfacePortConflictNoOpsWhenInterfaceFieldIsEmpty() { let viewModel = ExpertViewModel(connectionService: ConnectionService()) viewModel.selectedSchema = makeSchemaWithInterfaceField() viewModel.startNewItem() // "interface" has no default, starts empty viewModel.checkInterfacePortConflict() XCTAssertFalse(viewModel.isCheckingInterfacePortConflict) XCTAssertNil(viewModel.interfacePortConflict) } func testStartingOrCancelingEditResetsAcknowledgedConflictState() { let viewModel = ExpertViewModel(connectionService: ConnectionService()) viewModel.selectedSchema = makeSchemaWithInterfaceField() viewModel.startNewItem() viewModel.acknowledgeInterfacePortConflict() XCTAssertFalse(viewModel.hasUnresolvedInterfacePortConflict, "acknowledging clears the block even with no conflict object set") viewModel.cancelEditing() viewModel.startNewItem() // A fresh edit must not inherit a stale acknowledgement from a previous one. XCTAssertNil(viewModel.interfacePortConflict) } }