Просмотр исходного кода

Add CLI and Playwright coverage for the new ports

CLI (Tests/): UpsPortWorkflowTests, OtherPortWorkflowTests and
LaptopNicWorkflowTests exercise add/set/del/describe with exact YAML
asserts, plus four error facts per kind (missing resource, invalid port
type, invalid set index, invalid del index) and --help assertions for
each new command branch.

Playwright (Tests.E2e/): one card test per kind adds two port groups
through PortGroupEditor, reloads, and asserts both groups and their
individual ports survive the round trip. UpsCardPom, OtherCardPom and
LaptopCardPom gained a Ports member and thin wrappers over the existing
PortsPom, mirroring AccessPointCardPom.

Every test pairs the new usb type with rj45 so the port summary has more
than one group to fold.
WhiteStorm 22 часов назад
Родитель
Сommit
2ed50aefe9

+ 44 - 0
Tests.E2e/LaptopCardTests.cs

@@ -326,4 +326,48 @@ public class LaptopCardTests(
             await context.CloseAsync();
             await context.CloseAsync();
         }
         }
     }
     }
+
+    // =============================================================
+    // NICs (ports)
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Add_Nics_To_A_Laptop() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var name = $"e2e-lap-{Guid.NewGuid():N}"[..16];
+
+        try {
+            var list = new LaptopListPom(page);
+            await list.GotoAsync(_fixture.BaseUrl);
+            await list.AssertLoadedAsync();
+
+            await list.AddLaptopAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(name)).ToBeVisibleAsync();
+
+            await Assertions.Expect(card.PortGroupSection).ToBeVisibleAsync();
+
+            // Built-in wired NIC plus a USB-attached dock.
+            await card.AddPortGroupAsync("rj45", "1", 1);
+            await card.AssertPortGroupVisibleAsync(0);
+
+            await card.AddPortGroupAsync("usb", "10", 2);
+            await card.AssertPortGroupVisibleAsync(1);
+
+            await page.ReloadAsync();
+            await Assertions.Expect(card.LaptopItem(name)).ToBeVisibleAsync();
+
+            await card.AssertPortVisibleAsync(0, 0);
+            await card.AssertPortVisibleAsync(1, 0);
+            await card.AssertPortVisibleAsync(1, 1);
+
+            await card.DeleteLaptopAsync(name);
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
 }
 }

+ 45 - 0
Tests.E2e/OtherCardTests.cs

@@ -222,4 +222,49 @@ public class OtherCardTests(
             await context.CloseAsync();
             await context.CloseAsync();
         }
         }
     }
     }
+
+    // =============================================================
+    // Ports
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Add_Port_Groups_To_Other_Hardware() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var name = $"e2e-oth-{Guid.NewGuid():N}"[..16];
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/other/list");
+
+            var list = new OtherListPom(page);
+            await list.AddOtherAsync(name);
+
+            if (!page.Url.Contains($"/resources/hardware/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+                await list.OpenOtherAsync(name);
+
+            var card = new OtherCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await Assertions.Expect(card.PortGroupSection).ToBeVisibleAsync();
+
+            await card.AddPortGroupAsync("rj45", "0.1", 1);
+            await card.AssertPortGroupVisibleAsync(0);
+
+            await card.AddPortGroupAsync("usb", "0.48", 2);
+            await card.AssertPortGroupVisibleAsync(1);
+
+            await page.ReloadAsync();
+            await card.AssertVisibleAsync(name);
+
+            await card.AssertPortVisibleAsync(0, 0);
+            await card.AssertPortVisibleAsync(1, 0);
+            await card.AssertPortVisibleAsync(1, 1);
+
+            await card.DeleteAsync(name);
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
 }
 }

+ 24 - 0
Tests.E2e/PageObjectModels/LaptopCardPom.cs

