Harden D&D scene description extraction

This commit is contained in:
2026-07-23 23:03:37 +00:00
parent 9614eb540d
commit e1c2f3c202
8 changed files with 118 additions and 763 deletions

View File

@@ -7,6 +7,7 @@
"properties": {
"scenes": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,

View File

@@ -97,6 +97,9 @@ func validate(value dnd.SceneDescriptionList) error {
if value.Scenes == nil {
return fmt.Errorf("scenes must be present")
}
if len(value.Scenes) == 0 {
return fmt.Errorf("scenes must not be empty")
}
for index, scene := range value.Scenes {
prefix := fmt.Sprintf("scenes[%d]", index)
if strings.TrimSpace(scene.ID) == "" {

View File

@@ -8,6 +8,8 @@ import (
"strings"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
@@ -86,6 +88,40 @@ func TestCodecOwnsDurableSchemaAndMetadata(t *testing.T) {
}
}
func TestDurableSchemaRequiresAtLeastOneScene(t *testing.T) {
schemaDocument, err := jsonschema.UnmarshalJSON(bytes.NewReader(New().Schema().JSONSchema))
if err != nil {
t.Fatal(err)
}
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", schemaDocument); err != nil {
t.Fatal(err)
}
compiled, err := compiler.Compile("schema.json")
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
content []byte
valid bool
}{
{name: "one scene", content: mustEncode(t, validList()), valid: true},
{name: "empty scenes", content: []byte(`{"scenes":[]}`)},
} {
t.Run(test.name, func(t *testing.T) {
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(test.content))
if err != nil {
t.Fatal(err)
}
err = compiled.Validate(instance)
if (err == nil) != test.valid {
t.Fatalf("Validate() error = %v, want valid=%t", err, test.valid)
}
})
}
}
func TestCodecStrictlyRejectsInvalidDurableBoundaries(t *testing.T) {
validJSON := `{"scenes":[{"id":"chunk-000001","source_ref":{"source_id":"session","start_unit_id":1,"end_unit_id":1},"kind":"narrative","title":"Arrival","summary":"The party arrives."}]}`
tests := []struct{ name, raw, want string }{
@@ -95,6 +131,7 @@ func TestCodecStrictlyRejectsInvalidDurableBoundaries(t *testing.T) {
{"unknown source field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
{"trailing", `{"scenes":[]} {}`, "multiple JSON values"},
{"missing scenes", `{}`, "scenes must be present"},
{"empty scenes", `{"scenes":[]}`, "scenes must not be empty"},
{"invalid kind", strings.Replace(validJSON, `"kind":"narrative"`, `"kind":"other"`, 1), "kind must be supported"},
{"blank title", strings.Replace(validJSON, `"title":"Arrival"`, `"title":" "`, 1), "title must not be empty"},
{"blank summary", strings.Replace(validJSON, `"summary":"The party arrives."`, `"summary":" "`, 1), "summary must not be empty"},
@@ -138,6 +175,7 @@ func TestCodecRejectsRequiredApprovedValues(t *testing.T) {
want string
}{
{"nil scenes", dnd.SceneDescriptionList{}, "scenes must be present"},
{"empty scenes", dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{}}, "scenes must not be empty"},
{"empty ID", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].ID = " " }), "id must not be empty"},
{"unsupported kind", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].Kind = "unsupported" }), "kind must be supported"},
{"empty title", mutate(validList(), func(value *dnd.SceneDescriptionList) { value.Scenes[0].Title = " " }), "title must not be empty"},
@@ -155,6 +193,15 @@ func TestCodecRejectsRequiredApprovedValues(t *testing.T) {
}
}
func mustEncode(t *testing.T, value dnd.SceneDescriptionList) []byte {
t.Helper()
content, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return content
}
func mutate(value dnd.SceneDescriptionList, change func(*dnd.SceneDescriptionList)) dnd.SceneDescriptionList {
change(&value)
return value