Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions clients/go/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ tag whose matching `## [X.Y.Z]` heading is missing from this file.

## [Unreleased]

### Changed

- **BREAKING:** `ChangesetOperationTargetRange` is now a nested `TextRange`
(`{start: {line, character}, end: {line, character}}`) instead of flat
`{start, end}` `int64` fields.

### Fixed

- `ActionEnvelope.Origin` is now omitted from JSON output when absent
(`json:"origin,omitempty"`) instead of serializing as `null`.

### Added

- `SnapshotState.ResourceWatch` pointer field — the `Snapshot.state` union
Expand Down
2 changes: 1 addition & 1 deletion clients/go/ahptypes/actions.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ type ActionEnvelope struct {
Channel URI `json:"channel"`
Action StateAction `json:"action"`
ServerSeq int64 `json:"serverSeq"`
Origin *ActionOrigin `json:"origin"`
Origin *ActionOrigin `json:"origin,omitempty"`
RejectionReason *string `json:"rejectionReason,omitempty"`
}

Expand Down
15 changes: 4 additions & 11 deletions clients/go/ahptypes/commands.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -950,21 +950,14 @@ func (*ChangesetOperationResourceTarget) isChangesetOperationTarget() {}

// ChangesetOperationRangeTarget targets a range within a resource.
type ChangesetOperationRangeTarget struct {
Kind string `json:"kind"`
Resource URI `json:"resource"`
Side *string `json:"side,omitempty"`
Range ChangesetOperationTargetRange `json:"range"`
Kind string `json:"kind"`
Resource URI `json:"resource"`
Side *string `json:"side,omitempty"`
Range TextRange `json:"range"`
}

func (*ChangesetOperationRangeTarget) isChangesetOperationTarget() {}

// ChangesetOperationTargetRange is the [start, end] index pair for a
// range target.
type ChangesetOperationTargetRange struct {
Start int64 `json:"start"`
End int64 `json:"end"`
}

// UnmarshalJSON dispatches on the `kind` discriminator.
func (t *ChangesetOperationTarget) UnmarshalJSON(data []byte) error {
disc, _, err := readDiscriminator(data, "kind")
Expand Down
331 changes: 331 additions & 0 deletions clients/go/ahptypes/roundtrip_fixture_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
// TestRoundTripCorpus — data-driven wire round-trip parity for the Go client.
//
// Loads the SHARED, language-agnostic round-trip corpus under
// types/test-cases/round-trips/*.json (the same fixtures the Swift and
// TypeScript clients run) and asserts each via the REAL generated Go wire
// types — encoding/json (un)marshal, the real discriminated-union
// UnmarshalJSON/MarshalJSON, the real SessionStatus bitset.
// No mocks, no faked SUT: every fixture decodes real bytes into a real type and
// re-encodes with the same serializer.
//
// Each fixture has the shape:
// { "name": ..., "description": ..., "type": ...,
// "input": <wire JSON value>,
// "acceptableOutputs": [ <exactly one canonical re-encoded value> ],
// "notApplicable": [ <optional list of client names to skip> ] }
//
// The harness decodes `input` as the real type named by `type`, re-encodes
// with encoding/json, and asserts the result structurally equals
// acceptableOutputs[0] (key-order-independent, value- and key-presence-sensitive).
// acceptableOutputs MUST have exactly one entry — the single intended wire form.
//
// If the fixture carries "notApplicable": ["go"] (unlikely — only expected for
// the TypeScript structural limitation), the fixture is skipped with a note.
//
// Run: go test ./ahptypes/ -run TestRoundTripCorpus -v

package ahptypes

import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)

// findRoundTripFixtureDir walks upward from the cwd looking for
// types/test-cases/round-trips so the test works whether `go test` runs from
// clients/go/ahptypes or the module root.
func findRoundTripFixtureDir(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
candidate := filepath.Join(wd, "types", "test-cases", "round-trips")
if fi, err := os.Stat(candidate); err == nil && fi.IsDir() {
return candidate
}
parent := filepath.Dir(wd)
if parent == wd {
t.Fatalf("could not locate types/test-cases/round-trips walking upward from cwd")
}
wd = parent
}
}