@@ -6,6 +6,10 @@ public class LaptopCardPom(IPage page) {
     public TagsPom Tags => new(page);
     public TagsPom Tags => new(page);
     public LabelsPom Labels => new(page);
     public LabelsPom Labels => new(page);
 
 
+    public PortsPom Ports => new(page);
+
+    private const string _portsPrefix = "laptop-ports";
+
     // -------------------------------------------------
     // -------------------------------------------------
     // Modals
     // Modals
     // -------------------------------------------------
     // -------------------------------------------------
@@ -184,4 +188,24 @@ public class LaptopCardPom(IPage page) {
 
 
     private static string Sanitize(string value)
     private static string Sanitize(string value)
         => value.Replace(" ", "-");
         => value.Replace(" ", "-");
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public ILocator PortGroupSection => Ports.Root(_portsPrefix);
+
+    public ILocator PortGroup(int index) => Ports.PortGroup(_portsPrefix, index);
+
+    public ILocator Port(int groupIndex, int portIndex)
+        => Ports.Port(_portsPrefix, groupIndex, portIndex);
+
+    public async Task AddPortGroupAsync(string type, string speed, int count)
+        => await Ports.AddPortGroupAsync(_portsPrefix, type, speed, count);
+
+    public async Task AssertPortGroupVisibleAsync(int index)
+        => await Ports.AssertPortGroupVisibleAsync(_portsPrefix, index);
+
+    public async Task AssertPortVisibleAsync(int groupIndex, int portIndex)
+        => await Ports.AssertPortVisibleAsync(_portsPrefix, groupIndex, portIndex);
 }
 }

+ 24 - 0
Tests.E2e/PageObjectModels/OtherCardPom.cs

@@ -6,6 +6,10 @@ public class OtherCardPom(IPage page) {
     public TagsPom Tags => new(page);
     public TagsPom Tags => new(page);
     public LabelsPom Labels => new(page);
     public LabelsPom Labels => new(page);
 
 
+    public PortsPom Ports => new(page);
+
+    private const string _portsPrefix = "other-ports";
+
     // -------------------------------------------------
     // -------------------------------------------------
     // Notes
     // Notes
     // -------------------------------------------------
     // -------------------------------------------------
@@ -139,4 +143,24 @@ public class OtherCardPom(IPage page) {
         await DeleteButton(name).ClickAsync();
         await DeleteButton(name).ClickAsync();
         await ConfirmDeleteButton.ClickAsync();
         await ConfirmDeleteButton.ClickAsync();
     }
     }
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public ILocator PortGroupSection => Ports.Root(_portsPrefix);
+
+    public ILocator PortGroup(int index) => Ports.PortGroup(_portsPrefix, index);
+
+    public ILocator Port(int groupIndex, int portIndex)
+        => Ports.Port(_portsPrefix, groupIndex, portIndex);
+
+    public async Task AddPortGroupAsync(string type, string speed, int count)
+        => await Ports.AddPortGroupAsync(_portsPrefix, type, speed, count);
+
+    public async Task AssertPortGroupVisibleAsync(int index)
+        => await Ports.AssertPortGroupVisibleAsync(_portsPrefix, index);
+
+    public async Task AssertPortVisibleAsync(int groupIndex, int portIndex)
+        => await Ports.AssertPortVisibleAsync(_portsPrefix, groupIndex, portIndex);
 }
 }

+ 24 - 0
Tests.E2e/PageObjectModels/UpsCardPom.cs

@@ -6,6 +6,10 @@ public class UpsCardPom(IPage page) {
     public TagsPom Tags => new(page);
     public TagsPom Tags => new(page);
     public LabelsPom Labels => new(page);
     public LabelsPom Labels => new(page);
 
 
+    public PortsPom Ports => new(page);
+
+    private const string _portsPrefix = "ups-ports";
+
     // -------------------------------------------------
     // -------------------------------------------------
     // Notes
     // Notes
     // -------------------------------------------------
     // -------------------------------------------------
@@ -139,4 +143,24 @@ public class UpsCardPom(IPage page) {
         await DeleteButton(name).ClickAsync();
         await DeleteButton(name).ClickAsync();
         await ConfirmDeleteButton.ClickAsync();
         await ConfirmDeleteButton.ClickAsync();
     }
     }
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public ILocator PortGroupSection => Ports.Root(_portsPrefix);
+
+    public ILocator PortGroup(int index) => Ports.PortGroup(_portsPrefix, index);
+
+    public ILocator Port(int groupIndex, int portIndex)
+        => Ports.Port(_portsPrefix, groupIndex, portIndex);
+
+    public async Task AddPortGroupAsync(string type, string speed, int count)
+        => await Ports.AddPortGroupAsync(_portsPrefix, type, speed, count);
+
+    public async Task AssertPortGroupVisibleAsync(int index)
+        => await Ports.AssertPortGroupVisibleAsync(_portsPrefix, index);
+
+    public async Task AssertPortVisibleAsync(int groupIndex, int portIndex)
+        => await Ports.AssertPortVisibleAsync(_portsPrefix, groupIndex, portIndex);
 }
 }

