Require candidate decoders for artifact codecs
This commit is contained in:
@@ -400,6 +400,9 @@ func (referenceContractCodecA) Encode(stateTestArtifact) ([]byte, error) {
|
||||
func (referenceContractCodecA) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
func (codec referenceContractCodecA) DecodeCandidate(content []byte) (stateTestArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
func (referenceContractCodecB) Kind() contracts.ArtifactKind { return referenceContractKindBeta }
|
||||
func (referenceContractCodecB) Schema() contracts.ArtifactSchema {
|
||||
@@ -415,6 +418,9 @@ func (referenceContractCodecB) Encode(stateTestArtifact) ([]byte, error) {
|
||||
func (referenceContractCodecB) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
func (codec referenceContractCodecB) DecodeCandidate(content []byte) (stateTestArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
func referenceContractLane(t *testing.T, resolved pipeline.ResolvedPipeline, id string) pipeline.ResolvedArtifactLane {
|
||||
t.Helper()
|
||||
|
||||
@@ -955,6 +955,9 @@ func (stateTestCodec) Encode(v stateTestArtifact) ([]byte, error) {
|
||||
func (stateTestCodec) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
func (codec stateTestCodec) DecodeCandidate(content []byte) (stateTestArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
type stateTestExtractor struct{ harness *stateTestHarness }
|
||||
|
||||
|
||||
@@ -427,6 +427,10 @@ func (effectiveCodec) Decode(content []byte) (effectiveArtifact, error) {
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (codec effectiveCodec) DecodeCandidate(content []byte) (effectiveArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
type effectiveInput struct{ key string }
|
||||
|
||||
func (m effectiveInput) Key() string { return m.key }
|
||||
|
||||
@@ -69,6 +69,7 @@ type artifactCodecEntry struct {
|
||||
encodeCandidate func(any) ([]byte, error)
|
||||
metadata func(any) (map[string]any, error)
|
||||
decode func([]byte) (any, error)
|
||||
decodeCandidate func([]byte) (any, error)
|
||||
}
|
||||
|
||||
func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
|
||||
@@ -77,7 +78,7 @@ func NewArtifactCodecRegistry() *ArtifactCodecRegistry {
|
||||
|
||||
// RegisterArtifactCodec registers one codec for T. The concrete type is kept
|
||||
// private and checked at every erased encode boundary.
|
||||
func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contracts.ArtifactCodec[T]) error {
|
||||
func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contracts.CandidateArtifactCodec[T]) error {
|
||||
if registry == nil {
|
||||
return fmt.Errorf("artifact codec registry must not be nil")
|
||||
}
|
||||
@@ -119,6 +120,13 @@ func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contrac
|
||||
}
|
||||
return decoded, nil
|
||||
},
|
||||
decodeCandidate: func(content []byte) (any, error) {
|
||||
decoded, err := codec.DecodeCandidate(append([]byte(nil), content...))
|
||||
if err != nil {
|
||||
return nil, &ArtifactCodecOperationError{Operation: "decode candidate", Kind: spec.Kind, Err: err}
|
||||
}
|
||||
return decoded, nil
|
||||
},
|
||||
}
|
||||
entry.encodeCandidate = func(value any) ([]byte, error) {
|
||||
typed, err := exactTypedValue[T]("encode candidate artifact", value)
|
||||
|
||||
@@ -24,12 +24,13 @@ type codecScore struct {
|
||||
type codecNotesAlias codecNotes
|
||||
|
||||
type testArtifactCodec[T any] struct {
|
||||
kind contracts.ArtifactKind
|
||||
schema contracts.ArtifactSchema
|
||||
mediaType string
|
||||
encodeFunc func(T) ([]byte, error)
|
||||
candidateFunc func(T) ([]byte, error)
|
||||
decodeFunc func([]byte) (T, error)
|
||||
kind contracts.ArtifactKind
|
||||
schema contracts.ArtifactSchema
|
||||
mediaType string
|
||||
encodeFunc func(T) ([]byte, error)
|
||||
candidateFunc func(T) ([]byte, error)
|
||||
decodeFunc func([]byte) (T, error)
|
||||
candidateDecodeFunc func([]byte) (T, error)
|
||||
}
|
||||
|
||||
func (c testArtifactCodec[T]) Kind() contracts.ArtifactKind { return c.kind }
|
||||
@@ -43,8 +44,15 @@ func (c testArtifactCodec[T]) EncodeCandidate(value T) ([]byte, error) {
|
||||
}
|
||||
func (c testArtifactCodec[T]) Encode(value T) ([]byte, error) { return c.encodeFunc(value) }
|
||||
func (c testArtifactCodec[T]) Decode(content []byte) (T, error) { return c.decodeFunc(content) }
|
||||
func (c testArtifactCodec[T]) DecodeCandidate(content []byte) (T, error) {
|
||||
if c.candidateDecodeFunc != nil {
|
||||
return c.candidateDecodeFunc(content)
|
||||
}
|
||||
return c.decodeFunc(content)
|
||||
}
|
||||
|
||||
var _ contracts.ArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{}
|
||||
var _ contracts.CandidateArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{}
|
||||
|
||||
func TestArtifactCodecRegistryStoresHeterogeneousExactTypes(t *testing.T) {
|
||||
registry := NewArtifactCodecRegistry()
|
||||
@@ -138,6 +146,44 @@ func TestArtifactCodecRegistryKeepsCandidateAndFinalEncodingDistinct(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryKeepsCandidateAndFinalDecodingDistinct(t *testing.T) {
|
||||
candidateCalls, finalCalls := 0, 0
|
||||
codec := notesCodec()
|
||||
codec.candidateDecodeFunc = func([]byte) (codecNotes, error) {
|
||||
candidateCalls++
|
||||
return codecNotes{Items: []string{"candidate"}}, nil
|
||||
}
|
||||
codec.decodeFunc = func([]byte) (codecNotes, error) {
|
||||
finalCalls++
|
||||
return codecNotes{Items: []string{"final"}}, nil
|
||||
}
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
}
|
||||
entry, _, err := registry.entry(codec.kind)
|
||||
if err != nil {
|
||||
t.Fatalf("entry() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
candidate, err := entry.decodeCandidate([]byte(`{"items":["one"]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("candidate decode error = %v", err)
|
||||
}
|
||||
candidateWant := codecNotes{Items: []string{"candidate"}}
|
||||
if !reflect.DeepEqual(candidate, candidateWant) || candidateCalls != 1 || finalCalls != 0 {
|
||||
t.Fatalf("candidate decode = %#v, calls = candidate %d, final %d", candidate, candidateCalls, finalCalls)
|
||||
}
|
||||
decoded, err := entry.decode([]byte(`{"items":["one"]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("final decode error = %v", err)
|
||||
}
|
||||
finalWant := codecNotes{Items: []string{"final"}}
|
||||
if !reflect.DeepEqual(decoded, finalWant) || candidateCalls != 1 || finalCalls != 1 {
|
||||
t.Fatalf("final decode = %#v, calls = candidate %d, final %d", decoded, candidateCalls, finalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) {
|
||||
registry := NewArtifactCodecRegistry()
|
||||
codec := notesCodec()
|
||||
@@ -267,6 +313,28 @@ func TestArtifactCodecRegistryWrapsEncodeFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryWrapsCandidateDecodeFailure(t *testing.T) {
|
||||
cause := errors.New("cannot decode candidate notes")
|
||||
codec := notesCodec()
|
||||
codec.candidateDecodeFunc = func([]byte) (codecNotes, error) {
|
||||
return codecNotes{}, cause
|
||||
}
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
}
|
||||
entry, _, err := registry.entry(codec.kind)
|
||||
if err != nil {
|
||||
t.Fatalf("entry() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
_, err = entry.decodeCandidate([]byte(`{"items":["one"]}`))
|
||||
var operationErr *ArtifactCodecOperationError
|
||||
if !errors.As(err, &operationErr) || operationErr.Operation != "decode candidate" || !errors.Is(err, cause) {
|
||||
t.Fatalf("candidate decode error = %T %v, want typed wrapping error", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
|
||||
shared := []byte(`{"items":["one"]}`)
|
||||
codec := notesCodec()
|
||||
@@ -275,6 +343,10 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
|
||||
content[0] = '['
|
||||
return codecNotes{Items: []string{"one"}}, nil
|
||||
}
|
||||
codec.candidateDecodeFunc = func(content []byte) (codecNotes, error) {
|
||||
content[0] = '['
|
||||
return codecNotes{Items: []string{"candidate"}}, nil
|
||||
}
|
||||
registry := NewArtifactCodecRegistry()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err)
|
||||
@@ -295,6 +367,18 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) {
|
||||
if !bytes.Equal(artifact.Content, before) {
|
||||
t.Fatalf("serialized content changed during decode: %q", artifact.Content)
|
||||
}
|
||||
candidateContent := []byte(`{"items":["candidate"]}`)
|
||||
candidateBefore := append([]byte(nil), candidateContent...)
|
||||
entry, _, err := registry.entry("test/notes")
|
||||
if err != nil {
|
||||
t.Fatalf("entry() error = %v, want nil", err)
|
||||
}
|
||||
if _, err := entry.decodeCandidate(candidateContent); err != nil {
|
||||
t.Fatalf("candidate decode error = %v, want nil", err)
|
||||
}
|
||||
if !bytes.Equal(candidateContent, candidateBefore) {
|
||||
t.Fatalf("candidate content changed during decode: %q", candidateContent)
|
||||
}
|
||||
}
|
||||
|
||||
func notesCodec() testArtifactCodec[codecNotes] {
|
||||
|
||||
@@ -49,6 +49,10 @@ func (*observedNotesCodec) Decode(content []byte) (codecNotes, error) {
|
||||
return value, json.Unmarshal(content, &value)
|
||||
}
|
||||
|
||||
func (c *observedNotesCodec) DecodeCandidate(content []byte) (codecNotes, error) {
|
||||
return c.Decode(content)
|
||||
}
|
||||
|
||||
func firstNote(value codecNotes) string {
|
||||
if len(value.Items) == 0 {
|
||||
return ""
|
||||
|
||||
@@ -500,7 +500,7 @@ func registriesFromModuleCatalog(catalog ModuleCatalog) Registries {
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterArtifactCodec[T any](t *testing.T, registry *ArtifactCodecRegistry, codec contracts.ArtifactCodec[T]) {
|
||||
func mustRegisterArtifactCodec[T any](t *testing.T, registry *ArtifactCodecRegistry, codec contracts.CandidateArtifactCodec[T]) {
|
||||
t.Helper()
|
||||
if err := RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatalf("RegisterArtifactCodec() error = %v", err)
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.CombatTurnList] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.CombatTurnList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.EnemyEventList] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.EnemyEventList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
|
||||
@@ -82,13 +82,16 @@ func TestCodecDefensivelyOwnsValuesAndDefersSemanticValidation(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := codec.Decode(content)
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("Decode() = %#v, %v; want semantic candidate preservation", decoded, err)
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want semantic candidate preservation", decoded, err)
|
||||
}
|
||||
decoded.Events[0].SourceRefs[0].SourceID = "changed"
|
||||
if candidate.Events[0].SourceRefs[0].SourceID != "" {
|
||||
t.Fatal("Decode() retained caller-owned source references")
|
||||
t.Fatal("DecodeCandidate() retained caller-owned source references")
|
||||
}
|
||||
if decoded, err := codec.Decode(content); err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("Decode() = %#v, %v; want durable decode behavior", decoded, err)
|
||||
}
|
||||
|
||||
first := codec.Schema()
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.SceneDescriptionList] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.SceneDescriptionList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.SpellList] = (*Codec)(nil)
|
||||
var _ contracts.CandidateArtifactCodec[dnd.SpellList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
|
||||
@@ -114,6 +114,10 @@ func TestCodecEncodesIncompleteCandidateWithoutWeakeningFinalEncoding(t *testing
|
||||
if !json.Valid(content) {
|
||||
t.Fatalf("EncodeCandidate() = %q, want JSON", content)
|
||||
}
|
||||
decoded, err := codec.DecodeCandidate(content)
|
||||
if err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||
}
|
||||
result, err := spellshape.New(spellshape.Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Value: candidate})
|
||||
if err != nil || result.Approved || result.ReasonCode != spellshape.ReasonCode {
|
||||
t.Fatalf("shape validation = %#v, %v; want candidate rejection", result, err)
|
||||
@@ -121,6 +125,9 @@ func TestCodecEncodesIncompleteCandidateWithoutWeakeningFinalEncoding(t *testing
|
||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), "spell_casts must be present") {
|
||||
t.Fatalf("Encode() error = %v, want strict final shape error", err)
|
||||
}
|
||||
if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), "spell_casts must be present") {
|
||||
t.Fatalf("Decode() error = %v, want strict final shape error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodecSchemaIsMutationSafe(t *testing.T) {
|
||||
|
||||
@@ -164,3 +164,7 @@ func (seriatimArtifactCodec) Decode(content []byte) (seriatimArtifact, error) {
|
||||
err := json.Unmarshal(content, &value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (codec seriatimArtifactCodec) DecodeCandidate(content []byte) (seriatimArtifact, error) {
|
||||
return codec.Decode(content)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user