Files
RouterOS/RouterOSAssistantTests/DevicesViewModelTests.swift
T
KayandClaude Sonnet 5 e2b303963a M12: Geräte-Tab — LAN-Scanner mit Static-IP-Zuweisung
Neuer Tab: eine Tabelle pro physischem Ethernet/WLAN-Port mit den dort
gefundenen Geräten (Name, IP, MAC, Fest/Dynamisch/Kein-DHCP), gebaut aus
DHCP-Leases + ARP + Bridge-Host-Tabelle. Rechtsklick auf ein dynamisches
Gerät -> "Feste IP zuweisen" (RouterOS' "Make Static", per
/ip dhcp-server lease make-static), mit Bestätigungsdialog und
Session-Backup vor dem ersten Schreibvorgang (geteilter Mechanismus mit
dem Experte-Tab).

Vier reale Bugs live gefunden und gefixt (siehe HANDOFF.md Bug 14-17):

- "print terse" gibt das "dynamic"-Feld von /ip dhcp-server lease nie
  aus, in keinem Zustand -> Status kommt jetzt über RouterOS' find/get
  gegen die interne Eigenschaft, nicht aus gelesenen Feldern.
- fetchMenuItems' .id-Positionsüberlagerung ordnete für dieses Menü die
  falsche .id der falschen Zeile zu -> Erkennung und make-static-Ziel
  laufen jetzt über die MAC-Adresse statt .id.
- Ein SwiftUI-.confirmationDialog löschte sein eigenes Ziel-Objekt vor
  der Ausführung der bestätigten Aktion (Setter feuert bei jedem
  Knopfdruck, nicht nur Abbrechen) -> Dialog-Sichtbarkeit und
  Nutzlast entkoppelt, wie in BackupListView.
- Die eigene Verifikations-Abfrage (get [find ...] feld als ein
  kombinierter Befehl) war selbst eine nie verifizierte Annahme und
  lieferte falsche Negative -> ersetzt durch :foreach aus zwei einzeln
  bestätigten Bausteinen (find, get <id> feld).

RouterOSCommand bekommt einen neuen .action-Operationstyp für
RouterOS-"Menü-spezifische Befehle" jenseits von add/set/remove (aktuell
nur make-static). HANDOFF.md/CHATLOG.md mit allen vier Bugs, neuen
Milestones M11/M12 und offenen Punkten aktualisiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTgRxJTzaQwaRkngbaE1GJ
2026-09-14 13:55:46 +02:00

175 lines
8.1 KiB
Swift