+ 48 - 0
Tests.E2e/UpsCardTests.cs

@@ -222,4 +222,52 @@ public class UpsCardTests(
             await context.CloseAsync();
             await context.CloseAsync();
         }
         }
     }
     }
+
+    // =============================================================
+    // Ports
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Add_Usb_And_Rj45_Port_Groups_To_A_Ups() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var name = $"e2e-ups-{Guid.NewGuid():N}"[..16];
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/ups/list");
+
+            var list = new UpsListPom(page);
+            await list.AddUpsAsync(name);
+
+            if (!page.Url.Contains($"/resources/hardware/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+                await list.OpenUpsAsync(name);
+
+            var card = new UpsCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await Assertions.Expect(card.PortGroupSection).ToBeVisibleAsync();
+
+            // The monitoring port: physically RJ45-shaped, enumerates as USB.
+            await card.AddPortGroupAsync("usb", "0.48", 1);
+            await card.AssertPortGroupVisibleAsync(0);
+
+            // The dataline surge pass-through pair.
+            await card.AddPortGroupAsync("rj45", "1", 2);
+            await card.AssertPortGroupVisibleAsync(1);
+
+            // Both groups must survive a round trip through the API.
+            await page.ReloadAsync();
+            await card.AssertVisibleAsync(name);
+
+            await card.AssertPortVisibleAsync(0, 0);
+            await card.AssertPortVisibleAsync(1, 0);
+            await card.AssertPortVisibleAsync(1, 1);
+
+            await card.DeleteAsync(name);
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
 }
 }

+ 7 - 0
Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs

@@ -49,6 +49,13 @@ public class LaptopCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputH
 
 
         // GPU help
         // GPU help
         Assert.Contains("Manage GPUs", (await ExecuteAsync("laptops", "gpu", "--help")).Item1);
         Assert.Contains("Manage GPUs", (await ExecuteAsync("laptops", "gpu", "--help")).Item1);
+
+        // NIC help
+        Assert.Contains("Manage network interface cards", (await ExecuteAsync("laptops", "nic", "--help")).Item1);
+        Assert.Contains("Add a NIC to a Laptop", (await ExecuteAsync("laptops", "nic", "add", "--help")).Item1);
+        Assert.Contains("Update a Laptop NIC", (await ExecuteAsync("laptops", "nic", "set", "--help")).Item1);
+        Assert.Contains("Remove a NIC from a Laptop", (await ExecuteAsync("laptops", "nic", "del", "--help")).Item1);
+
         Assert.Contains("Rename a Laptop", (await ExecuteAsync("laptops", "rename", "--help")).Item1);
         Assert.Contains("Rename a Laptop", (await ExecuteAsync("laptops", "rename", "--help")).Item1);
     }
     }
 
 

+ 49 - 0
Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs

@@ -87,4 +87,53 @@ public class LaptopErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
 
 
         Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
         Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
     }
     }
