Files
KayandClaude Sonnet 5 c9671b017c M28: Übersicht Fokus-Modus + einheitliches Close-Button-Layout
Klick auf einen Knoten in der Übersicht öffnet jetzt ein schwebendes
Popup mit der kompletten verbundenen Kette (neu OverviewGraph.
connectedChain, volle transitive Hülle unabhängig vom Knotentyp,
bewusst getrennt von der bestehenden highlightedNodeIDs), sauber im
selben Spalten-Layout neu angeordnet, Rest des Diagramms abgedunkelt.
Nicht-modales Overlay statt .sheet, damit die rechte Seitenleiste
währenddessen bedienbar bleibt (Knoten direkt aus dem Popup heraus
bearbeitbar). Popup-Größe passt sich automatisch dem Inhalt an, keine
Scrollbalken.

Der dabei entstandene Close-Button-Header (Titel + Spacer + X, fest
oben, Divider direkt darunter) wurde auf alle vier Popup-Formulare der
App vereinheitlicht: Experte-Bearbeiten-Sheet und die drei
Devices-Sheets (Rohdaten, Netzwerk-Test, Port-Scan) — dort ersetzt er
jeweils den bisherigen einzelnen "Schließen"-Button unten.

Mehrere Design-Iterationen live mit dem User durchgespielt (Trennlinie
im Canvas → separates Panel → Popup → .sheet → non-modales Overlay),
finale Version live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:46:39 +02:00

242 lines
13 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])
}
/// `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<String> = [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"), [])
}
}