import XCTest @testable import RouterOSAssistant final class OverviewGraphTests: XCTestCase { private func item(_ fields: [String: String], id: String = "*1") -> RouterOSMenuItem { RouterOSMenuItem(id: id, fields: fields) } func testInterfaceToIPAddressEdge() { let graph = OverviewViewModel.buildGraph( interfaces: [item(["name": "ether1", "type": "ether"])], bridgePorts: [], vlans: [], wireguardPeers: [], addresses: [item(["address": "192.168.88.1/24", "interface": "ether1", "network": "192.168.88.0"])], pools: [], dhcpServers: [], dhcpNetworks: [], dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: [] ) XCTAssertTrue(graph.nodes.contains { $0.id == "Interfaces:ether1" }) XCTAssertTrue(graph.nodes.contains { $0.id == "IP-Adressen:192.168.88.1/24" }) XCTAssertTrue(graph.edges.contains { $0.from == "Interfaces:ether1" && $0.to == "IP-Adressen:192.168.88.1/24" }) } func testVlanEdgeToParentInterface() { let graph = OverviewViewModel.buildGraph( interfaces: [ item(["name": "ether5", "type": "ether"]), item(["name": "vlan20", "type": "vlan"]) ], bridgePorts: [], vlans: [item(["name": "vlan20", "vlan-id": "20", "interface": "ether5"])], wireguardPeers: [], addresses: [], pools: [], dhcpServers: [], dhcpNetworks: [], dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: [] ) XCTAssertTrue(graph.edges.contains { $0.from == "Interfaces:ether5" && $0.to == "Interfaces:vlan20" }) let vlanNode = graph.nodes.first { $0.id == "Interfaces:vlan20" } XCTAssertEqual(vlanNode?.subtitle, "VLAN 20") } /// A plain interface (no curated schema for bare "/interface" here) edits generically via /// "/interface" using its own item id; a VLAN interface edits through the curated /// "/interface vlan" schema using the VLAN's own item id from the "/interface vlan" listing, /// not the generic "/interface" listing's id for the same object. func testInterfaceEditTargetsUseGenericOrCuratedVlanSchema() { let graph = OverviewViewModel.buildGraph( interfaces: [ item(["name": "ether5", "type": "ether"], id: "*1"), item(["name": "vlan20", "type": "vlan"], id: "*2") ], bridgePorts: [], vlans: [item(["name": "vlan20", "vlan-id": "20", "interface": "ether5"], id: "*A1")], wireguardPeers: [], addresses: [], pools: [], dhcpServers: [], dhcpNetworks: [], dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: [] ) let ether5 = graph.nodes.first { $0.id == "Interfaces:ether5" } XCTAssertEqual(ether5?.editTarget, OverviewNode.EditTarget(menuPath: "/interface", restPath: "interface", itemID: "*1")) let vlan20 = graph.nodes.first { $0.id == "Interfaces:vlan20" } XCTAssertEqual(vlan20?.editTarget, OverviewNode.EditTarget(menuPath: "/interface vlan", restPath: "interface/vlan", itemID: "*A1")) } func testDhcpServerEdgesToInterfaceAndPool() { let graph = OverviewViewModel.buildGraph( interfaces: [item(["name": "ether2", "type": "ether"])], bridgePorts: [], vlans: [], wireguardPeers: [], addresses: [], pools: [item(["name": "pool1", "ranges": "192.168.88.10-192.168.88.254"])], dhcpServers: [item(["name": "dhcp1", "interface": "ether2", "address-pool": "pool1"])], dhcpNetworks: [], dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: [] ) XCTAssertTrue(graph.edges.contains { $0.from == "Interfaces:ether2" && $0.to == "Pools & DHCP:dhcp:dhcp1" }) XCTAssertTrue(graph.edges.contains { $0.from == "Pools & DHCP:dhcp:dhcp1" && $0.to == "Pools & DHCP:pool:pool1" }) } func testDhcpNetworkLinksViaAddressNetworkField() { let graph = OverviewViewModel.buildGraph( interfaces: [item(["name": "ether2", "type": "ether"])], bridgePorts: [], vlans: [], wireguardPeers: [], addresses: [item(["address": "192.168.88.1/24", "interface": "ether2", "network": "192.168.88.0"])], pools: [], dhcpServers: [], dhcpNetworks: [item(["address": "192.168.88.0/24", "gateway": "192.168.88.1"])], dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: [] ) XCTAssertTrue(graph.edges.contains { $0.from == "IP-Adressen:192.168.88.1/24" && $0.to == "Pools & DHCP:dhcpnet:192.168.88.0/24" }) } func testRouteLinksToInterfaceOnlyWhenGatewayIsAnInterfaceName() { let graph = OverviewViewModel.buildGraph( interfaces: [item(["name": "ether1", "type": "ether"])], bridgePorts: [], vlans: [], wireguardPeers: [], addresses: [], pools: [], dhcpServers: [], dhcpNetworks: [], dhcpClients: [], routes: [ item(["dst-address": "0.0.0.0/0", "gateway": "ether1"]), item(["dst-address": "10.0.0.0/24", "gateway": "192.168.88.254"]) ], filterRules: [], natRules: [], addressLists: [] ) XCTAssertTrue(graph.edges.contains { $0.from == "Interfaces:ether1" && $0.to == "route:0" }) XCTAssertFalse(graph.edges.contains { $0.to == "route:1" }) } func testFirewallFilterEdgesToInterfacesAndAddressList() { let graph = OverviewViewModel.buildGraph( interfaces: [ item(["name": "ether1", "type": "ether"]), item(["name": "ether2", "type": "ether"]) ], bridgePorts: [], vlans: [], wireguardPeers: [], addresses: [], pools: [], dhcpServers: [], dhcpNetworks: [], dhcpClients: [], routes: [], filterRules: [item([ "chain": "forward", "action": "drop", "in-interface": "ether1", "out-interface": "ether2", "src-address-list": "gesperrt" ])], natRules: [], addressLists: [item(["list": "gesperrt", "address": "10.0.0.5"])] ) XCTAssertTrue(graph.edges.contains { $0.from == "Interfaces:ether1" && $0.to == "filter:0" }) XCTAssertTrue(graph.edges.contains { $0.from == "filter:0" && $0.to == "Interfaces:ether2" }) XCTAssertTrue(graph.edges.contains { $0.from == "Firewall & NAT:addrlist:gesperrt" && $0.to == "filter:0" }) } func testEditableNodesCarryTheirRouterOSMenuAndItemID() { let graph = OverviewViewModel.buildGraph( interfaces: [item(["name": "ether1", "type": "ether"])], bridgePorts: [], vlans: [], wireguardPeers: [], addresses: [item(["address": "192.168.88.1/24", "interface": "ether1", "network": "192.168.88.0"], id: "*1")], pools: [item(["name": "pool1", "ranges": "192.168.88.10-192.168.88.254"], id: "*2")], dhcpServers: [], dhcpNetworks: [], dhcpClients: [], routes: [item(["dst-address": "0.0.0.0/0", "gateway": "192.168.88.254"], id: "*3")], filterRules: [item(["chain": "forward", "action": "drop"], id: "*4")], natRules: [item(["chain": "srcnat", "action": "masquerade"], id: "*5")], addressLists: [item(["list": "gesperrt", "address": "10.0.0.5"], id: "*6")] ) let address = graph.nodes.first { $0.id == "IP-Adressen:192.168.88.1/24" } XCTAssertEqual(address?.editTarget, OverviewNode.EditTarget(menuPath: "/ip address", restPath: "ip/address", itemID: "*1")) let pool = graph.nodes.first { $0.id == "Pools & DHCP:pool:pool1" } XCTAssertEqual(pool?.editTarget, OverviewNode.EditTarget(menuPath: "/ip pool", restPath: "ip/pool", itemID: "*2")) let route = graph.nodes.first { $0.id == "route:0" } XCTAssertEqual(route?.editTarget, OverviewNode.EditTarget(menuPath: "/ip route", restPath: "ip/route", itemID: "*3")) let filter = graph.nodes.first { $0.id == "filter:0" } XCTAssertEqual(filter?.editTarget, OverviewNode.EditTarget(menuPath: "/ip firewall filter", restPath: "ip/firewall/filter", itemID: "*4")) let nat = graph.nodes.first { $0.id == "nat:0" } XCTAssertEqual(nat?.editTarget, OverviewNode.EditTarget(menuPath: "/ip firewall nat", restPath: "ip/firewall/nat", itemID: "*5")) // Address-list nodes fold multiple entries together — no single item to write back to. let addressList = graph.nodes.first { $0.id == "Firewall & NAT:addrlist:gesperrt" } XCTAssertNil(addressList?.editTarget) } /// A dynamic/connected route (distance=0, auto-created by an interface address) must not be /// offered for editing — confirmed live: RouterOS rejects resending its own distance=0 /// ("value of distance out of range (1...255)"), and its .id isn't stable either. func testDynamicRouteHasNoEditTarget() { let graph = OverviewViewModel.buildGraph( interfaces: [], bridgePorts: [], vlans: [], wireguardPeers: [], addresses: [], pools: [], dhcpServers: [], dhcpNetworks: [], dhcpClients: [], routes: [item(["dst-address": "192.168.88.0/24", "gateway": "bridge", "distance": "0"], id: "*201C5060")], filterRules: [], natRules: [], addressLists: [] ) let route = graph.nodes.first { $0.id == "route:0" } XCTAssertNil(route?.editTarget) } /// Clicking an Interface should highlight every line that traces back to it, not just the /// ones directly touching it — e.g. a DHCP server's own pool, or an IP address's DHCP-network /// options, sit a second hop away from the interface itself. Confirmed scope (2026-09-15): /// only interfaces expand this way — every other node kind stays one-hop-only. func testHighlightedNodeIDsExpandsTransitivelyOnlyForInterfaces() { let graph = OverviewViewModel.buildGraph( interfaces: [item(["name": "ether1", "type": "ether"])], bridgePorts: [], vlans: [], wireguardPeers: [], addresses: [item(["address": "192.168.88.1/24", "interface": "ether1", "network": "192.168.88.0"])], pools: [item(["name": "pool1", "ranges": "192.168.88.10-192.168.88.254"])], dhcpServers: [item(["name": "dhcp1", "interface": "ether1", "address-pool": "pool1"])], dhcpNetworks: [item(["address": "192.168.88.0/24", "gateway": "192.168.88.1"])], dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: [] ) let interfaceID = "Interfaces:ether1" let addressID = "IP-Adressen:192.168.88.1/24" let dhcpServerID = "Pools & DHCP:dhcp:dhcp1" let poolID = "Pools & DHCP:pool:pool1" let dhcpNetworkID = "Pools & DHCP:dhcpnet:192.168.88.0/24" // Sanity: the pool and the DHCP-network options really are two hops away from the // interface (no direct edge to either) — otherwise this test wouldn't distinguish // transitive-closure behavior from plain one-hop matching. XCTAssertFalse(graph.edges.contains { $0.from == interfaceID && $0.to == poolID }) XCTAssertFalse(graph.edges.contains { $0.from == interfaceID && $0.to == dhcpNetworkID }) let fromInterface = graph.highlightedNodeIDs(startingAt: interfaceID) XCTAssertEqual(fromInterface, [interfaceID, addressID, dhcpServerID, poolID, dhcpNetworkID]) // A non-interface node (e.g. the pool) stays one-hop-only: just itself, regardless of // what it's connected to. XCTAssertEqual(graph.highlightedNodeIDs(startingAt: poolID), [poolID]) XCTAssertEqual(graph.highlightedNodeIDs(startingAt: addressID), [addressID]) } /// `connectedChain` backs the Übersicht "Fokus-Modus" and, unlike `highlightedNodeIDs`, always /// walks the full transitive closure regardless of node category — starting from the pool /// (two hops from the interface) should still reach every other node in the same graph used /// above. func testConnectedChainExpandsTransitivelyForEveryNodeKind() { let graph = OverviewViewModel.buildGraph( interfaces: [item(["name": "ether1", "type": "ether"])], bridgePorts: [], vlans: [], wireguardPeers: [], addresses: [item(["address": "192.168.88.1/24", "interface": "ether1", "network": "192.168.88.0"])], pools: [item(["name": "pool1", "ranges": "192.168.88.10-192.168.88.254"])], dhcpServers: [item(["name": "dhcp1", "interface": "ether1", "address-pool": "pool1"])], dhcpNetworks: [item(["address": "192.168.88.0/24", "gateway": "192.168.88.1"])], dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: [] ) let interfaceID = "Interfaces:ether1" let addressID = "IP-Adressen:192.168.88.1/24" let dhcpServerID = "Pools & DHCP:dhcp:dhcp1" let poolID = "Pools & DHCP:pool:pool1" let dhcpNetworkID = "Pools & DHCP:dhcpnet:192.168.88.0/24" let fullChain: Set = [interfaceID, addressID, dhcpServerID, poolID, dhcpNetworkID] XCTAssertEqual(graph.connectedChain(startingAt: poolID), fullChain) XCTAssertEqual(graph.connectedChain(startingAt: dhcpNetworkID), fullChain) XCTAssertEqual(graph.connectedChain(startingAt: interfaceID), fullChain) XCTAssertEqual(graph.connectedChain(startingAt: "does-not-exist"), []) } }