+
+
+    // NIC errors
+    [Fact]
+    public async Task nic_add_missing_laptop_returns_error() {
+        (var output, var _) = await ExecuteAsync(
+            "laptops", "nic", "add", "ghost",
+            "--type", "rj45",
+            "--speed", "1",
+            "--ports", "1"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task nic_add_invalid_type_returns_error() {
+        await ExecuteAsync("laptops", "add", "lap01");
+
+        (var output, var _) = await ExecuteAsync(
+            "laptops", "nic", "add", "lap01",
+            "--type", "not-a-port-type",
+            "--speed", "1",
+            "--ports", "1"
+        );
+
+        Assert.Contains("not valid", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task nic_set_invalid_index_returns_error() {
+        await ExecuteAsync("laptops", "add", "lap01");
+
+        (var output, var _) = await ExecuteAsync(
+            "laptops", "nic", "set", "lap01", "4",
+            "--type", "rj45"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task nic_del_invalid_index_returns_error() {
+        await ExecuteAsync("laptops", "add", "lap01");
+
+        (var output, var _) = await ExecuteAsync("laptops", "nic", "del", "lap01", "2");
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
 }
 }

+ 109 - 0
Tests/EndToEnd/LaptopTests/LaptopNicWorkflowTests.cs

@@ -0,0 +1,109 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.LaptopTests;
+
+[Collection("Yaml CLI tests")]
+public class LaptopNicWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<(string, string)> ExecuteAsync(params string[] args) {
+        outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
+
+        var output = await YamlCliTestHost.RunAsync(
+            args,
+            fs.Root,
+            outputHelper,
+            "config.yaml");
+
+        outputHelper.WriteLine(output);
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        return (output, yaml);
+    }
+
+    [Fact]
+    public async Task laptop_nic_cli_workflow_test() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("laptops", "add", "lap01");
+        await ExecuteAsync("laptops", "set", "lap01", "--model", "ThinkPad X1 Carbon");
+
+        // Built-in wired NIC.
+        (var output, var yaml) = await ExecuteAsync(
+            "laptops", "nic", "add", "lap01",
+            "--type", "rj45",
+            "--speed", "1",
+            "--ports", "1"
+        );
+        Assert.Equal("NIC added to Laptop 'lap01'.\n", output);
+
+        // Dock, attached over USB.
+        (output, yaml) = await ExecuteAsync(
+            "laptops", "nic", "add", "lap01",
+            "--type", "usb",
+            "--speed", "10",
+            "--ports", "2"
+        );
+        Assert.Equal("NIC added to Laptop 'lap01'.\n", output);
+
+        Assert.Equal("""
+                     version: 4
+                     resources:
+                     - kind: Laptop
+                       model: ThinkPad X1 Carbon
+                       ports:
+                       - type: rj45
+                         speed: 1
+                         count: 1
+                       - type: usb
+                         speed: 10
+                         count: 2
+                       name: lap01
+                     connections: []
+
+                     """, yaml);
+
+        (output, yaml) = await ExecuteAsync(
+            "laptops", "nic", "set", "lap01", "1",
+            "--type", "usb",
+            "--speed", "20",
+            "--ports", "2"
+        );
+        Assert.Equal("NIC #1 updated on Laptop 'lap01'.\n", output);
+        Assert.Contains("speed: 20", yaml);
+
+        // Describe reports the NIC count, matching how desktops report theirs.
+        (output, yaml) = await ExecuteAsync("laptops", "describe", "lap01");
+        Assert.Contains("NICs:", output);
+        Assert.Contains("2", output);
+
+        (output, yaml) = await ExecuteAsync("laptops", "nic", "del", "lap01", "1");
+        Assert.Equal("NIC #1 removed from Laptop 'lap01'.\n", output);
+
+        Assert.Equal("""
+                     version: 4
+                     resources:
+                     - kind: Laptop
+                       model: ThinkPad X1 Carbon
+                       ports:
+                       - type: rj45
+                         speed: 1
+                         count: 1
+                       name: lap01
+                     connections: []
+
+                     """, yaml);
+    }
+
+    [Fact]
+    public async Task describe_reports_zero_nics_when_laptop_has_none() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("laptops", "add", "lap-bare");
+
+        (var output, var _) = await ExecuteAsync("laptops", "describe", "lap-bare");
+
+        Assert.Contains("NICs:", output);
+        Assert.Contains("0", output);
+    }
+}

+ 13 - 0
Tests/EndToEnd/OtherTests/OtherCommandTests.cs

@@ -55,6 +55,19 @@ public class OtherCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHe
         Assert.Contains("Delete other hardware", delHelp);
         Assert.Contains("Delete other hardware", delHelp);
         (var renameHelp, var _) = await ExecuteAsync("other", "rename", "--help");
         (var renameHelp, var _) = await ExecuteAsync("other", "rename", "--help");
         Assert.Contains("Rename other hardware", renameHelp);
         Assert.Contains("Rename other hardware", renameHelp);
+
+        // Port help
+        (var portHelp, var _) = await ExecuteAsync("other", "port", "--help");
+        Assert.Contains("Manage ports on other hardware", portHelp);
+
+        (var portAddHelp, var _) = await ExecuteAsync("other", "port", "add", "--help");
+        Assert.Contains("Add a port to other hardware", portAddHelp);
+
+        (var portSetHelp, var _) = await ExecuteAsync("other", "port", "set", "--help");
+        Assert.Contains("Update an other hardware port", portSetHelp);
+
+        (var portDelHelp, var _) = await ExecuteAsync("other", "port", "del", "--help");
+        Assert.Contains("Remove a port from other hardware", portDelHelp);
     }
     }
 
 
     [Fact]
     [Fact]

