Add candidate artifact codec decoding
This commit is contained in:
@@ -50,6 +50,13 @@ type ArtifactCodec[T any] interface {
|
|||||||
Decode([]byte) (T, error)
|
Decode([]byte) (T, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CandidateArtifactCodec extends an artifact codec with strict representation
|
||||||
|
// decoding for values that have not yet passed semantic validation.
|
||||||
|
type CandidateArtifactCodec[T any] interface {
|
||||||
|
ArtifactCodec[T]
|
||||||
|
DecodeCandidate([]byte) (T, error)
|
||||||
|
}
|
||||||
|
|
||||||
// DigestArtifactSchema returns the SHA-256 digest of the exact JSON Schema
|
// DigestArtifactSchema returns the SHA-256 digest of the exact JSON Schema
|
||||||
// bytes. Schema formatting is therefore part of the registered identity.
|
// bytes. Schema formatting is therefore part of the registered identity.
|
||||||
func DigestArtifactSchema(schema ArtifactSchema) string {
|
func DigestArtifactSchema(schema ArtifactSchema) string {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const (
|
|||||||
var schemaAssets embed.FS
|
var schemaAssets embed.FS
|
||||||
|
|
||||||
var _ contracts.ArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil)
|
var _ contracts.ArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil)
|
||||||
|
var _ contracts.CandidateArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil)
|
||||||
|
|
||||||
type Codec struct{}
|
type Codec struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -124,14 +124,29 @@ func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
sibling, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if decoded.Occurrences[1].Quantity == value.Occurrences[1].Quantity || &decoded.Occurrences[1].SourceRefs[0] == &value.Occurrences[1].SourceRefs[0] {
|
if decoded.Occurrences[1].Quantity == value.Occurrences[1].Quantity || &decoded.Occurrences[1].SourceRefs[0] == &value.Occurrences[1].SourceRefs[0] {
|
||||||
t.Fatal("DecodeCandidate() retained caller-owned occurrence fields")
|
t.Fatal("DecodeCandidate() retained caller-owned occurrence fields")
|
||||||
}
|
}
|
||||||
|
nameOffset := bytes.Index(content, []byte("Gold Pieces"))
|
||||||
|
if nameOffset < 0 {
|
||||||
|
t.Fatal("candidate JSON does not contain item name")
|
||||||
|
}
|
||||||
|
content[nameOffset] = 'X'
|
||||||
|
if decoded.Occurrences[1].Name != "Gold Pieces" {
|
||||||
|
t.Fatal("DecodeCandidate() retained input bytes")
|
||||||
|
}
|
||||||
*decoded.Occurrences[1].Quantity = 99
|
*decoded.Occurrences[1].Quantity = 99
|
||||||
decoded.Occurrences[1].SourceRefs[0].SourceID = "changed"
|
decoded.Occurrences[1].SourceRefs[0].SourceID = "changed"
|
||||||
if *value.Occurrences[1].Quantity != 12 || value.Occurrences[1].SourceRefs[0].SourceID != "session" {
|
if *value.Occurrences[1].Quantity != 12 || value.Occurrences[1].SourceRefs[0].SourceID != "session" {
|
||||||
t.Fatal("decoded item occurrence aliases input")
|
t.Fatal("decoded item occurrence aliases input")
|
||||||
}
|
}
|
||||||
|
if *sibling.Occurrences[1].Quantity != 12 || sibling.Occurrences[1].SourceRefs[0].SourceID != "session" {
|
||||||
|
t.Fatal("decoded item occurrence aliases a sibling decode")
|
||||||
|
}
|
||||||
|
|
||||||
first := codec.Schema()
|
first := codec.Schema()
|
||||||
first.JSONSchema[0] = '['
|
first.JSONSchema[0] = '['
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
var schemaAssets embed.FS
|
var schemaAssets embed.FS
|
||||||
|
|
||||||
var _ contracts.ArtifactCodec[dnd.ItemRegistry] = (*Codec)(nil)
|
var _ contracts.ArtifactCodec[dnd.ItemRegistry] = (*Codec)(nil)
|
||||||
|
var _ contracts.CandidateArtifactCodec[dnd.ItemRegistry] = (*Codec)(nil)
|
||||||
|
|
||||||
type Codec struct{}
|
type Codec struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -59,9 +59,13 @@ func TestCodecOwnsDurableSchemaAndExactType(t *testing.T) {
|
|||||||
func TestCodecCandidateEncodingMetadataAndStrictDecode(t *testing.T) {
|
func TestCodecCandidateEncodingMetadataAndStrictDecode(t *testing.T) {
|
||||||
codec := New()
|
codec := New()
|
||||||
candidate := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Silver Key"}}}
|
candidate := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Silver Key"}}}
|
||||||
if content, err := codec.EncodeCandidate(candidate); err != nil || string(content) != `{"items":[{"id":"","name":"Silver Key","source_refs":null}]}` {
|
content, err := codec.EncodeCandidate(candidate)
|
||||||
|
if err != nil || string(content) != `{"items":[{"id":"","name":"Silver Key","source_refs":null}]}` {
|
||||||
t.Fatalf("EncodeCandidate() = %s, %v", content, err)
|
t.Fatalf("EncodeCandidate() = %s, %v", content, err)
|
||||||
}
|
}
|
||||||
|
if decoded, err := codec.DecodeCandidate(content); err != nil || !reflect.DeepEqual(decoded, candidate) {
|
||||||
|
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate)
|
||||||
|
}
|
||||||
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), "item ID pattern") {
|
if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), "item ID pattern") {
|
||||||
t.Fatalf("Encode() error = %v, want durable validation", err)
|
t.Fatalf("Encode() error = %v, want durable validation", err)
|
||||||
}
|
}
|
||||||
@@ -80,6 +84,39 @@ func TestCodecCandidateEncodingMetadataAndStrictDecode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCodecCandidateDecodeOwnsValues(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
input := validRegistry()
|
||||||
|
content, err := codec.EncodeCandidate(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nameOffset := bytes.Index(content, []byte("Silver Key"))
|
||||||
|
if nameOffset < 0 {
|
||||||
|
t.Fatal("candidate JSON does not contain item name")
|
||||||
|
}
|
||||||
|
content[nameOffset] = 'X'
|
||||||
|
if first.Items[0].Name != "Silver Key" {
|
||||||
|
t.Fatal("DecodeCandidate() retained input bytes")
|
||||||
|
}
|
||||||
|
first.Items[0].Name = "changed"
|
||||||
|
first.Items[0].SourceRefs[0].SourceID = "changed"
|
||||||
|
if input.Items[0].Name != "Silver Key" || input.Items[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||||
|
t.Fatal("DecodeCandidate() retained input values")
|
||||||
|
}
|
||||||
|
if second.Items[0].Name != "Silver Key" || second.Items[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||||
|
t.Fatal("DecodeCandidate() returned aliased values")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
|
func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
|
||||||
codec := New()
|
codec := New()
|
||||||
first := codec.Schema()
|
first := codec.Schema()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
var schemaAssets embed.FS
|
var schemaAssets embed.FS
|
||||||
|
|
||||||
var _ contracts.ArtifactCodec[dnd.LocationOccurrenceList] = (*Codec)(nil)
|
var _ contracts.ArtifactCodec[dnd.LocationOccurrenceList] = (*Codec)(nil)
|
||||||
|
var _ contracts.CandidateArtifactCodec[dnd.LocationOccurrenceList] = (*Codec)(nil)
|
||||||
|
|
||||||
type Codec struct{}
|
type Codec struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,39 @@ func TestCodecSupportsEmptyListsAndCandidateSemanticFailures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCodecCandidateDecodeOwnsValues(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
input := validList()
|
||||||
|
content, err := codec.EncodeCandidate(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nameOffset := bytes.Index(content, []byte("The Old Tavern"))
|
||||||
|
if nameOffset < 0 {
|
||||||
|
t.Fatal("candidate JSON does not contain location name")
|
||||||
|
}
|
||||||
|
content[nameOffset] = 'X'
|
||||||
|
if first.Occurrences[0].Name != "The Old Tavern" {
|
||||||
|
t.Fatal("DecodeCandidate() retained input bytes")
|
||||||
|
}
|
||||||
|
first.Occurrences[0].Name = "changed"
|
||||||
|
first.Occurrences[0].SourceRefs[0].SourceID = "changed"
|
||||||
|
if input.Occurrences[0].Name != "The Old Tavern" || input.Occurrences[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||||
|
t.Fatal("DecodeCandidate() retained input values")
|
||||||
|
}
|
||||||
|
if second.Occurrences[0].Name != "The Old Tavern" || second.Occurrences[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||||
|
t.Fatal("DecodeCandidate() returned aliased values")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCodecRejectsStructuralJSONBeforeSemanticApproval(t *testing.T) {
|
func TestCodecRejectsStructuralJSONBeforeSemanticApproval(t *testing.T) {
|
||||||
valid := `{"occurrences":[{"location_id":"location:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"The Tavern","kind":"visited","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
valid := `{"occurrences":[{"location_id":"location:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"The Tavern","kind":"visited","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||||
for _, test := range []struct{ name, raw, want string }{
|
for _, test := range []struct{ name, raw, want string }{
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
var schemaAssets embed.FS
|
var schemaAssets embed.FS
|
||||||
|
|
||||||
var _ contracts.ArtifactCodec[dnd.LocationRegistry] = (*Codec)(nil)
|
var _ contracts.ArtifactCodec[dnd.LocationRegistry] = (*Codec)(nil)
|
||||||
|
var _ contracts.CandidateArtifactCodec[dnd.LocationRegistry] = (*Codec)(nil)
|
||||||
|
|
||||||
type Codec struct{}
|
type Codec struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,39 @@ func TestCodecSupportsEmptyListsAndCandidateSemanticFailures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCodecCandidateDecodeOwnsValues(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
input := validList()
|
||||||
|
content, err := codec.EncodeCandidate(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nameOffset := bytes.Index(content, []byte("The Old Tavern"))
|
||||||
|
if nameOffset < 0 {
|
||||||
|
t.Fatal("candidate JSON does not contain location name")
|
||||||
|
}
|
||||||
|
content[nameOffset] = 'X'
|
||||||
|
if first.Locations[0].Name != "The Old Tavern" {
|
||||||
|
t.Fatal("DecodeCandidate() retained input bytes")
|
||||||
|
}
|
||||||
|
first.Locations[0].Name = "changed"
|
||||||
|
first.Locations[0].SourceRefs[0].SourceID = "changed"
|
||||||
|
if input.Locations[0].Name != "The Old Tavern" || input.Locations[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||||
|
t.Fatal("DecodeCandidate() retained input values")
|
||||||
|
}
|
||||||
|
if second.Locations[0].Name != "The Old Tavern" || second.Locations[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||||
|
t.Fatal("DecodeCandidate() returned aliased values")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCodecRejectsStructuralJSONBeforeSemanticApproval(t *testing.T) {
|
func TestCodecRejectsStructuralJSONBeforeSemanticApproval(t *testing.T) {
|
||||||
valid := `{"locations":[{"id":"location:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"The Tavern","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
valid := `{"locations":[{"id":"location:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"The Tavern","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||||
for _, test := range []struct{ name, raw, want string }{
|
for _, test := range []struct{ name, raw, want string }{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
var schemaAssets embed.FS
|
var schemaAssets embed.FS
|
||||||
|
|
||||||
var _ contracts.ArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil)
|
var _ contracts.ArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil)
|
||||||
|
var _ contracts.CandidateArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil)
|
||||||
|
|
||||||
type Codec struct{}
|
type Codec struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,39 @@ func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *tes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCodecCandidateDecodeOwnsValues(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
input := validList()
|
||||||
|
content, err := codec.EncodeCandidate(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nameOffset := bytes.Index(content, []byte("Mira Thorn"))
|
||||||
|
if nameOffset < 0 {
|
||||||
|
t.Fatal("candidate JSON does not contain NPC name")
|
||||||
|
}
|
||||||
|
content[nameOffset] = 'X'
|
||||||
|
if first.Occurrences[0].Name != "Mira Thorn" {
|
||||||
|
t.Fatal("DecodeCandidate() retained input bytes")
|
||||||
|
}
|
||||||
|
first.Occurrences[0].Name = "changed"
|
||||||
|
first.Occurrences[0].SourceRefs[0].SourceID = "changed"
|
||||||
|
if input.Occurrences[0].Name != "Mira Thorn" || input.Occurrences[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||||
|
t.Fatal("DecodeCandidate() retained input values")
|
||||||
|
}
|
||||||
|
if second.Occurrences[0].Name != "Mira Thorn" || second.Occurrences[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||||
|
t.Fatal("DecodeCandidate() returned aliased values")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) {
|
func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) {
|
||||||
validJSON := `{"occurrences":[{"npc_id":"npc:test-mira","name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
validJSON := `{"occurrences":[{"npc_id":"npc:test-mira","name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||||
for _, test := range []struct{ name, raw, want string }{
|
for _, test := range []struct{ name, raw, want string }{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
var schemaAssets embed.FS
|
var schemaAssets embed.FS
|
||||||
|
|
||||||
var _ contracts.ArtifactCodec[dnd.NPCRegistry] = (*Codec)(nil)
|
var _ contracts.ArtifactCodec[dnd.NPCRegistry] = (*Codec)(nil)
|
||||||
|
var _ contracts.CandidateArtifactCodec[dnd.NPCRegistry] = (*Codec)(nil)
|
||||||
|
|
||||||
type Codec struct{}
|
type Codec struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,39 @@ func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCodecCandidateDecodeOwnsValues(t *testing.T) {
|
||||||
|
codec := New()
|
||||||
|
input := validList()
|
||||||
|
content, err := codec.EncodeCandidate(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := codec.DecodeCandidate(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nameOffset := bytes.Index(content, []byte("Mira Thorn"))
|
||||||
|
if nameOffset < 0 {
|
||||||
|
t.Fatal("candidate JSON does not contain NPC name")
|
||||||
|
}
|
||||||
|
content[nameOffset] = 'X'
|
||||||
|
if first.NPCs[0].Name != "Mira Thorn" {
|
||||||
|
t.Fatal("DecodeCandidate() retained input bytes")
|
||||||
|
}
|
||||||
|
first.NPCs[0].Name = "changed"
|
||||||
|
first.NPCs[0].SourceRefs[0].SourceID = "changed"
|
||||||
|
if input.NPCs[0].Name != "Mira Thorn" || input.NPCs[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||||
|
t.Fatal("DecodeCandidate() retained input values")
|
||||||
|
}
|
||||||
|
if second.NPCs[0].Name != "Mira Thorn" || second.NPCs[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||||
|
t.Fatal("DecodeCandidate() returned aliased values")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) {
|
func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) {
|
||||||
base := validList().NPCs[0]
|
base := validList().NPCs[0]
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user