Files
sow-tools/internal/topdata/json_contract_test.go
T

729 lines
19 KiB
Go

package topdata
import (
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
)
func TestUnsupportedObjectKeysAreSorted(t *testing.T) {
obj := map[string]any{
"row": map[string]any{},
"when_present": []any{},
"aaa": true,
}
got := unsupportedObjectKeys(
obj,
"row",
"require_present",
"any_present",
"unless_present",
)
want := []string{"aaa", "when_present"}
if !slices.Equal(got, want) {
t.Fatalf("unsupportedObjectKeys() = %v, want %v", got, want)
}
}
func TestUnsupportedObjectKeysErrorNamesObjectAndContract(t *testing.T) {
err := unsupportedObjectKeysError(
"global injection 10",
map[string]any{"row": map[string]any{}, "when_present": []any{}},
"row",
"require_present",
"any_present",
"unless_present",
)
if err == nil {
t.Fatal("expected unsupported-property error")
}
for _, want := range []string{
"global injection 10",
`"when_present"`,
"row",
"require_present",
"any_present",
"unless_present",
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q does not contain %q", err, want)
}
}
}
func TestParseGlobalConditionGroups(t *testing.T) {
tests := []struct {
name string
injection map[string]any
want globalConditionGroups
wantError string
}{
{
name: "all groups",
injection: map[string]any{
"require_present": []any{
map[string]any{"field": "FeatIndex", "id": "feat:required"},
},
"any_present": []any{
map[string]any{"field": "FeatIndex", "id": "feat:first"},
map[string]any{"field": "FeatIndex", "id": "feat:second"},
},
"unless_present": []any{
map[string]any{"field": "FeatIndex", "id": "feat:blocked"},
},
},
want: globalConditionGroups{
RequirePresent: []globalCondition{
{Field: "FeatIndex", ID: "feat:required"},
},
AnyPresent: []globalCondition{
{Field: "FeatIndex", ID: "feat:first"},
{Field: "FeatIndex", ID: "feat:second"},
},
UnlessPresent: []globalCondition{
{Field: "FeatIndex", ID: "feat:blocked"},
},
},
},
{
name: "empty existing groups remain valid",
injection: map[string]any{
"require_present": []any{},
"unless_present": []any{},
},
want: globalConditionGroups{
RequirePresent: []globalCondition{},
UnlessPresent: []globalCondition{},
},
},
{
name: "require present null is rejected",
injection: map[string]any{
"require_present": nil,
},
wantError: "require_present must be an array",
},
{
name: "unless present null is rejected",
injection: map[string]any{
"unless_present": nil,
},
wantError: "unless_present must be an array",
},
{
name: "any present must be array",
injection: map[string]any{
"any_present": "feat:first",
},
wantError: "any_present must be an array",
},
{
name: "any present must not be empty",
injection: map[string]any{
"any_present": []any{},
},
wantError: "any_present must contain at least one condition",
},
{
name: "condition is closed",
injection: map[string]any{
"require_present": []any{
map[string]any{
"field": "FeatIndex",
"id": "feat:required",
"typo": true,
},
},
},
wantError: `require_present[0] contains unsupported key "typo"`,
},
{
name: "field is required",
injection: map[string]any{
"require_present": []any{
map[string]any{"id": "feat:required"},
},
},
wantError: "require_present[0].field is required",
},
{
name: "id is required",
injection: map[string]any{
"require_present": []any{
map[string]any{"field": "FeatIndex"},
},
},
wantError: "require_present[0].id is required",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := parseGlobalConditionGroups(tc.injection)
if tc.wantError != "" {
if err == nil {
t.Fatalf("parseGlobalConditionGroups() error = nil, want %q", tc.wantError)
}
if !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("parseGlobalConditionGroups() error = %q, want contains %q", err, tc.wantError)
}
return
}
if err != nil {
t.Fatalf("parseGlobalConditionGroups() error = %v", err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("parseGlobalConditionGroups() = %#v, want %#v", got, tc.want)
}
})
}
t.Run("absent groups remain valid", func(t *testing.T) {
got, err := parseGlobalConditionGroups(map[string]any{})
if err != nil {
t.Fatalf("parseGlobalConditionGroups() error = %v", err)
}
if !reflect.DeepEqual(got, globalConditionGroups{}) {
t.Fatalf("parseGlobalConditionGroups() = %#v, want zero value", got)
}
})
}
func TestGlobalConditionGroupsMatch(t *testing.T) {
rowsWithoutBlocked := []map[string]any{
{"FeatIndex": map[string]any{"id": "feat:required"}},
{"FeatIndex": map[string]any{"id": "feat:second"}},
}
rowsWithBlocked := append([]map[string]any(nil), rowsWithoutBlocked...)
rowsWithBlocked = append(rowsWithBlocked, map[string]any{"FeatIndex": map[string]any{"id": "feat:blocked"}})
if !globalConditionGroupsMatch(globalConditionGroups{
RequirePresent: []globalCondition{
{Field: "FeatIndex", ID: "feat:required"},
},
AnyPresent: []globalCondition{
{Field: "FeatIndex", ID: "feat:first"},
{Field: "FeatIndex", ID: "feat:second"},
},
}, rowsWithoutBlocked) {
t.Fatal("expected required + later any_present alternative to match")
}
if globalConditionGroupsMatch(globalConditionGroups{
AnyPresent: []globalCondition{
{Field: "FeatIndex", ID: "feat:missing"},
},
}, rowsWithoutBlocked) {
t.Fatal("expected missing any_present alternatives to reject")
}
if globalConditionGroupsMatch(globalConditionGroups{
UnlessPresent: []globalCondition{
{Field: "FeatIndex", ID: "feat:blocked"},
},
}, rowsWithBlocked) {
t.Fatal("expected unless_present match to reject")
}
}
func TestValidateProjectRejectsUnsupportedCanonicalJSON(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, root string)
fragments []string
}{
{
name: "base root",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsBase(t, root, `{
"output": "spells.2da",
"columns": ["Label"],
"rows": [
{"id": 0, "key": "spells:acid_fog", "Label": "AcidFog"}
],
"typo": true
}`+"\n")
},
fragments: []string{"base.json root", "typo", "unsupported"},
},
{
name: "plain rows root",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "categories.json"), `{
"output": "categories.2da",
"columns": ["Category"],
"rows": [
{"id": 0, "key": "categories:harmful", "Category": "Harmful"}
],
"typo": true
}`+"\n")
},
fragments: []string{"plain rows root", "typo", "unsupported"},
},
{
name: "module root",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsDataset(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "entry.json"), `{
"entries": {
"spells:burning_hands": {"Label": "BurningHands"}
},
"typo": true
}`+"\n")
},
fragments: []string{"module root", "typo", "unsupported"},
},
{
name: "global root",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsDataset(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{
"position": "append",
"typo": true
}`+"\n")
},
fragments: []string{"global.json root", "typo", "unsupported"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root := testProjectRoot(t)
tc.setup(t, root)
report := ValidateProject(testProject(root))
if !report.HasErrors() {
t.Fatalf("expected canonical unsupported-key validation error, got:\n%s", diagnosticsText(report.Diagnostics))
}
assertDiagnosticTextContainsAll(t, report.Diagnostics, tc.fragments...)
})
}
}
func TestValidateProjectRejectsUnsupportedGlobalJSON(t *testing.T) {
tests := []struct {
name string
globalJSON string
fragments []string
}{
{
name: "injection object is closed",
globalJSON: `{
"injections": [
{
"row": {},
"when_present": []
}
]
}` + "\n",
fragments: []string{"global injection 0", "when_present", "unsupported"},
},
{
name: "condition object is closed",
globalJSON: `{
"injections": [
{
"row": {},
"require_present": [
{"field": "FeatIndex", "id": "feat:x", "typo": true}
]
}
]
}` + "\n",
fragments: []string{"require_present[0]", "typo", "unsupported"},
},
{
name: "default rule is closed",
globalJSON: `{
"defaults": [
{
"match": "all",
"values": {},
"typo": true
}
]
}` + "\n",
fragments: []string{"global default 0", "typo", "unsupported"},
},
{
name: "default match object is closed",
globalJSON: `{
"defaults": [
{
"match": {"source": "entries", "typo": true},
"values": {}
}
]
}` + "\n",
fragments: []string{"global default 0 match", "typo", "unsupported"},
},
{
name: "default format object is closed",
globalJSON: `{
"defaults": [
{
"match": "all",
"values": {
"ImpactScript": {"format": "ss_{id}", "typo": true}
}
}
]
}` + "\n",
fragments: []string{"global default 0 values.ImpactScript", "typo", "unsupported"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root := testProjectRoot(t)
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsDataset(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), tc.globalJSON)
report := ValidateProject(testProject(root))
if !report.HasErrors() {
t.Fatalf("expected global unsupported-key validation error, got:\n%s", diagnosticsText(report.Diagnostics))
}
assertDiagnosticTextContainsAll(t, report.Diagnostics, tc.fragments...)
})
}
}
func TestValidateProjectAcceptsAnyPresent(t *testing.T) {
root := testProjectRoot(t)
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsDataset(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{
"injections": [
{
"row": {"ImpactScript": "ss_new_spell"},
"any_present": [
{"field": "FeatIndex", "id": "feat:gate"}
]
}
]
}`+"\n")
report := ValidateProject(testProject(root))
if report.HasErrors() {
t.Fatalf("expected non-empty any_present to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
}
}
func TestValidateProjectRejectsInvalidAnyPresent(t *testing.T) {
tests := []struct {
name string
injection string
fragments []string
}{
{
name: "string",
injection: `{
"row": {},
"any_present": "feat:gate"
}`,
fragments: []string{"any_present", "array"},
},
{
name: "empty list",
injection: `{
"row": {},
"any_present": []
}`,
fragments: []string{"any_present", "at least one condition"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root := testProjectRoot(t)
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsDataset(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{
"injections": [
`+tc.injection+`
]
}`+"\n")
report := ValidateProject(testProject(root))
if !report.HasErrors() {
t.Fatalf("expected invalid any_present validation error, got:\n%s", diagnosticsText(report.Diagnostics))
}
assertDiagnosticTextContainsAll(t, report.Diagnostics, tc.fragments...)
})
}
}
func TestValidateProjectReportsUnsupportedGlobalInjectionKeysAndInvalidAnyPresent(t *testing.T) {
root := testProjectRoot(t)
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsDataset(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{
"injections": [
{
"row": {},
"when_present": [],
"any_present": []
}
]
}`+"\n")
report := ValidateProject(testProject(root))
if !report.HasErrors() {
t.Fatalf("expected invalid global injection to fail validation, got:\n%s", diagnosticsText(report.Diagnostics))
}
assertDiagnosticTextContainsAll(t, report.Diagnostics,
`global injection 0 contains unsupported key "when_present"`,
"any_present must contain at least one condition",
)
}
func TestValidateProjectValidatesGlobalColumnsOnce(t *testing.T) {
root := testProjectRoot(t)
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsDataset(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{
"columns": "ImpactScript",
"entries": {},
"overrides": []
}`+"\n")
report := ValidateProject(testProject(root))
if !report.HasErrors() {
t.Fatalf("expected invalid global columns file to fail validation, got:\n%s", diagnosticsText(report.Diagnostics))
}
text := diagnosticsText(report.Diagnostics)
if got := strings.Count(text, "columns must be a JSON array when present"); got != 1 {
t.Fatalf("expected one global columns diagnostic, got %d:\n%s", got, text)
}
}
func TestValidateProjectValidatesEveryCanonicalModuleContainer(t *testing.T) {
root := testProjectRoot(t)
writeCanonicalContractScaffold(t, root)
writeCanonicalRepadjustDataset(t, root)
modulePath := filepath.Join(root, "topdata", "data", "repadjust", "modules", "combined.json")
writeFile(t, modulePath, `{
"entries": {
"repadjust:burning_hands": {"Label": "BurningHands"}
},
"overrides": [
{"key": "repadjust:test", "WIKISUMMARY": "deprecated"}
]
}`+"\n")
report := ValidateProject(testProject(root))
if !report.HasErrors() {
t.Fatalf("expected combined entries+overrides module to validate every container, got:\n%s", diagnosticsText(report.Diagnostics))
}
assertDiagnosticTextContainsAll(t, report.Diagnostics, "deprecated wiki field", "WIKISUMMARY")
writeFile(t, modulePath, `{
"entries": {
"repadjust:burning_hands": {"Label": "BurningHands"}
},
"overrides": [
{"key": "repadjust:test", "Label": "FixedLabel"}
]
}`+"\n")
report = ValidateProject(testProject(root))
if report.HasErrors() {
t.Fatalf("expected fixed combined entries+overrides module to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
}
}
func TestValidateProjectAcceptsCanonicalJSONContracts(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, root string)
}{
{
name: "columns only module",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsDataset(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "columns.json"), `{
"columns": ["ImpactScript"]
}`+"\n")
},
},
{
name: "entries only module",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsDataset(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "entries.json"), `{
"entries": {
"spells:burning_hands": {"Label": "BurningHands"}
}
}`+"\n")
},
},
{
name: "overrides only module",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsDataset(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "overrides.json"), `{
"overrides": [
{"key": "spells:acid_fog", "ImpactScript": "ss_acid_fog"}
]
}`+"\n")
},
},
{
name: "rows module",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsDataset(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "modules", "rows.json"), `{
"rows": [
{"key": "spells:burning_hands", "Label": "BurningHands"}
]
}`+"\n")
},
},
{
name: "current base root",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsBase(t, root, `{
"output": "spells.2da",
"columns": ["Label"],
"rows": [
{"id": 0, "key": "spells:acid_fog", "Label": "AcidFog"}
]
}`+"\n")
},
},
{
name: "loose plain rows root",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "ruleset.json"), `{
"columns": ["Name", "Value"],
"rows": [
{"id": 0, "Name": "TEST_RULE", "Value": "1"}
]
}`+"\n")
},
},
{
name: "plain rows root compare reference false",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "categories.json"), `{
"output": "categories.2da",
"compare_reference": false,
"columns": ["Category"],
"rows": [
{"id": 0, "key": "categories:harmful", "Category": "Harmful"}
]
}`+"\n")
},
},
{
name: "row like payload helpers remain valid",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsBase(t, root, `{
"output": "spells.2da",
"columns": ["Name", "Parent", "ImpactScript"],
"rows": [
{
"id": 0,
"key": "spells:acid_fog",
"Parent": "spells:acid_fog",
"ImpactScript": "ss_acid_fog",
"inherit": {"from": "Parent", "fields": ["ImpactScript"]},
"_tlk": {"name": "Acid Fog"},
"meta": {}
}
]
}`+"\n")
},
},
{
name: "global injection row payload stays open",
setup: func(t *testing.T, root string) {
writeCanonicalContractScaffold(t, root)
writeCanonicalSpellsDataset(t, root)
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "global.json"), `{
"injections": [
{
"row": {"ImpactScript": "ss_new_spell"}
}
]
}`+"\n")
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root := testProjectRoot(t)
tc.setup(t, root)
report := ValidateProject(testProject(root))
if report.HasErrors() {
t.Fatalf("expected canonical contract to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
}
})
}
}
func writeCanonicalContractScaffold(t *testing.T, root string) {
t.Helper()
mkdirAll(t, filepath.Join(root, "topdata", "data"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
}
func writeCanonicalSpellsDataset(t *testing.T, root string) {
t.Helper()
writeCanonicalSpellsBase(t, root, `{
"output": "spells.2da",
"columns": ["Label", "ImpactScript"],
"rows": [
{"id": 0, "key": "spells:acid_fog", "Label": "AcidFog", "ImpactScript": "ss_acid_fog"}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "lock.json"), `{"spells:acid_fog":0}`+"\n")
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells", "modules"))
}
func writeCanonicalSpellsBase(t *testing.T, root string, content string) {
t.Helper()
mkdirAll(t, filepath.Join(root, "topdata", "data", "spells"))
writeFile(t, filepath.Join(root, "topdata", "data", "spells", "base.json"), content)
}
func writeCanonicalRepadjustDataset(t *testing.T, root string) {
t.Helper()
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust", "modules"))
writeFile(t, filepath.Join(root, "topdata", "data", "repadjust", "base.json"), `{
"output": "repadjust.2da",
"columns": ["Label"],
"rows": [
{"id": 0, "key": "repadjust:test", "Label": "TestLabel"}
]
}`+"\n")
}
func assertDiagnosticTextContainsAll(t *testing.T, diags []Diagnostic, fragments ...string) {
t.Helper()
text := diagnosticsText(diags)
for _, fragment := range fragments {
if !strings.Contains(text, fragment) {
t.Fatalf("expected diagnostics to contain %q, got:\n%s", fragment, text)
}
}
}