+ 53 - 0
Tests/EndToEnd/OtherTests/OtherErrorTests.cs

@@ -57,4 +57,57 @@ public class OtherErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelp
 
 
         Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
         Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
     }
     }
+
+
+    // Port errors
+    [Fact]
+    public async Task port_add_missing_other_returns_error() {
+        (var output, var _) = await ExecuteAsync(
+            "other", "port", "add", "ghost",
+            "--type", "rj45",
+            "--speed", "1",
+            "--count", "1"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task port_add_invalid_type_returns_error() {
+        await ExecuteAsync("other", "add", "radio01");
+
+        (var output, var _) = await ExecuteAsync(
+            "other", "port", "add", "radio01",
+            "--type", "not-a-port-type",
+            "--speed", "1",
+            "--count", "1"
+        );
+
+        Assert.Contains("not valid", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task port_set_invalid_index_returns_error() {
+        await ExecuteAsync("other", "add", "radio01");
+
+        (var output, var _) = await ExecuteAsync(
+            "other", "port", "set", "radio01",
+            "--index", "5",
+            "--type", "rj45"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task port_del_invalid_index_returns_error() {
+        await ExecuteAsync("other", "add", "radio01");
+
+        (var output, var _) = await ExecuteAsync(
+            "other", "port", "del", "radio01",
+            "--index", "3"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
 }
 }

+ 114 - 0
Tests/EndToEnd/OtherTests/OtherPortWorkflowTests.cs

@@ -0,0 +1,114 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.OtherTests;
+
+[Collection("Yaml CLI tests")]
+public class OtherPortWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<(string, string)> ExecuteAsync(params string[] args) {
+        outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
+
+        var output = await YamlCliTestHost.RunAsync(
+            args,
+            fs.Root,
+            outputHelper,
+            "config.yaml");
+
+        outputHelper.WriteLine(output);
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        return (output, yaml);
+    }
+
+    [Fact]
+    public async Task other_port_cli_workflow_test() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("other", "add", "decoder01");
+        await ExecuteAsync(
+            "other", "set", "decoder01",
+            "--model", "TVIP-v605",
+            "--description", "IPTV set-top box"
+        );
+
+        (var output, var yaml) = await ExecuteAsync(
+            "other", "port", "add", "decoder01",
+            "--type", "rj45",
+            "--speed", "0.1",
+            "--count", "1"
+        );
+        Assert.Equal("Port added to other hardware 'decoder01'.\n", output);
+
+        (output, yaml) = await ExecuteAsync(
+            "other", "port", "add", "decoder01",
+            "--type", "usb",
+            "--speed", "0.48",
+            "--count", "2"
+        );
+        Assert.Equal("Port added to other hardware 'decoder01'.\n", output);
+
+        Assert.Equal("""
+                     version: 4
+                     resources:
+                     - kind: Other
+                       model: TVIP-v605
+                       description: IPTV set-top box
+                       ports:
+                       - type: rj45
+                         speed: 0.1
+                         count: 1
+                       - type: usb
+                         speed: 0.48
+                         count: 2
+                       name: decoder01
+                     connections: []
+
+                     """, yaml);
+
+        (output, yaml) = await ExecuteAsync(
+            "other", "port", "set", "decoder01",
+            "--index", "1",
+            "--type", "usb",
+            "--speed", "0.48",
+            "--count", "3"
+        );
+        Assert.Equal("Port #1 updated on other hardware 'decoder01'.\n", output);
+        Assert.Contains("count: 3", yaml);
+
+        (output, yaml) = await ExecuteAsync("other", "describe", "decoder01");
+        Assert.Contains("Ports:", output);
+        Assert.Contains("rj45: 1", output);
+        Assert.Contains("usb: 3", output);
+
+        (output, yaml) = await ExecuteAsync("other", "port", "del", "decoder01", "--index", "0");
+        Assert.Equal("Port #0 removed from other hardware 'decoder01'.\n", output);
+
+        Assert.Equal("""
+                     version: 4
+                     resources:
+                     - kind: Other
+                       model: TVIP-v605
+                       description: IPTV set-top box
+                       ports:
+                       - type: usb
+                         speed: 0.48
+                         count: 3
+                       name: decoder01
+                     connections: []
+
+                     """, yaml);
+    }
+
+    [Fact]
+    public async Task describe_reports_none_when_other_has_no_ports() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("other", "add", "bare01");
+
+        (var output, var _) = await ExecuteAsync("other", "describe", "bare01");
+
+        Assert.Contains("Ports:", output);
+        Assert.Contains("None", output);
+    }
+}

+ 13 - 0
Tests/EndToEnd/UpsTests/UpsCommandTests.cs

@@ -55,6 +55,19 @@ public class UpsCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelp
         Assert.Contains("Delete a UPS unit", delHelp);
         Assert.Contains("Delete a UPS unit", delHelp);
         (var renameHelp, var _) = await ExecuteAsync("ups", "rename", "--help");
         (var renameHelp, var _) = await ExecuteAsync("ups", "rename", "--help");
         Assert.Contains("Rename a UPS unit", renameHelp);
         Assert.Contains("Rename a UPS unit", renameHelp);
+
+        // Port help
+        (var portHelp, var _) = await ExecuteAsync("ups", "port", "--help");
+        Assert.Contains("Manage ports on a UPS unit", portHelp);
+
+        (var portAddHelp, var _) = await ExecuteAsync("ups", "port", "add", "--help");
+        Assert.Contains("Add a port to a UPS unit", portAddHelp);
+
+        (var portSetHelp, var _) = await ExecuteAsync("ups", "port", "set", "--help");
+        Assert.Contains("Update a UPS unit port", portSetHelp);
+
+        (var portDelHelp, var _) = await ExecuteAsync("ups", "port", "del", "--help");
+        Assert.Contains("Remove a port from a UPS unit", portDelHelp);
     }
     }
 
 
     [Fact]
     [Fact]

+ 53 - 0
Tests/EndToEnd/UpsTests/UpsErrorTest.cs

@@ -62,4 +62,57 @@ public class UpsErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper
 
 
         Assert.Contains("error", output, StringComparison.OrdinalIgnoreCase);
         Assert.Contains("error", output, StringComparison.OrdinalIgnoreCase);
     }
     }
