Per explizitem Nutzerwunsch die "Port freimachen?"-Abfrage samt allen Warnungen aus dem LAN-Schritt des Wizards auch auf den Experte-Tab angewendet, ausgeloest durch echten Live-Fall (ether4 wurde manuell ueber Expert als eigenes Netz angelegt, blieb dabei unbemerkt Bridge- Mitglied - zwei DHCP-Server im selben Broadcast-Domain). PortConflictWarningView aus LanStepView.swift in Features/Shared/ extrahiert (jetzt von Wizard UND Experte-Tab genutzt), neuer immediateApply-Parameter fuer die kontextabhaengige Abschluss-Meldung (Wizard: erst bei "Jetzt anwenden"; Experte: sofort bei "Anlegen"/ "Speichern"). Neue PortConflict.resolutionCommandsIncludingBridgeDetach() - der Experte-Tab hat anders als der Wizard keinen separaten, automatischen Bridge-Detach-Schritt, muss die Bridge-Entfernung also selbst mit auflisten. ExpertViewModel bekommt dieselbe Race-sichere Generation-Zaehler-Logik wie SetupViewModel (bugs.md #1), angewendet auf das .interfacePick-Feld des jeweils offenen Schemas. Speichern-Button gesperrt bis Konflikt bestaetigt oder Port gewechselt. 6 neue Regressionstests. Build + alle 111 Unit-Tests gruen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
6.6 KiB
Swift
135 lines
6.6 KiB
Swift
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)
|
|
}
|
|
}
|