@@ -2,11 +2,238 @@ package state_test
22
33import (
44 "encoding/json"
5+ "reflect"
56 "testing"
67
78 "github.com/stablekernel/crucible/state"
89)
910
11+ // fieldShape is one exported field of a serialized struct, captured as its Go
12+ // field NAME and its full `json:` struct tag. Together these are the wire
13+ // contract a recorded document depends on, so the frozen expectation pins both.
14+ type fieldShape struct {
15+ Name string
16+ JSONTag string
17+ }
18+
19+ // reflectShape walks the exported fields of a struct type and returns each
20+ // field's name and its `json:` tag, in declaration order. Unexported fields
21+ // (e.g. the `extra` round-trip buffer) carry no wire shape and are skipped, so
22+ // the frozen expectation pins exactly the bytes that cross the wire.
23+ func reflectShape (t * testing.T , v any ) []fieldShape {
24+ t .Helper ()
25+ rt := reflect .TypeOf (v )
26+ if rt .Kind () != reflect .Struct {
27+ t .Fatalf ("reflectShape: %T is not a struct" , v )
28+ }
29+ var out []fieldShape
30+ for i := 0 ; i < rt .NumField (); i ++ {
31+ f := rt .Field (i )
32+ if f .PkgPath != "" { // unexported: no wire shape
33+ continue
34+ }
35+ out = append (out , fieldShape {Name : f .Name , JSONTag : f .Tag .Get ("json" )})
36+ }
37+ return out
38+ }
39+
40+ // assertShape compares a serialized type's reflected exported-field shape against
41+ // the frozen expectation, field by field, and fails on any rename, removal,
42+ // reorder, retag, or unexpected addition.
43+ func assertShape (t * testing.T , name string , got , want []fieldShape ) {
44+ t .Helper ()
45+ if len (got ) != len (want ) {
46+ t .Fatalf ("%s field count = %d, want %d (frozen v1 wire shape)\n got: %+v\n want: %+v" ,
47+ name , len (got ), len (want ), got , want )
48+ }
49+ for i := range want {
50+ if got [i ] != want [i ] {
51+ t .Fatalf ("%s field[%d] = {Name:%q JSONTag:%q}, want {Name:%q JSONTag:%q} (frozen v1 wire shape)" ,
52+ name , i , got [i ].Name , got [i ].JSONTag , want [i ].Name , want [i ].JSONTag )
53+ }
54+ }
55+ }
56+
57+ // TestWireShape_Frozen is the v1.0 WIRE FREEZE guard for the serialized IR and
58+ // palette structs. It reflects over each serialized type and pins the exact set,
59+ // order, NAMES, and `json:` tags (including the omitempty flag) of its exported
60+ // fields. A recorded document is parsed by these names and tags, so renaming,
61+ // removing, reordering, or retagging any field is a breaking wire change and must
62+ // break this test.
63+ //
64+ // HOW TO UPDATE (deliberately, additive only): appending a new optional field
65+ // with an `omitempty` tag is a backward-compatible (minor) change — add the new
66+ // {Name, JSONTag} entry to the END of the relevant want slice in the same order
67+ // the struct declares it. Any rename/removal/reorder/retag is a breaking (major)
68+ // change and the test SHOULD fail until the wire-format version is bumped
69+ // deliberately. The `extra` unexported round-trip buffer carries no wire shape
70+ // and is intentionally not pinned here.
71+ func TestWireShape_Frozen (t * testing.T ) {
72+ t .Parallel ()
73+
74+ // Generic IR types are instantiated at concrete string/string/any params; the
75+ // type params do not change a field's name or json tag, only the element type.
76+ type S = string
77+ type E = string
78+ type C = any
79+
80+ cases := []struct {
81+ name string
82+ zero any
83+ want []fieldShape
84+ }{
85+ {
86+ name : "IR" ,
87+ zero : state.IR [S , E , C ]{},
88+ want : []fieldShape {
89+ {"SchemaVersion" , "schemaVersion,omitempty" },
90+ {"ID" , "id,omitempty" },
91+ {"Name" , "name" },
92+ {"Version" , "version,omitempty" },
93+ {"Input" , "input,omitempty" },
94+ {"Output" , "output,omitempty" },
95+ {"Context" , "context,omitempty" },
96+ {"States" , "states,omitempty" },
97+ {"Initial" , "initial" },
98+ {"HasInitial" , "hasInitial" },
99+ {"Meta" , "meta,omitempty" },
100+ },
101+ },
102+ {
103+ name : "State" ,
104+ zero : state.State [S , E , C ]{},
105+ want : []fieldShape {
106+ {"Name" , "name" },
107+ {"OwnedBy" , "ownedBy,omitempty" },
108+ {"Transitions" , "transitions,omitempty" },
109+ {"OnEntry" , "onEntry,omitempty" },
110+ {"OnExit" , "onExit,omitempty" },
111+ {"IsFinal" , "isFinal,omitempty" },
112+ {"OnDone" , "onDone,omitempty" },
113+ {"OnEntryAssign" , "onEntryAssign,omitempty" },
114+ {"OnExitAssign" , "onExitAssign,omitempty" },
115+ {"Children" , "children,omitempty" },
116+ {"InitialChild" , "initialChild,omitempty" },
117+ {"Regions" , "regions,omitempty" },
118+ {"HistoryType" , "historyType,omitempty" },
119+ {"HistoryDefault" , "historyDefault,omitempty" },
120+ {"Invoke" , "invoke,omitempty" },
121+ {"Parent" , "-" },
122+ {"Meta" , "meta,omitempty" },
123+ },
124+ },
125+ {
126+ name : "Transition" ,
127+ zero : state.Transition [S , E , C ]{},
128+ want : []fieldShape {
129+ {"From" , "from" },
130+ {"To" , "to" },
131+ {"On" , "on" },
132+ {"Guards" , "guards,omitempty" },
133+ {"Effects" , "effects,omitempty" },
134+ {"WaitMode" , "waitMode,omitempty" },
135+ {"Assigns" , "assigns,omitempty" },
136+ {"GuardExpr" , "guardExpr,omitempty" },
137+ {"Internal" , "internal,omitempty" },
138+ {"EventLess" , "eventLess,omitempty" },
139+ {"After" , "after,omitempty" },
140+ {"Wildcard" , "wildcard,omitempty" },
141+ {"Forbidden" , "forbidden,omitempty" },
142+ {"Reenter" , "reenter,omitempty" },
143+ {"Raise" , "raise,omitempty" },
144+ {"SrcFile" , "srcFile,omitempty" },
145+ {"SrcLine" , "srcLine,omitempty" },
146+ {"Meta" , "meta,omitempty" },
147+ },
148+ },
149+ {
150+ name : "Region" ,
151+ zero : state.Region [S , E , C ]{},
152+ want : []fieldShape {
153+ {"Name" , "name" },
154+ {"States" , "states,omitempty" },
155+ {"InitialChild" , "initialChild,omitempty" },
156+ {"Meta" , "meta,omitempty" },
157+ },
158+ },
159+ {
160+ name : "Invocation" ,
161+ zero : state.Invocation [S , E , C ]{},
162+ want : []fieldShape {
163+ {"ID" , "id,omitempty" },
164+ {"Src" , "src" },
165+ {"Input" , "input,omitempty" },
166+ {"OnDone" , "onDone" },
167+ {"OnError" , "onError" },
168+ {"Kind" , "kind,omitempty" },
169+ {"SystemID" , "systemId,omitempty" },
170+ {"Meta" , "meta,omitempty" },
171+ },
172+ },
173+ {
174+ name : "Ref" ,
175+ zero : state.Ref {},
176+ want : []fieldShape {
177+ {"Name" , "name" },
178+ {"Params" , "params,omitempty" },
179+ {"Meta" , "meta,omitempty" },
180+ },
181+ },
182+ {
183+ name : "ContextSchema" ,
184+ zero : state.ContextSchema {},
185+ want : []fieldShape {
186+ {"Fields" , "fields,omitempty" },
187+ {"Meta" , "meta,omitempty" },
188+ },
189+ },
190+ {
191+ name : "Descriptor" ,
192+ zero : state.Descriptor {},
193+ want : []fieldShape {
194+ {"Kind" , "kind" },
195+ {"Name" , "name" },
196+ {"Description" , "description,omitempty" },
197+ {"Category" , "category,omitempty" },
198+ {"Examples" , "examples,omitempty" },
199+ {"Params" , "params,omitempty" },
200+ {"Reads" , "reads,omitempty" },
201+ {"Writes" , "writes,omitempty" },
202+ {"Binding" , "binding,omitempty" },
203+ },
204+ },
205+ {
206+ name : "ParamSpec" ,
207+ zero : state.ParamSpec {},
208+ want : []fieldShape {
209+ {"Name" , "name" },
210+ {"Type" , "type" },
211+ {"Required" , "required,omitempty" },
212+ {"Description" , "description,omitempty" },
213+ {"Default" , "default,omitempty" },
214+ {"Enum" , "enum,omitempty" },
215+ {"Examples" , "examples,omitempty" },
216+ },
217+ },
218+ {
219+ name : "BindingSpec" ,
220+ zero : state.BindingSpec {},
221+ want : []fieldShape {
222+ {"Transport" , "transport,omitempty" },
223+ {"Meta" , "meta,omitempty" },
224+ },
225+ },
226+ }
227+
228+ for _ , tc := range cases {
229+ tc := tc
230+ t .Run (tc .name , func (t * testing.T ) {
231+ t .Parallel ()
232+ assertShape (t , tc .name , reflectShape (t , tc .zero ), tc .want )
233+ })
234+ }
235+ }
236+
10237// TestEnumWireValues_Frozen pins the numeric wire value of every closed-int enum
11238// that serializes as a bare integer (WaitMode, HistoryType, ActorKind). These
12239// integers are part of the frozen v1.0 wire contract: a recorded document encodes
0 commit comments