// roundTripFixture is the decoded shape of one corpus JSON file.
type roundTripFixture struct {
Name string `json:"name"`
Description string `json:"description"`
// Group "A" = all clients agree (assert acceptableOutputs[0]).
// Group "B" = runtime-decoders drop unknown keys (assert acceptableOutputs[0]);
// TypeScript preserves them (asserts preservedOutput instead).
// Absent group is treated as "A" for backward compatibility.
Group string `json:"group"`
Type string `json:"type"`
Input json.RawMessage `json:"input"`
AcceptableOutputs []json.RawMessage `json:"acceptableOutputs"`
// PreservedOutput is the expected output for the TypeScript client (Group B only).
// Go always asserts acceptableOutputs[0] for both groups.
PreservedOutput json.RawMessage `json:"preservedOutput"`
// NotApplicable lists client names for which this fixture does not apply.
// Legacy field — new fixtures use group:"B" + preservedOutput instead.
NotApplicable []string `json:"notApplicable"`
}

// TestRoundTripCorpus is the primary cross-language wire round-trip parity gate
// for the Go client.
func TestRoundTripCorpus(t *testing.T) {
dir := findRoundTripFixtureDir(t)

entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })

var fixtureFiles []string
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
fixtureFiles = append(fixtureFiles, entry.Name())
}

// Loaded-something guard: the checkout must actually include the corpus.
if len(fixtureFiles) == 0 {
t.Fatalf("no round-trip fixtures found in %s — ensure the checkout includes types/test-cases/round-trips/", dir)
}

ranReal := 0
for _, name := range fixtureFiles {
name := name
ok := t.Run(name, func(tt *testing.T) {
path := filepath.Join(dir, name)
raw, err := os.ReadFile(path)
if err != nil {
tt.Fatalf("read: %v", err)
}
runRoundTripFixture(tt, name, raw)
})
if ok {
ranReal++
}
}

t.Logf("round-trip corpus: %d fixtures, %d asserted for real", len(fixtureFiles), ranReal)
}

func runRoundTripFixture(t *testing.T, name string, raw []byte) {
t.Helper()

var fx roundTripFixture
if err := json.Unmarshal(raw, &fx); err != nil {
t.Fatalf("parse fixture: %v", err)
}
if fx.Type == "" {
t.Fatalf("missing `type`")
}
if len(fx.Input) == 0 {
t.Fatalf("%s: missing `input`", name)
}
if len(fx.AcceptableOutputs) == 0 {
t.Fatalf("%s: fixture made no assertions — `acceptableOutputs` is empty", name)
}

// Enforce single canonical form: acceptableOutputs MUST have exactly one entry.
// Multi-form acceptance sets encode observed-but-wrong divergence as acceptable.
if len(fx.AcceptableOutputs) != 1 {
t.Fatalf("%s: acceptableOutputs must have exactly 1 entry (the single canonical re-encoded form); got %d. "+
"Multiple entries cement divergence instead of fixing it.", name, len(fx.AcceptableOutputs))
}

// Honor notApplicable: skip clients listed there with a note.
// Legacy field — new fixtures use group:"B" + preservedOutput instead.
for _, skip := range fx.NotApplicable {
if skip == "go" {
t.Logf("⊘ %s: not applicable to go — %s", name, fx.Description)
t.Skip()
return
}
}

// Group B: Go is a runtime-decoder — it drops unknown keys → asserts acceptableOutputs[0].
// (Group A also asserts acceptableOutputs[0]; the group field only affects the TypeScript harness.)

// Decode `input` as the real generated type, re-encode with encoding/json.
reencoded := decodeAndReencode(t, name, fx.Type, string(fx.Input))

// Assert the re-encoded result structurally equals the single canonical output.
if canonicalJSONEqualRaw(t, name, reencoded, string(fx.AcceptableOutputs[0])) {
return // PASS
}

t.Fatalf("%s: re-encoded output does not match the canonical acceptableOutput.\n got: %s\n expected: %s",
name, reencoded, string(fx.AcceptableOutputs[0]))
}