import XCTest
@testable import RouterOSAssistant
final class DevicesViewModelTests: XCTestCase {
private func item(_ fields: [String: String], id: String = "*1") -> RouterOSMenuItem {
RouterOSMenuItem(id: id, fields: fields)
}
func testDynamicLeaseResolvesExactPortViaBridgeHost() {
let devices = DevicesViewModel.buildDevices(
leases: [item([
"address": "192.168.88.50", "mac-address": "AA:BB:CC:DD:EE:01",
"host-name": "MacBook", "server": "dhcp1"
], id: "*5")],
arpEntries: [item(["address": "192.168.88.50", "mac-address": "AA:BB:CC:DD:EE:01", "interface": "bridge-lan"])],
bridgeHosts: [item(["mac-address": "aa:bb:cc:dd:ee:01", "bridge": "bridge-lan", "on-interface": "ether3"])],
interfaces: [item(["name": "bridge-lan", "type": "bridge"])],
dhcpServers: [item(["name": "dhcp1", "interface": "bridge-lan"])]
)
XCTAssertEqual(devices.count, 1)
let device = devices[0]
XCTAssertEqual(device.resolvedPort, "ether3")
XCTAssertEqual(device.displayPort, "Port: ether3")
XCTAssertTrue(device.isExactPort)
XCTAssertFalse(device.isStatic)
XCTAssertEqual(device.leaseID, "*5")
}
func testArpOnlyBridgeInterfaceIsNotExactPortWithoutBridgeHost() {
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.51", "mac-address": "AA:BB:CC:DD:EE:02", "server": "dhcp1"])],
arpEntries: [item(["address": "192.168.88.51", "mac-address": "AA:BB:CC:DD:EE:02", "interface": "bridge-lan"])],
bridgeHosts: [],
interfaces: [item(["name": "bridge-lan", "type": "bridge"])],
dhcpServers: [item(["name": "dhcp1", "interface": "bridge-lan"])]
)
XCTAssertEqual(devices[0].networkHint, "bridge-lan")
XCTAssertEqual(devices[0].displayPort, "Netz: bridge-lan")
XCTAssertFalse(devices[0].isExactPort)
}
func testDirectPortArpInterfaceIsExact() {
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.52", "mac-address": "AA:BB:CC:DD:EE:03", "server": "dhcp1"])],
arpEntries: [item(["address": "192.168.88.52", "mac-address": "AA:BB:CC:DD:EE:03", "interface": "ether5"])],
bridgeHosts: [],
interfaces: [item(["name": "ether5", "type": "ether"])],
dhcpServers: [item(["name": "dhcp1", "interface": "ether5"])]
)
XCTAssertEqual(devices[0].resolvedPort, "ether5")
XCTAssertTrue(devices[0].isExactPort)
}
func testLeaseNotInStaticMacSetIsDynamic() {
// Confirmed live on a real hEX: "/ip dhcp-server lease print terse" never emits a
// "dynamic" key at all, in either state — reading fields can't distinguish static from
// dynamic for this menu. Static/dynamic comes from `staticMacAddresses` (RouterOS' own
// `find dynamic=no`, matched by MAC — not `.id`, confirmed live to mis-pair for this menu
// via `fetchMenuItems`' positional overlay), not from anything in the lease's own fields.
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.53", "mac-address": "AA:BB:CC:DD:EE:04", "server": "dhcp1"], id: "*3")],
arpEntries: [], bridgeHosts: [], interfaces: [], dhcpServers: [],
staticMacAddresses: ["aa:bb:cc:dd:ee:09"]
)
XCTAssertFalse(devices[0].isStatic)
}
func testLeaseInStaticMacSetIsStatic() {
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.54", "mac-address": "AA:BB:CC:DD:EE:07", "server": "dhcp1"], id: "*4")],
arpEntries: [], bridgeHosts: [], interfaces: [], dhcpServers: [],
staticMacAddresses: ["aa:bb:cc:dd:ee:07"]
)
XCTAssertTrue(devices[0].isStatic)
}
func testStaticDetectionIgnoresIDEntirelyEvenWhenMismatched() {
// Regression guard for the exact bug found live: two leases where the "static" `.id`
// happened to get attached to the WRONG row by the id-overlay must not both show static.
let devices = DevicesViewModel.buildDevices(
leases: [
item(["address": "192.168.88.1", "mac-address": "AA:AA:AA:AA:AA:01", "server": "dhcp1"], id: "*4"),
item(["address": "192.168.88.2", "mac-address": "AA:AA:AA:AA:AA:02", "server": "dhcp1"], id: "*4")
],
arpEntries: [], bridgeHosts: [], interfaces: [], dhcpServers: [],
staticMacAddresses: ["aa:aa:aa:aa:aa:02"]
)
XCTAssertFalse(devices[0].isStatic)
XCTAssertTrue(devices[1].isStatic)
}
func testArpOnlyDeviceHasNoLeaseAndCannotBeMadeStatic() {
let devices = DevicesViewModel.buildDevices(
leases: [],
arpEntries: [item(["address": "192.168.88.60", "mac-address": "AA:BB:CC:DD:EE:05", "interface": "ether5"])],
bridgeHosts: [], interfaces: [item(["name": "ether5", "type": "ether"])], dhcpServers: []
)
XCTAssertEqual(devices.count, 1)
XCTAssertFalse(devices[0].hasLease)
XCTAssertNil(devices[0].leaseID)
// No lease at all means "static" doesn't apply either — must not read as "Fest".
XCTAssertFalse(devices[0].isStatic)
}
func testLeaseAndArpEntryForSameMacAreNotDuplicated() {
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.61", "mac-address": "AA:BB:CC:DD:EE:06", "server": "dhcp1"])],
arpEntries: [item(["address": "192.168.88.61", "mac-address": "aa:bb:cc:dd:ee:06", "interface": "ether5"])],
bridgeHosts: [], interfaces: [item(["name": "ether5", "type": "ether"])],
dhcpServers: [item(["name": "dhcp1", "interface": "ether5"])]
)
XCTAssertEqual(devices.count, 1)
XCTAssertTrue(devices[0].hasLease)
}
func testDevicesAreSortedByIPAddress() {
let devices = DevicesViewModel.buildDevices(
leases: [
item(["address": "192.168.88.20", "mac-address": "AA:00:00:00:00:02"]),
item(["address": "192.168.88.5", "mac-address": "AA:00:00:00:00:01"])
],
arpEntries: [], bridgeHosts: [], interfaces: [], dhcpServers: []
)
XCTAssertEqual(devices.map(\.ipAddress), ["192.168.88.5", "192.168.88.20"])
}
func testPortGroupsIncludeEmptyPortsAndSortNaturally() {
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.10", "mac-address": "AA:00:00:00:00:10", "server": "dhcp1"])],
arpEntries: [item(["address": "192.168.88.10", "mac-address": "aa:00:00:00:00:10", "interface": "ether10"])],
bridgeHosts: [],
interfaces: [
item(["name": "ether2", "type": "ether"]),
item(["name": "ether10", "type": "ether"])
],
dhcpServers: [item(["name": "dhcp1", "interface": "ether10"])]
)
let groups = DevicesViewModel.buildPortGroups(
devices: devices,
interfaces: [
item(["name": "ether2", "type": "ether"]),
item(["name": "ether10", "type": "ether"])
]
)
XCTAssertEqual(groups.map(\.title), ["ether2", "ether10"])
XCTAssertEqual(groups[0].devices.count, 0)
XCTAssertEqual(groups[1].devices.count, 1)
}
func testPortGroupsBucketUnresolvedDevicesSeparately() {
let devices = DevicesViewModel.buildDevices(
leases: [item(["address": "192.168.88.11", "mac-address": "AA:00:00:00:00:11", "server": "dhcp1"])],
arpEntries: [],
bridgeHosts: [],
interfaces: [item(["name": "bridge-lan", "type": "bridge"])],
dhcpServers: [item(["name": "dhcp1", "interface": "bridge-lan"])]
)
let groups = DevicesViewModel.buildPortGroups(devices: devices, interfaces: [item(["name": "bridge-lan", "type": "bridge"])])
XCTAssertEqual(groups.count, 1)
XCTAssertEqual(groups[0].id, "unbekannt")
XCTAssertEqual(groups[0].devices.count, 1)
}
}