- Rechtsklick-Kontextmenü ersetzt durch sichtbaren "Aktionen"-Button je Geräte-Zeile (bisher nicht diskoverbar) - Live-Traffic pro Port (↓/↑, wiederverwendet InterfaceTrafficMonitor) plus kleines Sparkline-Liniendiagramm der letzten 10 Sekunden (Swift Charts, neuer TrafficSample-Typ) - Bug 34: ARP-Tabelle kann mehrere Zeilen für dieselbe MAC halten (reachable + stale/failed) — Auflösung bevorzugte bisher blind die zuletzt gesehene Zeile statt die erreichbare. Zwei Regressionstests. - Nebenbefund: dedizierte SSH-Dienste (Backup/NetworkTools/Traffic/ Update/FactoryReset) haben keinen eigenen Bestätigungspfad für einen neuen SSH-Host-Key, scheitern still solange REST verbindet — als offener Punkt dokumentiert. Alles live bestätigt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
223 lines
11 KiB
Swift
223 lines
11 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)
|
|
}
|
|
|
|
/// Live-confirmed (2026-09-16): RouterOS' `/ip arp` table held two rows for the same MAC at
|
|
/// once — a "reachable" one on the device's real standalone port, and a stale "failed" one
|
|
/// left over from an earlier network on "bridge". The old code built `arpInterfaceByMAC` by
|
|
/// blindly overwriting with whatever row came last, so which one "won" depended on array
|
|
/// order, not correctness — this device fell into "Unbekannter Port" despite the working
|
|
/// row existing. Reproduces that exact shape (stale bridge row listed AFTER the reachable
|
|
/// direct-port row, matching the live capture) and asserts the reachable, more specific
|
|
/// port wins regardless of order.
|
|
func testReachableArpRowWinsOverStaleFailedRowForSameMAC() {
|
|
let devices = DevicesViewModel.buildDevices(
|
|
leases: [item([
|
|
"address": "192.168.4.254", "mac-address": "AC:91:A1:04:BB:08",
|
|
"host-name": "Laptop", "server": "dhcp_ether4"
|
|
])],
|
|
arpEntries: [
|
|
item(["address": "192.168.4.254", "mac-address": "AC:91:A1:04:BB:08", "interface": "ether4", "status": "reachable"]),
|
|
item(["address": "192.168.88.252", "mac-address": "AC:91:A1:04:BB:08", "interface": "bridge", "status": "failed"])
|
|
],
|
|
bridgeHosts: [],
|
|
interfaces: [item(["name": "bridge", "type": "bridge"]), item(["name": "ether4", "type": "ether"])],
|
|
dhcpServers: [item(["name": "dhcp_ether4", "interface": "ether4"])]
|
|
)
|
|
|
|
XCTAssertEqual(devices[0].resolvedPort, "ether4")
|
|
XCTAssertTrue(devices[0].isExactPort)
|
|
XCTAssertNotEqual(devices[0].displayPort, "unbekannt")
|
|
}
|
|
|
|
/// Same shape, rows in the opposite order (reachable row listed second) — the fix must not
|
|
/// just happen to work for the one order seen live.
|
|
func testReachableArpRowWinsOverStaleFailedRowRegardlessOfOrder() {
|
|
let devices = DevicesViewModel.buildDevices(
|
|
leases: [item([
|
|
"address": "192.168.4.254", "mac-address": "AC:91:A1:04:BB:08",
|
|
"host-name": "Laptop", "server": "dhcp_ether4"
|
|
])],
|
|
arpEntries: [
|
|
item(["address": "192.168.88.252", "mac-address": "AC:91:A1:04:BB:08", "interface": "bridge", "status": "failed"]),
|
|
item(["address": "192.168.4.254", "mac-address": "AC:91:A1:04:BB:08", "interface": "ether4", "status": "reachable"])
|
|
],
|
|
bridgeHosts: [],
|
|
interfaces: [item(["name": "bridge", "type": "bridge"]), item(["name": "ether4", "type": "ether"])],
|
|
dhcpServers: [item(["name": "dhcp_ether4", "interface": "ether4"])]
|
|
)
|
|
|
|
XCTAssertEqual(devices[0].resolvedPort, "ether4")
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|