// decodeAndReencode decodes inputJSON into the real generated type named by
// `type` and re-encodes it with encoding/json. Adding a wire type to the
// corpus is a deliberate edit here — the corpus never decodes arbitrary types
// reflectively.
func decodeAndReencode(t *testing.T, name, typ, inputJSON string) string {
t.Helper()

dec := func(v any) {
if err := json.Unmarshal([]byte(inputJSON), v); err != nil {
t.Fatalf("%s: decode %s: %v", name, typ, err)
}
}
enc := func(v any) string {
out, err := json.Marshal(v)
if err != nil {
t.Fatalf("%s: re-encode %s: %v", name, typ, err)
}
return string(out)
}

switch typ {
case "ActionEnvelope":
var v ActionEnvelope
dec(&v)
return enc(&v)
case "StateAction":
var v StateAction
dec(&v)
return enc(&v)
case "Customization":
var v Customization
dec(&v)
return enc(&v)
case "SessionStatus":
var v SessionStatus
dec(&v)
return enc(v)
case "StringOrMarkdown":
var v StringOrMarkdown
dec(&v)
return enc(&v)
case "JsonRpcMessage":
var v JsonRpcMessage
dec(&v)
return enc(&v)
case "ChangesetOperationTarget":
var v ChangesetOperationTarget
dec(&v)
return enc(&v)
case "SessionInputQuestion":
var v SessionInputQuestion
dec(&v)
return enc(&v)
case "SessionSummary":
var v SessionSummary
dec(&v)
return enc(&v)
case "SessionAddedParams":
var v SessionAddedParams
dec(&v)
return enc(&v)
case "PartialSessionSummary":
var v PartialSessionSummary
dec(&v)
return enc(&v)
default:
t.Fatalf("%s: round-trip fixture: unknown wire type %q. Add a decode entry to decodeAndReencode.", name, typ)
return ""
}
}

// ─── JSON equality ───────────────────────────────────────────────────────────

// canonicalJSONEqualRaw compares two JSON documents structurally (key-order
// independent, value- and key-presence sensitive).
func canonicalJSONEqualRaw(t *testing.T, name, lhs, rhs string) bool {
t.Helper()
lo := parseToAny(t, name, lhs)
ro := parseToAny(t, name, rhs)
return canonicalJSONEqual(t, name, lo, ro)
}

// parseToAny decodes a JSON document into a generic value using json.Number so
// large 64-bit integers stay exact.
func parseToAny(t *testing.T, name, s string) any {
t.Helper()
d := json.NewDecoder(strings.NewReader(s))
d.UseNumber()
var out any
if err := d.Decode(&out); err != nil {
t.Fatalf("%s: parse JSON %q: %v", name, s, err)
}
return out
}

// canonicalJSONEqual re-serializes both sides through encoding/json after
// normalizing json.Number values, so equality is structural.
func canonicalJSONEqual(t *testing.T, name string, a, b any) bool {
t.Helper()
return canonicalString(t, name, a) == canonicalString(t, name, b)
}

func canonicalString(t *testing.T, name string, v any) string {
t.Helper()
out, err := json.Marshal(normalizeNumbers(v))
if err != nil {
t.Fatalf("%s: canonicalize: %v", name, err)
}
return string(out)
}

// normalizeNumbers walks a generic JSON value and converts json.Number leaves to
// a canonical numeric form so that, e.g., 0 and 0.0 compare equal.
func normalizeNumbers(v any) any {
switch x := v.(type) {
case map[string]any:
out := make(map[string]any, len(x))
for k, val := range x {
out[k] = normalizeNumbers(val)
}
return out
case []any:
out := make([]any, len(x))
for i, val := range x {
out[i] = normalizeNumbers(val)
}
return out
case json.Number:
if i, err := x.Int64(); err == nil {
return i
}
if f, err := x.Float64(); err == nil {
return f
}
return x.String()
default:
return v
}
}

// ─── ProtocolVersion constant tests ─────────────────────────────────────────

// TestProtocolVersionConstants verifies the three properties of the
// ProtocolVersion constants that were previously exercised via corpus
// fixtures 021–023 (now deleted from the round-trip corpus).
func TestProtocolVersionConstants(t *testing.T) {
if strings.TrimSpace(ProtocolVersion) == "" {
t.Errorf("ProtocolVersion must be non-empty, got %q", ProtocolVersion)
}

supported := SupportedProtocolVersions()
if len(supported) == 0 {
t.Errorf("SupportedProtocolVersions() must be non-empty")
}

if len(supported) > 0 && supported[0] != ProtocolVersion {
t.Errorf("first SupportedProtocolVersions entry %q must equal ProtocolVersion %q",
supported[0], ProtocolVersion)
}
}
Loading
Loading