From eb0331c4395a13cb858d153f21765e7d08047589 Mon Sep 17 00:00:00 2001 From: Thomas Hartwig Date: Fri, 24 Apr 2026 13:08:17 +0200 Subject: [PATCH 1/5] feat(commissioning): persist SpecificationVersion, SoftwareVersion, and SerialNumber (#50) Read the three new Basic Information attributes during commissioning and persist them to the node store. The tree output now shows Matter spec version, firmware version, and serial number alongside the existing vendor/product lines, omitting fields that are absent or zero so pre-1.3 and serial-less devices are unaffected. Co-Authored-By: Claude Sonnet 4.6 --- cli/commission.go | 13 ++-- cli/output/tree.go | 27 ++++++-- cli/output/tree_d2.go | 16 ++++- cli/tree.go | 15 +++-- .../clusters/basicinformation/specversion.go | 22 +++++++ .../basicinformation/specversion_test.go | 26 ++++++++ internal/commissioning/flow.go | 24 +++++-- internal/commissioning/flow_test.go | 64 +++++++++++++++++++ internal/store/types.go | 19 +++--- 9 files changed, 193 insertions(+), 33 deletions(-) create mode 100644 internal/clusters/basicinformation/specversion.go create mode 100644 internal/clusters/basicinformation/specversion_test.go diff --git a/cli/commission.go b/cli/commission.go index 066bcfa..12a3eb4 100644 --- a/cli/commission.go +++ b/cli/commission.go @@ -351,11 +351,14 @@ func nextNodeID() (uint64, error) { // buildNodeFromResult creates a store.Node populated from the commissioning result. func buildNodeFromResult(nodeID uint64, result *commissioning.CommissioningResult) *store.Node { node := &store.Node{ - ID: nodeID, - VendorID: result.VendorID, - ProductID: result.ProductID, - LastAddress: result.Address, - LastSeen: time.Now(), + ID: nodeID, + VendorID: result.VendorID, + ProductID: result.ProductID, + SpecificationVersion: result.SpecificationVersion, + SoftwareVersion: result.SoftwareVersion, + SerialNumber: result.SerialNumber, + LastAddress: result.Address, + LastSeen: time.Now(), } if result.ProductName != "" { node.Name = result.ProductName diff --git a/cli/output/tree.go b/cli/output/tree.go index f62668a..2f4288b 100644 --- a/cli/output/tree.go +++ b/cli/output/tree.go @@ -8,6 +8,7 @@ import ( "io" "strings" + "github.com/p0fi/matter-cli/internal/clusters/basicinformation" "github.com/p0fi/matter-cli/internal/store" "github.com/p0fi/matter-cli/internal/vendordb" ) @@ -166,13 +167,16 @@ type TreeEndpoint struct { // TreeData is the full device tree data passed to FormatRichTree. type TreeData struct { - NodeID uint64 - NodeName string - VendorID uint16 - ProductID uint16 - LastAddress string - Endpoints []TreeEndpoint - Level int + NodeID uint64 + NodeName string + VendorID uint16 + ProductID uint16 + SpecificationVersion uint32 + SoftwareVersion uint32 + SerialNumber string + LastAddress string + Endpoints []TreeEndpoint + Level int } // FormatRichTree renders the device tree with depth controlled by data.Level: @@ -193,6 +197,15 @@ func FormatRichTree(w io.Writer, data *TreeData) error { fmt.Fprintf(w, "%s %s\n", Bold(name), Muted(fmt.Sprintf("(Node %d)", data.NodeID))) fmt.Fprintf(w, " %s %s\n", Label("Vendor:"), Accent(vendordb.FormatVendorID(data.VendorID))) fmt.Fprintf(w, " %s %s\n", Label("Product:"), Accent(fmt.Sprintf("0x%04X", data.ProductID))) + if sv := basicinformation.FormatSpecVersion(data.SpecificationVersion); sv != "" { + fmt.Fprintf(w, " %s Matter %s\n", Label("Matter:"), Value(sv)) + } + if data.SoftwareVersion != 0 { + fmt.Fprintf(w, " %s %s\n", Label("FW ver:"), Value(fmt.Sprintf("%d", data.SoftwareVersion))) + } + if data.SerialNumber != "" { + fmt.Fprintf(w, " %s %s\n", Label("Serial:"), Value(data.SerialNumber)) + } if data.LastAddress != "" { fmt.Fprintf(w, " %s %s\n", Label("Address:"), Value(data.LastAddress)) } diff --git a/cli/output/tree_d2.go b/cli/output/tree_d2.go index ed1ce8e..7228b53 100644 --- a/cli/output/tree_d2.go +++ b/cli/output/tree_d2.go @@ -9,6 +9,7 @@ import ( "os" "strings" + "github.com/p0fi/matter-cli/internal/clusters/basicinformation" "github.com/p0fi/matter-cli/internal/vendordb" "oss.terrastruct.com/d2/d2graph" "oss.terrastruct.com/d2/d2layouts/d2dagrelayout" @@ -128,8 +129,21 @@ func buildD2Script(data *TreeData) string { name = "Unnamed" } - // Node container label includes vendor/product info. + // Node container label includes vendor/product info and optional device details. nodeLabel := fmt.Sprintf("%s · ProductID: 0x%04X", vendordb.FormatVendorID(data.VendorID), data.ProductID) + var details []string + if sv := basicinformation.FormatSpecVersion(data.SpecificationVersion); sv != "" { + details = append(details, "Matter "+sv) + } + if data.SoftwareVersion != 0 { + details = append(details, fmt.Sprintf("FW %d", data.SoftwareVersion)) + } + if data.SerialNumber != "" { + details = append(details, "SN "+data.SerialNumber) + } + if len(details) > 0 { + nodeLabel += "\n" + strings.Join(details, " · ") + } fmt.Fprintf(&sb, "%s: %q {\n", d2SafeKey(name), nodeLabel) fmt.Fprintf(&sb, " grid-columns: 2\n") diff --git a/cli/tree.go b/cli/tree.go index 6ad4417..7c4440a 100644 --- a/cli/tree.go +++ b/cli/tree.go @@ -131,12 +131,15 @@ func openFile(path string) error { // lists and, optionally, attribute values from the device. func buildTreeData(ctx context.Context, w io.Writer, node *store.Node, level int, verbose bool) (*output.TreeData, error) { data := &output.TreeData{ - NodeID: node.ID, - NodeName: node.Name, - VendorID: node.VendorID, - ProductID: node.ProductID, - LastAddress: node.LastAddress, - Level: level, + NodeID: node.ID, + NodeName: node.Name, + VendorID: node.VendorID, + ProductID: node.ProductID, + SpecificationVersion: node.SpecificationVersion, + SoftwareVersion: node.SoftwareVersion, + SerialNumber: node.SerialNumber, + LastAddress: node.LastAddress, + Level: level, } // Populate basic structure from store data (always needed). diff --git a/internal/clusters/basicinformation/specversion.go b/internal/clusters/basicinformation/specversion.go new file mode 100644 index 0000000..98e9221 --- /dev/null +++ b/internal/clusters/basicinformation/specversion.go @@ -0,0 +1,22 @@ +// Copyright 2026 matter-cli contributors +// SPDX-License-Identifier: Apache-2.0 + +package basicinformation + +import "fmt" + +// FormatSpecVersion converts a packed SpecificationVersion uint32 (e.g. +// 0x01030000 for Matter 1.3) into a human-readable string ("1.3"). Trailing +// zero minor/patch components are omitted. Returns an empty string for zero. +func FormatSpecVersion(v uint32) string { + if v == 0 { + return "" + } + major := (v >> 24) & 0xFF + minor := (v >> 16) & 0xFF + patch := (v >> 8) & 0xFF + if patch != 0 { + return fmt.Sprintf("%d.%d.%d", major, minor, patch) + } + return fmt.Sprintf("%d.%d", major, minor) +} diff --git a/internal/clusters/basicinformation/specversion_test.go b/internal/clusters/basicinformation/specversion_test.go new file mode 100644 index 0000000..dfb225a --- /dev/null +++ b/internal/clusters/basicinformation/specversion_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 matter-cli contributors +// SPDX-License-Identifier: Apache-2.0 + +package basicinformation + +import "testing" + +func TestFormatSpecVersion(t *testing.T) { + tests := []struct { + v uint32 + want string + }{ + {0x00000000, ""}, + {0x01030000, "1.3"}, + {0x01040000, "1.4"}, + {0x01000000, "1.0"}, + {0x01030100, "1.3.1"}, + {0x02010200, "2.1.2"}, + } + for _, tt := range tests { + got := FormatSpecVersion(tt.v) + if got != tt.want { + t.Errorf("FormatSpecVersion(0x%08X) = %q, want %q", tt.v, got, tt.want) + } + } +} diff --git a/internal/commissioning/flow.go b/internal/commissioning/flow.go index 8a199ba..2a42049 100644 --- a/internal/commissioning/flow.go +++ b/internal/commissioning/flow.go @@ -172,12 +172,15 @@ type Commissioner struct { // CommissioningResult contains device information gathered during commissioning. type CommissioningResult struct { - VendorName string - VendorID uint16 - ProductName string - ProductID uint16 - Address string // host:port used to reach the device - Endpoints []EndpointInfo // discovered endpoints and their clusters + VendorName string + VendorID uint16 + ProductName string + ProductID uint16 + SpecificationVersion uint32 // Matter spec revision (e.g. 0x01030000 = 1.3); zero on pre-1.3 devices + SoftwareVersion uint32 // firmware version reported by the device + SerialNumber string // optional per-spec; empty if the device doesn't expose it + Address string // host:port used to reach the device + Endpoints []EndpointInfo // discovered endpoints and their clusters } // EndpointInfo describes a single endpoint discovered via the Descriptor cluster. @@ -623,6 +626,15 @@ func (c *Commissioner) readDeviceInfo(ctx context.Context, session Session, resu if data, err := c.Client.ReadAttribute(ctx, session, 0, basicInfo, 0x0004); err == nil { result.ProductID = decodeTLVUint16(data) } + if data, err := c.Client.ReadAttribute(ctx, session, 0, basicInfo, 0x0009); err == nil { + result.SoftwareVersion = decodeTLVUint32(data) + } + if data, err := c.Client.ReadAttribute(ctx, session, 0, basicInfo, 0x000F); err == nil { + result.SerialNumber = decodeTLVString(data) + } + if data, err := c.Client.ReadAttribute(ctx, session, 0, basicInfo, 0x0015); err == nil { + result.SpecificationVersion = decodeTLVUint32(data) + } // Read endpoint structure from Descriptor cluster. // PartsList (attr 0x0003) on endpoint 0 lists all non-root endpoints. diff --git a/internal/commissioning/flow_test.go b/internal/commissioning/flow_test.go index 04e3161..e4c673c 100644 --- a/internal/commissioning/flow_test.go +++ b/internal/commissioning/flow_test.go @@ -1084,3 +1084,67 @@ func TestCommissioner_Commission_Cancelled(t *testing.T) { t.Fatal("expected error for cancelled context") } } + +// TestReadDeviceInfo_NewAttributes verifies that readDeviceInfo reads +// SpecificationVersion, SoftwareVersion, and SerialNumber from the device +// and populates the result correctly. +func TestReadDeviceInfo_NewAttributes(t *testing.T) { + const basicInfo = uint32(0x0028) + + swVerTLV := encodeTLVUint32(42) + specVerTLV := encodeTLVUint32(0x01030000) + + serialW := tlv.NewWriter() + _ = serialW.PutUTF8String(tlv.AnonymousTag(), "SN-ABC123") + serialTLV := serialW.Bytes() + + c := newTestCommissioner() + mc := c.Client.(*mockInteractionClient) + mc.readOverrides = map[attrKey]struct { + data []byte + err error + }{ + {endpoint: 0, cluster: basicInfo, attribute: 0x0009}: {data: swVerTLV}, + {endpoint: 0, cluster: basicInfo, attribute: 0x000F}: {data: serialTLV}, + {endpoint: 0, cluster: basicInfo, attribute: 0x0015}: {data: specVerTLV}, + } + + result := &CommissioningResult{} + session := &mockSession{} + c.readDeviceInfo(context.Background(), session, result) + + if result.SoftwareVersion != 42 { + t.Errorf("SoftwareVersion: got %d, want 42", result.SoftwareVersion) + } + if result.SerialNumber != "SN-ABC123" { + t.Errorf("SerialNumber: got %q, want %q", result.SerialNumber, "SN-ABC123") + } + if result.SpecificationVersion != 0x01030000 { + t.Errorf("SpecificationVersion: got 0x%08X, want 0x01030000", result.SpecificationVersion) + } +} + +// TestReadDeviceInfo_MissingAttributes verifies that readDeviceInfo does not +// fail when SpecificationVersion, SoftwareVersion, or SerialNumber are absent +// (pre-1.3 or serial-less devices). +func TestReadDeviceInfo_MissingAttributes(t *testing.T) { + c := newTestCommissioner() + // Default mock returns an error for every attribute — simulates a device + // that exposes only the mandatory attributes but not the optional ones. + mc := c.Client.(*mockInteractionClient) + mc.readErr = fmt.Errorf("UNSUPPORTED_ATTRIBUTE") + + result := &CommissioningResult{} + session := &mockSession{} + c.readDeviceInfo(context.Background(), session, result) + + if result.SpecificationVersion != 0 { + t.Errorf("SpecificationVersion should be zero for pre-1.3 device, got %d", result.SpecificationVersion) + } + if result.SoftwareVersion != 0 { + t.Errorf("SoftwareVersion should be zero when absent, got %d", result.SoftwareVersion) + } + if result.SerialNumber != "" { + t.Errorf("SerialNumber should be empty when absent, got %q", result.SerialNumber) + } +} diff --git a/internal/store/types.go b/internal/store/types.go index d1a063e..7d34e8b 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -21,14 +21,17 @@ type Fabric struct { // Node represents a commissioned Matter device within a fabric. type Node struct { - ID uint64 `json:"id"` - FabricID uint64 `json:"fabric_id"` - Name string `json:"name"` - VendorID uint16 `json:"vendor_id"` - ProductID uint16 `json:"product_id"` - Endpoints []Endpoint `json:"endpoints"` - LastAddress string `json:"last_address"` - LastSeen time.Time `json:"last_seen"` + ID uint64 `json:"id"` + FabricID uint64 `json:"fabric_id"` + Name string `json:"name"` + VendorID uint16 `json:"vendor_id"` + ProductID uint16 `json:"product_id"` + SpecificationVersion uint32 `json:"specification_version,omitempty"` + SoftwareVersion uint32 `json:"software_version,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + Endpoints []Endpoint `json:"endpoints"` + LastAddress string `json:"last_address"` + LastSeen time.Time `json:"last_seen"` } // Endpoint represents a single endpoint on a Matter node. From d605561abfb49af88b7836e82b16f79f78a805ed Mon Sep 17 00:00:00 2001 From: Thomas Hartwig Date: Fri, 24 Apr 2026 14:14:58 +0200 Subject: [PATCH 2/5] fix: address Copilot review feedback on specversion doc and test accuracy - Correct FormatSpecVersion doc comment: minor is always included, only trailing zero patch is omitted. - Replace global readErr in TestReadDeviceInfo_MissingAttributes with per-attribute readOverrides for the three optional attributes, so the test accurately simulates a device that exposes mandatory attributes but not SoftwareVersion/SerialNumber/SpecificationVersion. Co-Authored-By: Claude Sonnet 4.6 --- .../clusters/basicinformation/specversion.go | 5 +++-- internal/commissioning/flow_test.go | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/internal/clusters/basicinformation/specversion.go b/internal/clusters/basicinformation/specversion.go index 98e9221..88cc81a 100644 --- a/internal/clusters/basicinformation/specversion.go +++ b/internal/clusters/basicinformation/specversion.go @@ -6,8 +6,9 @@ package basicinformation import "fmt" // FormatSpecVersion converts a packed SpecificationVersion uint32 (e.g. -// 0x01030000 for Matter 1.3) into a human-readable string ("1.3"). Trailing -// zero minor/patch components are omitted. Returns an empty string for zero. +// 0x01030000 for Matter 1.3) into a human-readable string ("1.3"). The minor +// component is always included; a trailing zero patch component is omitted. +// Returns an empty string for zero. func FormatSpecVersion(v uint32) string { if v == 0 { return "" diff --git a/internal/commissioning/flow_test.go b/internal/commissioning/flow_test.go index e4c673c..76ae933 100644 --- a/internal/commissioning/flow_test.go +++ b/internal/commissioning/flow_test.go @@ -1129,10 +1129,20 @@ func TestReadDeviceInfo_NewAttributes(t *testing.T) { // (pre-1.3 or serial-less devices). func TestReadDeviceInfo_MissingAttributes(t *testing.T) { c := newTestCommissioner() - // Default mock returns an error for every attribute — simulates a device - // that exposes only the mandatory attributes but not the optional ones. + // Override only the three new optional attributes to return + // UNSUPPORTED_ATTRIBUTE, simulating a device that supports the mandatory + // BasicInformation attributes but not SoftwareVersion, SerialNumber, or + // SpecificationVersion. mc := c.Client.(*mockInteractionClient) - mc.readErr = fmt.Errorf("UNSUPPORTED_ATTRIBUTE") + unsupported := fmt.Errorf("UNSUPPORTED_ATTRIBUTE") + mc.readOverrides = map[attrKey]struct { + data []byte + err error + }{ + {0, 0x0028, 0x0009}: {err: unsupported}, // SoftwareVersion + {0, 0x0028, 0x000F}: {err: unsupported}, // SerialNumber + {0, 0x0028, 0x0015}: {err: unsupported}, // SpecificationVersion + } result := &CommissioningResult{} session := &mockSession{} From 3c68d873aca668d3e6e566f3138e4d0843a40e01 Mon Sep 17 00:00:00 2001 From: Thomas Hartwig Date: Fri, 24 Apr 2026 14:22:22 +0200 Subject: [PATCH 3/5] style: Align Struct Field Comments in CommissioningResult --- internal/commissioning/flow.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/commissioning/flow.go b/internal/commissioning/flow.go index 2a42049..4b7e18a 100644 --- a/internal/commissioning/flow.go +++ b/internal/commissioning/flow.go @@ -176,9 +176,9 @@ type CommissioningResult struct { VendorID uint16 ProductName string ProductID uint16 - SpecificationVersion uint32 // Matter spec revision (e.g. 0x01030000 = 1.3); zero on pre-1.3 devices - SoftwareVersion uint32 // firmware version reported by the device - SerialNumber string // optional per-spec; empty if the device doesn't expose it + SpecificationVersion uint32 // Matter spec revision (e.g. 0x01030000 = 1.3); zero on pre-1.3 devices + SoftwareVersion uint32 // firmware version reported by the device + SerialNumber string // optional per-spec; empty if the device doesn't expose it Address string // host:port used to reach the device Endpoints []EndpointInfo // discovered endpoints and their clusters } From 06c70f5c79a3a7df7157ec07a866c15596666ae9 Mon Sep 17 00:00:00 2001 From: Thomas Hartwig Date: Fri, 24 Apr 2026 14:36:47 +0200 Subject: [PATCH 4/5] style: rename and align tree node header labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename labels for clarity (Product → Product ID, Matter → Spec Version, Serial → Serial Number) and pad all labels to a fixed width so values line up in a single column. Co-Authored-By: Claude Sonnet 4.6 --- cli/output/tree.go | 19 ++++++++++--------- cli/output/tree_d2.go | 6 +++--- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/cli/output/tree.go b/cli/output/tree.go index 2f4288b..eed71ee 100644 --- a/cli/output/tree.go +++ b/cli/output/tree.go @@ -91,10 +91,10 @@ func FormatTree(w io.Writer, node *store.Node) error { name = "Unnamed" } fmt.Fprintf(w, "%s %s\n", Bold(name), Muted(fmt.Sprintf("(Node %d)", node.ID))) - fmt.Fprintf(w, " %s %s\n", Label("Vendor:"), Accent(vendordb.FormatVendorID(node.VendorID))) - fmt.Fprintf(w, " %s %s\n", Label("Product:"), Accent(fmt.Sprintf("0x%04X", node.ProductID))) + fmt.Fprintf(w, " %s %s\n", Label(fmt.Sprintf("%-11s", "Vendor:")), Accent(vendordb.FormatVendorID(node.VendorID))) + fmt.Fprintf(w, " %s %s\n", Label(fmt.Sprintf("%-11s", "Product ID:")), Accent(fmt.Sprintf("0x%04X", node.ProductID))) if node.LastAddress != "" { - fmt.Fprintf(w, " %s %s\n", Label("Address:"), Value(node.LastAddress)) + fmt.Fprintf(w, " %s %s\n", Label(fmt.Sprintf("%-11s", "Address:")), Value(node.LastAddress)) } fmt.Fprintln(w) @@ -195,19 +195,20 @@ func FormatRichTree(w io.Writer, data *TreeData) error { name = "Unnamed" } fmt.Fprintf(w, "%s %s\n", Bold(name), Muted(fmt.Sprintf("(Node %d)", data.NodeID))) - fmt.Fprintf(w, " %s %s\n", Label("Vendor:"), Accent(vendordb.FormatVendorID(data.VendorID))) - fmt.Fprintf(w, " %s %s\n", Label("Product:"), Accent(fmt.Sprintf("0x%04X", data.ProductID))) + lbl := func(s string) string { return Label(fmt.Sprintf("%-14s", s)) } + fmt.Fprintf(w, " %s %s\n", lbl("Vendor:"), Accent(vendordb.FormatVendorID(data.VendorID))) + fmt.Fprintf(w, " %s %s\n", lbl("Product ID:"), Accent(fmt.Sprintf("0x%04X", data.ProductID))) if sv := basicinformation.FormatSpecVersion(data.SpecificationVersion); sv != "" { - fmt.Fprintf(w, " %s Matter %s\n", Label("Matter:"), Value(sv)) + fmt.Fprintf(w, " %s %s\n", lbl("Spec Version:"), Value(sv)) } if data.SoftwareVersion != 0 { - fmt.Fprintf(w, " %s %s\n", Label("FW ver:"), Value(fmt.Sprintf("%d", data.SoftwareVersion))) + fmt.Fprintf(w, " %s %s\n", lbl("FW ver:"), Value(fmt.Sprintf("%d", data.SoftwareVersion))) } if data.SerialNumber != "" { - fmt.Fprintf(w, " %s %s\n", Label("Serial:"), Value(data.SerialNumber)) + fmt.Fprintf(w, " %s %s\n", lbl("Serial Number:"), Value(data.SerialNumber)) } if data.LastAddress != "" { - fmt.Fprintf(w, " %s %s\n", Label("Address:"), Value(data.LastAddress)) + fmt.Fprintf(w, " %s %s\n", lbl("Address:"), Value(data.LastAddress)) } fmt.Fprintln(w) diff --git a/cli/output/tree_d2.go b/cli/output/tree_d2.go index 7228b53..e5369cf 100644 --- a/cli/output/tree_d2.go +++ b/cli/output/tree_d2.go @@ -130,16 +130,16 @@ func buildD2Script(data *TreeData) string { } // Node container label includes vendor/product info and optional device details. - nodeLabel := fmt.Sprintf("%s · ProductID: 0x%04X", vendordb.FormatVendorID(data.VendorID), data.ProductID) + nodeLabel := fmt.Sprintf("%s · Product ID: 0x%04X", vendordb.FormatVendorID(data.VendorID), data.ProductID) var details []string if sv := basicinformation.FormatSpecVersion(data.SpecificationVersion); sv != "" { - details = append(details, "Matter "+sv) + details = append(details, "Spec Version: "+sv) } if data.SoftwareVersion != 0 { details = append(details, fmt.Sprintf("FW %d", data.SoftwareVersion)) } if data.SerialNumber != "" { - details = append(details, "SN "+data.SerialNumber) + details = append(details, "Serial Number: "+data.SerialNumber) } if len(details) > 0 { nodeLabel += "\n" + strings.Join(details, " · ") From d6ee7b13b434fb007208ccf7dac3ec194db07f33 Mon Sep 17 00:00:00 2001 From: Thomas Hartwig Date: Fri, 24 Apr 2026 14:42:58 +0200 Subject: [PATCH 5/5] fix(test): update D2 test assertion to match renamed Product ID label Co-Authored-By: Claude Sonnet 4.6 --- cli/output/tree_d2_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/output/tree_d2_test.go b/cli/output/tree_d2_test.go index 445341d..4982ffe 100644 --- a/cli/output/tree_d2_test.go +++ b/cli/output/tree_d2_test.go @@ -31,7 +31,7 @@ func TestBuildD2Script_Level1(t *testing.T) { assert.Contains(t, script, "Kitchen Light") assert.Contains(t, script, "0x1234") - assert.Contains(t, script, "ProductID: 0x5678") + assert.Contains(t, script, "Product ID: 0x5678") // Nested: endpoints inside node container assert.Contains(t, script, "ep0:") assert.Contains(t, script, "ep1:")