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>
This commit is contained in:
Kay
2026-09-17 22:21:36 +02:00
co-authored by Claude Sonnet 5
parent 57850cfe03
commit b503bac82c
7 changed files with 288 additions and 73 deletions
@@ -77,4 +77,58 @@ final class ExpertViewModelTests: XCTestCase {
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)
}
}
@@ -29,4 +29,26 @@ final class PortConflictTests: XCTestCase {
XCTAssertEqual(commands[1].menuPath, "/interface pppoe-client")
XCTAssertEqual(commands[1].operation, .remove(matchField: "interface", matchValue: "ether1"))
}
/// Regression test for the Experte-tab "Port freimachen?" flow (per explicit request): unlike
/// the Setup Wizard, the Experte tab has no separate step that unconditionally detaches a
/// bridge port on its own, so its resolution list must include that removal explicitly
/// `resolutionCommands()` alone (the wizard's version) deliberately leaves it out.
func testResolutionCommandsIncludingBridgeDetachAddsBridgePortRemoval() {
let conflict = PortConflict(interfaceName: "ether4", reasons: [.bridgeMember(bridgeName: "bridge")])
let commands = conflict.resolutionCommandsIncludingBridgeDetach()
XCTAssertEqual(commands.count, 1)
XCTAssertEqual(commands[0].menuPath, "/interface bridge port")
XCTAssertEqual(commands[0].operation, .remove(matchField: "interface", matchValue: "ether4"))
}
func testResolutionCommandsIncludingBridgeDetachKeepsOtherReasonsToo() {
let conflict = PortConflict(interfaceName: "ether4", reasons: [.bridgeMember(bridgeName: "bridge"), .dhcpClient])
let commands = conflict.resolutionCommandsIncludingBridgeDetach()
XCTAssertEqual(commands.count, 2)
XCTAssertEqual(commands[0].menuPath, "/interface bridge port")
XCTAssertEqual(commands[1].menuPath, "/ip dhcp-client")
}
}