Adopt registry-backed item occurrences
This commit is contained in:
@@ -24,7 +24,7 @@ const (
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
@@ -36,7 +36,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||
if err := Validate(req.Source, req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
|
||||
// Validate checks only invariants introduced by item-event normalization.
|
||||
// Shape and source-reference failures remain owned by their earlier validators.
|
||||
func Validate(doc *source.SourceDocument, value dnd.ItemEventList) error {
|
||||
func Validate(doc *source.SourceDocument, value dnd.ItemOccurrenceList) error {
|
||||
if itemeventshape.Validate(value) != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -57,14 +57,14 @@ func Validate(doc *source.SourceDocument, value dnd.ItemEventList) error {
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid normalized item event invariants", issues))
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid normalized item occurrence invariants", issues))
|
||||
}
|
||||
|
||||
func issuesFor(order shared.SourceRefOrder, value dnd.ItemEventList) []string {
|
||||
func issuesFor(order shared.SourceRefOrder, value dnd.ItemOccurrenceList) []string {
|
||||
issues := make([]string, 0)
|
||||
seenIdentity := make(map[string]int)
|
||||
for eventIndex, event := range value.Events {
|
||||
prefix := fmt.Sprintf("events[%d]", eventIndex)
|
||||
for eventIndex, event := range value.Occurrences {
|
||||
prefix := fmt.Sprintf("occurrences[%d]", eventIndex)
|
||||
if event.Name != itemeventmodel.DisplayValue(event.Name) {
|
||||
issues = append(issues, prefix+".name is not display-normalized")
|
||||
}
|
||||
@@ -85,21 +85,21 @@ func issuesFor(order shared.SourceRefOrder, value dnd.ItemEventList) []string {
|
||||
}
|
||||
key := itemeventmodel.ExactIdentity(order, event)
|
||||
if previous, exists := seenIdentity[key]; exists {
|
||||
issues = append(issues, fmt.Sprintf("%s duplicates item event %d under normalized identity", prefix, previous))
|
||||
issues = append(issues, fmt.Sprintf("%s duplicates item occurrence %d under normalized identity", prefix, previous))
|
||||
} else {
|
||||
seenIdentity[key] = eventIndex
|
||||
}
|
||||
}
|
||||
for eventIndex := 1; eventIndex < len(value.Events); eventIndex++ {
|
||||
if itemeventmodel.Less(order, value.Events[eventIndex], value.Events[eventIndex-1]) {
|
||||
issues = append(issues, fmt.Sprintf("events[%d] is out of canonical order", eventIndex))
|
||||
for eventIndex := 1; eventIndex < len(value.Occurrences); eventIndex++ {
|
||||
if itemeventmodel.Less(order, value.Occurrences[eventIndex], value.Occurrences[eventIndex-1]) {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d] is out of canonical order", eventIndex))
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func sourceRefsValid(index source.DocumentIndex, value dnd.ItemEventList) bool {
|
||||
for _, event := range value.Events {
|
||||
func sourceRefsValid(index source.DocumentIndex, value dnd.ItemOccurrenceList) bool {
|
||||
for _, event := range value.Occurrences {
|
||||
if !itemeventmodel.ValidSourceRefs(index, event.SourceRefs) {
|
||||
return false
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,20 +11,11 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
normalizeitemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
||||
)
|
||||
|
||||
func TestValidatorApprovesNormalizerOutput(t *testing.T) {
|
||||
func TestValidatorApprovesNormalizedOutput(t *testing.T) {
|
||||
doc := invariantDocument()
|
||||
input := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
||||
{Name: " Coin ", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
|
||||
{Name: "Arrow", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
|
||||
}}
|
||||
normalized, err := normalizeitemevents.New(normalizeitemevents.Options{}).Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemEventList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.ItemEventList]{Value: input}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: normalized.Value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: normalizedList()})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("Validate() = %#v, %v; want approval", result, err)
|
||||
}
|
||||
@@ -33,26 +24,30 @@ func TestValidatorApprovesNormalizerOutput(t *testing.T) {
|
||||
func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*dnd.ItemEventList)
|
||||
mutate func(*dnd.ItemOccurrenceList)
|
||||
want string
|
||||
}{
|
||||
{name: "name whitespace", mutate: func(value *dnd.ItemEventList) { value.Events[0].Name = " Coin " }, want: ".name is not display-normalized"},
|
||||
{name: "from whitespace", mutate: func(value *dnd.ItemEventList) {
|
||||
value.Events[0].Kind = dnd.ItemEventKindLost
|
||||
value.Events[0].From = " Aria "
|
||||
{name: "name whitespace", mutate: func(value *dnd.ItemOccurrenceList) { value.Occurrences[0].Name = " Coin " }, want: ".name is not display-normalized"},
|
||||
{name: "from whitespace", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences[0].Kind = dnd.ItemOccurrenceKindLost
|
||||
value.Occurrences[0].From = " Aria "
|
||||
}, want: ".from is not display-normalized"},
|
||||
{name: "to whitespace", mutate: func(value *dnd.ItemEventList) {
|
||||
value.Events[0].Kind = dnd.ItemEventKindAcquired
|
||||
value.Events[0].To = " Aria "
|
||||
{name: "to whitespace", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences[0].Kind = dnd.ItemOccurrenceKindAcquired
|
||||
value.Occurrences[0].To = " Aria "
|
||||
}, want: ".to is not display-normalized"},
|
||||
{name: "reference order", mutate: func(value *dnd.ItemEventList) {
|
||||
value.Events[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||
{name: "reference order", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||
}, want: "not in canonical order"},
|
||||
{name: "duplicate reference", mutate: func(value *dnd.ItemEventList) {
|
||||
value.Events[0].SourceRefs = append(value.Events[0].SourceRefs, value.Events[0].SourceRefs[0])
|
||||
{name: "duplicate reference", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences[0].SourceRefs = append(value.Occurrences[0].SourceRefs, value.Occurrences[0].SourceRefs[0])
|
||||
}, want: "duplicates the previous reference"},
|
||||
{name: "list order", mutate: func(value *dnd.ItemEventList) { value.Events = []dnd.ItemEvent{value.Events[1], value.Events[0]} }, want: "out of canonical order"},
|
||||
{name: "duplicate event", mutate: func(value *dnd.ItemEventList) { value.Events = append(value.Events, value.Events[0]) }, want: "duplicates item event"},
|
||||
{name: "list order", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences = []dnd.ItemOccurrence{value.Occurrences[1], value.Occurrences[0]}
|
||||
}, want: "out of canonical order"},
|
||||
{name: "duplicate event", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences = append(value.Occurrences, value.Occurrences[0])
|
||||
}, want: "duplicates item occurrence"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -62,7 +57,7 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Validate() error = %v, want %q", err, test.want)
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: invariantDocument(), Value: value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: invariantDocument(), Value: value})
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("validator result = %#v, %v", result, err)
|
||||
}
|
||||
@@ -72,20 +67,20 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
||||
|
||||
func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) {
|
||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
|
||||
value := dnd.ItemEventList{Events: []dnd.ItemEvent{{
|
||||
Name: "Coin", Kind: dnd.ItemEventKindDiscovered,
|
||||
value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{
|
||||
ItemID: "item", Name: "Coin", Kind: dnd.ItemOccurrenceKindDiscovered,
|
||||
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
|
||||
}}}
|
||||
if err := Validate(doc, value); err == nil || !strings.Contains(err.Error(), "not in canonical order") {
|
||||
t.Fatalf("Validate() error = %v, want document-order rejection", err)
|
||||
}
|
||||
shapeInvalid := normalizedList()
|
||||
shapeInvalid.Events[0].Name = " "
|
||||
shapeInvalid.Occurrences[0].Name = " "
|
||||
if err := Validate(invariantDocument(), shapeInvalid); err != nil {
|
||||
t.Fatalf("shape failure must be deferred, got %v", err)
|
||||
}
|
||||
sourceInvalid := normalizedList()
|
||||
sourceInvalid.Events[0].SourceRefs[0].StartUnitID = 999
|
||||
sourceInvalid.Occurrences[0].SourceRefs[0].StartUnitID = 999
|
||||
if err := Validate(invariantDocument(), sourceInvalid); err != nil {
|
||||
t.Fatalf("source-reference failure must be deferred, got %v", err)
|
||||
}
|
||||
@@ -93,12 +88,12 @@ func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) {
|
||||
|
||||
func TestValidatorContractAndBoundedDiagnostics(t *testing.T) {
|
||||
value := normalizedList()
|
||||
value.Events = make([]dnd.ItemEvent, 30)
|
||||
for index := range value.Events {
|
||||
value.Events[index] = normalizedList().Events[0]
|
||||
value.Events[index].Name = strings.Repeat("火", 300)
|
||||
value.Occurrences = make([]dnd.ItemOccurrence, 30)
|
||||
for index := range value.Occurrences {
|
||||
value.Occurrences[index] = normalizedList().Occurrences[0]
|
||||
value.Occurrences[index].Name = strings.Repeat("火", 300)
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: invariantDocument(), Value: value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: invariantDocument(), Value: value})
|
||||
if err != nil || result.Approved || !utf8.ValidString(result.Message) || len([]byte(result.Message)) > 4096 || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||
t.Fatalf("bounded result = %#v, %v", result, err)
|
||||
}
|
||||
@@ -117,10 +112,9 @@ func TestValidatorContractAndBoundedDiagnostics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedList() dnd.ItemEventList {
|
||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{
|
||||
{Name: "Arrow", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
||||
{Name: "Coin", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
||||
func normalizedList() dnd.ItemOccurrenceList {
|
||||
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Arrow", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
||||
{ItemID: "item", Name: "Coin", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
119
internal/modules/dnd/validate/itemevents/registry/validator.go
Normal file
119
internal/modules/dnd/validate/itemevents/registry/validator.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Package registry validates item occurrence identity against item grounding.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "extract/dnd/item-events/registry"
|
||||
ReasonCode = "invalid_item_occurrence_registry"
|
||||
policy = "dnd.item_occurrences.validator.registry.v1"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
type Validator struct{ itemResolver *itemregistry.Resolver }
|
||||
|
||||
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(_ Options, references ...contracts.ReferenceSet) (*Validator, error) {
|
||||
if len(references) > 1 {
|
||||
return nil, fmt.Errorf("item occurrence registry validator accepts at most one reference set")
|
||||
}
|
||||
var referenceSet contracts.ReferenceSet
|
||||
if len(references) == 1 {
|
||||
referenceSet = references[0]
|
||||
}
|
||||
resolver, err := itemregistry.NewResolver(referenceSet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare item registry: %w", err)
|
||||
}
|
||||
return &Validator{itemResolver: resolver}, nil
|
||||
}
|
||||
|
||||
func (v *Validator) Name() string { return Key }
|
||||
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
func (v *Validator) ManifestMetadata() map[string]any {
|
||||
if v == nil || v.itemResolver == nil {
|
||||
return nil
|
||||
}
|
||||
metadata := map[string]any{"policy": policy}
|
||||
seeded := v.itemResolver.Seeded()
|
||||
if seeded.Bound() {
|
||||
metadata["item_registry_digest"] = seeded.Digest()
|
||||
metadata["item_count"] = seeded.Count()
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
if v == nil || v.itemResolver == nil {
|
||||
return nil
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "item_registry", Value: v.itemResolver.Seeded().ProjectionDigest()}}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||
if occurrenceshape.Validate(req.Value) != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
if v == nil || v.itemResolver == nil {
|
||||
return contracts.ValidationResult{}, fmt.Errorf("item occurrence registry validator must not be nil")
|
||||
}
|
||||
registry, err := v.itemResolver.Resolve(req.References)
|
||||
if err != nil {
|
||||
return contracts.ValidationResult{}, fmt.Errorf("resolve item registry: %w", err)
|
||||
}
|
||||
if !registry.Bound() {
|
||||
return rejection([]string{"item registry reference is required"}), nil
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for index, occurrence := range req.Value.Occurrences {
|
||||
item, ok := registry.LookupID(occurrence.ItemID)
|
||||
if !ok {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].item_id is not in the item registry: %s", index, diagnostics.Quote(occurrence.ItemID)))
|
||||
continue
|
||||
}
|
||||
if occurrence.Name != item.Name {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d] does not match registry item %s", index, diagnostics.Quote(occurrence.ItemID)))
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return rejection(issues), nil
|
||||
}
|
||||
|
||||
func rejection(issues []string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence registry", issues)}
|
||||
}
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return New(options, request.References)
|
||||
})
|
||||
}
|
||||
func DecodeOptions(options map[string]any) (Options, error) {
|
||||
if err := pipeline.RejectUnknownOptions(options); err != nil {
|
||||
return Options{}, err
|
||||
}
|
||||
return Options{}, nil
|
||||
}
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
@@ -0,0 +1,37 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry"
|
||||
itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
|
||||
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
|
||||
)
|
||||
|
||||
func TestValidatorRejectsUnknownAndMismatchedItemPairs(t *testing.T) {
|
||||
id := itemidentity.DeriveID("Torch")
|
||||
content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{{ID: id, Name: "Torch", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{itemregistry.ReferenceSlot: {Slot: contracts.ReferenceSlot{Name: itemregistry.ReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: itemregistry.ReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
|
||||
validator, err := New(Options{}, references)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := dnd.ItemOccurrence{ItemID: id, Name: "Torch", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
||||
for _, occurrence := range []dnd.ItemOccurrence{{ItemID: "unknown", Name: "Unknown", Kind: base.Kind, SourceRefs: base.SourceRefs}, {ItemID: id, Name: "Lantern", Kind: base.Kind, SourceRefs: base.SourceRefs}} {
|
||||
result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{References: references, Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{occurrence}}})
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{References: references, Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{base}}})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
@@ -34,7 +34,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||
if err := Validate(req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
|
||||
}
|
||||
@@ -42,21 +42,24 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
}
|
||||
|
||||
// Validate returns one bounded error for every owned item-event shape issue.
|
||||
func Validate(value dnd.ItemEventList) error {
|
||||
func Validate(value dnd.ItemOccurrenceList) error {
|
||||
issues := issuesFor(value)
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid item event shape", issues))
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid item occurrence shape", issues))
|
||||
}
|
||||
|
||||
func issuesFor(value dnd.ItemEventList) []string {
|
||||
if value.Events == nil {
|
||||
return []string{"events must be present"}
|
||||
func issuesFor(value dnd.ItemOccurrenceList) []string {
|
||||
if value.Occurrences == nil {
|
||||
return []string{"occurrences must be present"}
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for index, event := range value.Events {
|
||||
prefix := fmt.Sprintf("events[%d]", index)
|
||||
for index, event := range value.Occurrences {
|
||||
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||
if strings.TrimSpace(event.ItemID) == "" {
|
||||
issues = append(issues, prefix+".item_id must not be empty: "+diagnostics.Quote(event.ItemID))
|
||||
}
|
||||
if strings.TrimSpace(event.Name) == "" {
|
||||
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name))
|
||||
}
|
||||
@@ -80,7 +83,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,18 +15,17 @@ import (
|
||||
|
||||
func TestValidatorAcceptsEveryEventRuleAndNormalizableWhitespace(t *testing.T) {
|
||||
quantity := 1
|
||||
value := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
||||
{Name: " Hidden Cache ", Kind: dnd.ItemEventKindDiscovered, From: " ", To: " ", SourceRefs: refs(1, 1)},
|
||||
{Name: "Gold Pieces", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: refs(2, 2)},
|
||||
{Name: "Torch", Kind: dnd.ItemEventKindLost, From: " party ", SourceRefs: refs(3, 3)},
|
||||
{Name: "Potion", Kind: dnd.ItemEventKindConsumed, From: " Aria ", SourceRefs: refs(4, 4)},
|
||||
{Name: "Moonblade", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
|
||||
value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: " Hidden Cache ", Kind: dnd.ItemOccurrenceKindDiscovered, From: " ", To: " ", SourceRefs: refs(1, 1)},
|
||||
{ItemID: "item", Name: "Gold Pieces", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: refs(2, 2)},
|
||||
{ItemID: "item", Name: "Torch", Kind: dnd.ItemOccurrenceKindLost, From: " party ", SourceRefs: refs(3, 3)},
|
||||
{ItemID: "item", Name: "Potion", Kind: dnd.ItemOccurrenceKindConsumed, From: " Aria ", SourceRefs: refs(4, 4)},
|
||||
{ItemID: "item", Name: "Moonblade", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
|
||||
}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: value})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
empty, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: dnd.ItemEventList{Events: []dnd.ItemEvent{}}})
|
||||
empty, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}}})
|
||||
if err != nil || !empty.Approved {
|
||||
t.Fatalf("empty list result = %#v, %v", empty, err)
|
||||
}
|
||||
@@ -38,47 +37,47 @@ func TestValidatorRejectsOwnedSemanticBoundaries(t *testing.T) {
|
||||
negative := -1
|
||||
tests := []struct {
|
||||
name string
|
||||
value dnd.ItemEventList
|
||||
value dnd.ItemOccurrenceList
|
||||
want string
|
||||
}{
|
||||
{"missing events", dnd.ItemEventList{}, "events must be present"},
|
||||
{"blank name", listWith(dnd.ItemEvent{Name: " \t", Kind: dnd.ItemEventKindDiscovered, SourceRefs: refs(1, 1)}), "name must not be empty"},
|
||||
{"unsupported kind", listWith(dnd.ItemEvent{Name: "Ring", Kind: "unknown", SourceRefs: refs(1, 1)}), "kind is unsupported"},
|
||||
{"discovered holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered, From: "Aria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"acquired without holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"lost destination", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindLost, From: "Aria", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"consumed without holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindConsumed, SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"party transfer from", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "party", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"party transfer to", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: " PARTY ", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"case equivalent transfer", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "aria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"unicode equivalent transfer", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Åria", To: "Åria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"zero quantity", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, Quantity: &zero, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
|
||||
{"negative quantity", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, Quantity: &negative, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
|
||||
{"missing source refs", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered}), "source_refs must contain"},
|
||||
{"missing events", dnd.ItemOccurrenceList{}, "occurrences must be present"},
|
||||
{"blank name", listWith(dnd.ItemOccurrence{ItemID: "item", Name: " \t", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: refs(1, 1)}), "name must not be empty"},
|
||||
{"unsupported kind", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: "unknown", SourceRefs: refs(1, 1)}), "kind is unsupported"},
|
||||
{"discovered holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered, From: "Aria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"acquired without holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"lost destination", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindLost, From: "Aria", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"consumed without holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindConsumed, SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"party transfer from", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "party", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"party transfer to", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: " PARTY ", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"case equivalent transfer", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "aria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"unicode equivalent transfer", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Åria", To: "Åria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"zero quantity", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &zero, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
|
||||
{"negative quantity", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &negative, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
|
||||
{"missing source refs", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered}), "source_refs must contain"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: test.value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: test.value})
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
|
||||
t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: valid}); err != nil || !result.Approved {
|
||||
if result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: valid}); err != nil || !result.Approved {
|
||||
t.Fatalf("valid result = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing.T) {
|
||||
value := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 24)}
|
||||
for index := range value.Events {
|
||||
value.Events[index] = dnd.ItemEvent{Name: " \n", Kind: "unsupported"}
|
||||
value := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 24)}
|
||||
for index := range value.Occurrences {
|
||||
value.Occurrences[index] = dnd.ItemOccurrence{ItemID: "item", Name: " \n", Kind: "unsupported"}
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: value})
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "events[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: value})
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "occurrences[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
if value.Events[0].Name != " \n" {
|
||||
if value.Occurrences[0].Name != " \n" {
|
||||
t.Fatal("Validate() mutated input")
|
||||
}
|
||||
if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) {
|
||||
@@ -96,12 +95,12 @@ func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func validList() dnd.ItemEventList {
|
||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: refs(1, 1)}}}
|
||||
func validList() dnd.ItemOccurrenceList {
|
||||
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: refs(1, 1)}}}
|
||||
}
|
||||
|
||||
func listWith(event dnd.ItemEvent) dnd.ItemEventList {
|
||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{event}}
|
||||
func listWith(event dnd.ItemOccurrence) dnd.ItemOccurrenceList {
|
||||
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{event}}
|
||||
}
|
||||
|
||||
func refs(start, end int) []source.SourceRef {
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
@@ -34,9 +34,9 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||
if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil {
|
||||
return contracts.ValidationResult{}, fmt.Errorf("item event source-reference validator requires the current extraction chunk")
|
||||
return contracts.ValidationResult{}, fmt.Errorf("item occurrence source-reference validator requires the current extraction chunk")
|
||||
}
|
||||
if itemeventshape.Validate(req.Value) != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
@@ -47,21 +47,21 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
coverage = newChunkCoverage(req.Chunk)
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for eventIndex, event := range req.Value.Events {
|
||||
for eventIndex, event := range req.Value.Occurrences {
|
||||
for refIndex, ref := range event.SourceRefs {
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
continue
|
||||
}
|
||||
if coverage != nil && !coverage.contains(req.Source, ref) {
|
||||
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex))
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item event source references", issues)}, nil
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence source references", issues)}, nil
|
||||
}
|
||||
|
||||
type chunkCoverage struct {
|
||||
@@ -102,7 +102,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestValidatorOwnsSourceAndRangeValidation(t *testing.T) {
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("valid result = %#v, %v", result, err)
|
||||
}
|
||||
value.Events[0].SourceRefs = []source.SourceRef{
|
||||
value.Occurrences[0].SourceRefs = []source.SourceRef{
|
||||
{SourceID: "foreign", StartUnitID: 1, EndUnitID: 1},
|
||||
{SourceID: "session", StartUnitID: 99, EndUnitID: 99},
|
||||
{SourceID: "session", StartUnitID: 2, EndUnitID: 1},
|
||||
@@ -28,7 +28,7 @@ func TestValidatorOwnsSourceAndRangeValidation(t *testing.T) {
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("invalid result = %#v, %v", result, err)
|
||||
}
|
||||
for _, index := range []string{"events[0].source_refs[0]", "events[0].source_refs[1]", "events[0].source_refs[2]"} {
|
||||
for _, index := range []string{"occurrences[0].source_refs[0]", "occurrences[0].source_refs[1]", "occurrences[0].source_refs[2]"} {
|
||||
if !strings.Contains(result.Message, index) {
|
||||
t.Fatalf("validation message = %q, missing %q", result.Message, index)
|
||||
}
|
||||
@@ -39,40 +39,40 @@ func TestValidatorEnforcesChunkOnlyDuringExtraction(t *testing.T) {
|
||||
doc := document()
|
||||
chunk := &source.Chunk{ID: "chunk-0", SourceID: doc.ID, Units: append([]source.SourceUnit(nil), doc.Units[:2]...)}
|
||||
contained := validList()
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: contained})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: contained})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("contained evidence = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
crossesChunk := validList()
|
||||
crossesChunk.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: crossesChunk})
|
||||
crossesChunk.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: crossesChunk})
|
||||
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
|
||||
t.Fatalf("out-of-chunk evidence = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
noncontiguousChunk := &source.Chunk{ID: "chunk-1", SourceID: doc.ID, Units: []source.SourceUnit{doc.Units[0], doc.Units[2]}}
|
||||
spansMissingUnit := validList()
|
||||
spansMissingUnit.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 3}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: noncontiguousChunk, Value: spansMissingUnit})
|
||||
spansMissingUnit.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 3}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: noncontiguousChunk, Value: spansMissingUnit})
|
||||
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
|
||||
t.Fatalf("partially contained evidence = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
multiRange := validList()
|
||||
multiRange.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2}, {SourceID: doc.ID, StartUnitID: 3, EndUnitID: 4}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: multiRange})
|
||||
multiRange.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2}, {SourceID: doc.ID, StartUnitID: 3, EndUnitID: 4}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: multiRange})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("post-merge evidence = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) {
|
||||
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: document(), Value: validList()})
|
||||
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: document(), Value: validList()})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires the current extraction chunk") {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
malformed := dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring"}}}
|
||||
malformed := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring"}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), request(document(), malformed))
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("shape deferral = %#v, %v", result, err)
|
||||
@@ -80,12 +80,12 @@ func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
|
||||
value := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 24)}
|
||||
for index := range value.Events {
|
||||
value.Events[index] = dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "foreign", StartUnitID: index + 1, EndUnitID: index + 1}}}
|
||||
value := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 24)}
|
||||
for index := range value.Occurrences {
|
||||
value.Occurrences[index] = dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "foreign", StartUnitID: index + 1, EndUnitID: index + 1}}}
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), request(document(), value))
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "events[0].source_refs[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "occurrences[0].source_refs[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
before := validList()
|
||||
@@ -108,14 +108,14 @@ func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func request(doc *source.SourceDocument, value dnd.ItemEventList) contracts.TypedValidationRequest[dnd.ItemEventList] {
|
||||
return contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value}
|
||||
func request(doc *source.SourceDocument, value dnd.ItemOccurrenceList) contracts.TypedValidationRequest[dnd.ItemOccurrenceList] {
|
||||
return contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: value}
|
||||
}
|
||||
|
||||
func document() *source.SourceDocument {
|
||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}}
|
||||
}
|
||||
|
||||
func validList() dnd.ItemEventList {
|
||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}}
|
||||
func validList() dnd.ItemOccurrenceList {
|
||||
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
@@ -37,7 +37,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
|
||||
// Validate performs advisory source grounding only. Shape and range failures
|
||||
// are intentionally left to their blocking owners.
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||
if itemeventshape.Validate(req.Value) != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
@@ -47,15 +47,15 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
}
|
||||
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for index, event := range req.Value.Events {
|
||||
for index, event := range req.Value.Occurrences {
|
||||
citedText, err := resolver.CitedText(event.SourceRefs)
|
||||
if err != nil || shared.ContainsTokenSequence(citedText, event.Name) {
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: fmt.Sprintf("events[%d]", index),
|
||||
Scope: fmt.Sprintf("occurrences[%d]", index),
|
||||
ReasonCode: WarningReasonCode,
|
||||
Message: fmt.Sprintf("item event name %s was not found in cited source text", diagnostics.Quote(event.Name)),
|
||||
Message: fmt.Sprintf("item occurrence name %s was not found in cited source text", diagnostics.Quote(event.Name)),
|
||||
})
|
||||
}
|
||||
return contracts.ValidationResult{
|
||||
@@ -69,7 +69,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -19,29 +19,28 @@ func TestValidatorMatchesTokenSequencesAcrossRanges(t *testing.T) {
|
||||
{ID: 2, Text: "of healing in the chest."},
|
||||
{ID: 3, Text: "The group rests."},
|
||||
}}
|
||||
value := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
||||
{Name: "POTION OF HEALING", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}, {SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}},
|
||||
{Name: "missing-item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
|
||||
value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "POTION OF HEALING", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}, {SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}},
|
||||
{ItemID: "item", Name: "missing-item", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
|
||||
}}
|
||||
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("missing-item")}}},
|
||||
}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, References: references, Value: value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, References: references, Value: value})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 1 {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
if warning := result.Warnings[0]; warning.Scope != "events[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, "missing-item") {
|
||||
if warning := result.Warnings[0]; warning.Scope != "occurrences[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, "missing-item") {
|
||||
t.Fatalf("warning = %#v", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) {
|
||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "a potion"}}}
|
||||
for _, value := range []dnd.ItemEventList{
|
||||
{Events: []dnd.ItemEvent{{Name: "", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}}},
|
||||
{Events: []dnd.ItemEvent{{Name: "potion", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}}}},
|
||||
for _, value := range []dnd.ItemOccurrenceList{
|
||||
{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}}},
|
||||
{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "potion", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}}}},
|
||||
} {
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: value})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||
t.Fatalf("deferred result = %#v, %v", result, err)
|
||||
}
|
||||
@@ -50,13 +49,13 @@ func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) {
|
||||
|
||||
func TestValidatorBoundsWarningsAndRegistersPolicy(t *testing.T) {
|
||||
count := diagnostics.MaxWarnings + 5
|
||||
events := make([]dnd.ItemEvent, count)
|
||||
events := make([]dnd.ItemOccurrence, count)
|
||||
for index := range events {
|
||||
events[index] = dnd.ItemEvent{Name: "missing item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
||||
events[index] = dnd.ItemOccurrence{ItemID: "item", Name: "missing item", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{
|
||||
Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "nothing useful"}}},
|
||||
Value: dnd.ItemEventList{Events: events},
|
||||
Value: dnd.ItemOccurrenceList{Occurrences: events},
|
||||
})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
|
||||
Reference in New Issue
Block a user