Files
KayandClaude Sonnet 5 b503bac82c Experte-Tab: Port-Konflikt-Pruefung wie im Einrichten-Assistenten
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>
2026-09-17 22:21:36 +02:00

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)
}
}