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