+
+
+    // Port errors
+    [Fact]
+    public async Task port_add_missing_ups_returns_error() {
+        (var output, var _) = await ExecuteAsync(
+            "ups", "port", "add", "ghost",
+            "--type", "usb",
+            "--speed", "0.48",
+            "--count", "1"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task port_add_invalid_type_returns_error() {
+        await ExecuteAsync("ups", "add", "ups01");
+
+        (var output, var _) = await ExecuteAsync(
+            "ups", "port", "add", "ups01",
+            "--type", "not-a-port-type",
+            "--speed", "1",
+            "--count", "1"
+        );
+
+        Assert.Contains("not valid", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task port_set_invalid_index_returns_error() {
+        await ExecuteAsync("ups", "add", "ups01");
+
+        (var output, var _) = await ExecuteAsync(
+            "ups", "port", "set", "ups01",
+            "--index", "5",
+            "--type", "usb"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task port_del_invalid_index_returns_error() {
+        await ExecuteAsync("ups", "add", "ups01");
+
+        (var output, var _) = await ExecuteAsync(
+            "ups", "port", "del", "ups01",
+            "--index", "3"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
 }
 }

+ 119 - 0
Tests/EndToEnd/UpsTests/UpsPortWorkflowTests.cs

@@ -0,0 +1,119 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.UpsTests;
+
+[Collection("Yaml CLI tests")]
+public class UpsPortWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<(string, string)> ExecuteAsync(params string[] args) {
+        outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
+
+        var output = await YamlCliTestHost.RunAsync(
+            args,
+            fs.Root,
+            outputHelper,
+            "config.yaml");
+
+        outputHelper.WriteLine(output);
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        return (output, yaml);
+    }
+
+    [Fact]
+    public async Task ups_port_cli_workflow_test() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("ups", "add", "ups01");
+        await ExecuteAsync("ups", "set", "ups01", "--model", "APC-BGM2200", "--va", "2200");
+
+        // A UPS data port that is physically RJ45-shaped but enumerates as USB.
+        (var output, var yaml) = await ExecuteAsync(
+            "ups", "port", "add", "ups01",
+            "--type", "usb",
+            "--speed", "0.48",
+            "--count", "1"
+        );
+        Assert.Equal("Port added to UPS 'ups01'.\n", output);
+
+        // The dataline surge pass-through pair.
+        (output, yaml) = await ExecuteAsync(
+            "ups", "port", "add", "ups01",
+            "--type", "rj45",
+            "--speed", "1",
+            "--count", "2"
+        );
+        Assert.Equal("Port added to UPS 'ups01'.\n", output);
+
+        Assert.Equal("""
+                     version: 4
+                     resources:
+                     - kind: Ups
+                       model: APC-BGM2200
+                       va: 2200
+                       ports:
+                       - type: usb
+                         speed: 0.48
+                         count: 1
+                       - type: rj45
+                         speed: 1
+                         count: 2
+                       name: ups01
+                     connections: []
+
+                     """, yaml);
+
+        // Update the second group in place.
+        (output, yaml) = await ExecuteAsync(
+            "ups", "port", "set", "ups01",
+            "--index", "1",
+            "--type", "rj45",
+            "--speed", "1",
+            "--count", "4"
+        );
+        Assert.Equal("Port #1 updated on UPS 'ups01'.\n", output);
+        Assert.Contains("count: 4", yaml);
+
+        // Describe surfaces the port summary.
+        (output, yaml) = await ExecuteAsync("ups", "describe", "ups01");
+        Assert.Contains("Ports:", output);
+        Assert.Contains("usb: 1", output);
+        Assert.Contains("rj45: 4", output);
+
+        // Remove the pass-through pair again.
+        (output, yaml) = await ExecuteAsync("ups", "port", "del", "ups01", "--index", "1");
+        Assert.Equal("Port #1 removed from UPS 'ups01'.\n", output);
+
+        Assert.Equal("""
+                     version: 4
+                     resources:
+                     - kind: Ups
+                       model: APC-BGM2200
+                       va: 2200
+                       ports:
+                       - type: usb
+                         speed: 0.48
+                         count: 1
+                       name: ups01
+                     connections: []
+
+                     """, yaml);
+
+        (output, yaml) = await ExecuteAsync("ups", "describe", "ups01");
+        Assert.Contains("usb: 1", output);
+        Assert.DoesNotContain("rj45", output);
+    }
+
+    [Fact]
+    public async Task describe_reports_none_when_ups_has_no_ports() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("ups", "add", "ups-bare");
+
+        (var output, var _) = await ExecuteAsync("ups", "describe", "ups-bare");
+
+        Assert.Contains("Ports:", output);
+        Assert.Contains("None", output);
+    }
+}