Files
KayandClaude Sonnet 5 9b26677dc2 Übersicht: Interface-Klick hebt transitiv alle zusammenhängenden Linien hervor
OverviewGraph.highlightedNodeIDs(startingAt:) macht für Interface-Knoten
eine BFS über alle Kanten statt nur 1-Hop-Matching, damit z.B. "DHCP-Server
-> Pool" oder "IP-Adresse -> DHCP-Netzwerk" mit sichtbar werden. Andere
Knotentypen bleiben unverändert bei 1-Hop. Logik isoliert unit-getestet
(GUI selbst nicht automatisiert klickbar). 61 Tests grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
2026-09-15 19:58:05 +02:00

213 lines
12 KiB
Swift

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