2026-08-30 13:51:12 +00:00
|
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"testing"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
func TestParsePartyCanonicalContract(t *testing.T) {
|
|
|
|
|
|
data := []byte(`schema_version: narratio.party.v1
|
|
|
|
|
|
characters:
|
|
|
|
|
|
arannis:
|
|
|
|
|
|
player:
|
|
|
|
|
|
name: Eric
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: Arannis
|
|
|
|
|
|
alias:
|
|
|
|
|
|
- Ari
|
|
|
|
|
|
- The Grey Owl
|
|
|
|
|
|
classes:
|
|
|
|
|
|
- name: wizard
|
|
|
|
|
|
level: 8
|
|
|
|
|
|
- name: fighter
|
|
|
|
|
|
`)
|
|
|
|
|
|
|
|
|
|
|
|
document, err := ParseParty(data)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ParseParty() error = %v", err)
|
|
|
|
|
|
}
|
2026-09-02 12:04:35 +00:00
|
|
|
|
if !bytes.Equal(document.Raw, data) {
|
2026-08-30 13:51:12 +00:00
|
|
|
|
t.Fatal("canonical raw bytes were not preserved")
|
|
|
|
|
|
}
|
2026-09-02 12:04:35 +00:00
|
|
|
|
characters := document.Characters
|
2026-08-30 13:51:12 +00:00
|
|
|
|
if len(characters) != 1 || characters[0].ID != "arannis" {
|
|
|
|
|
|
t.Fatalf("characters = %#v, want ordered arannis character", characters)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got, want := characters[0].ClassSummary(), "wizard 8 / fighter"; got != want {
|
|
|
|
|
|
t.Fatalf("ClassSummary() = %q, want %q", got, want)
|
|
|
|
|
|
}
|
|
|
|
|
|
if got, want := characters[0].AliasSummary(), "Ari, The Grey Owl"; got != want {
|
|
|
|
|
|
t.Fatalf("AliasSummary() = %q, want %q", got, want)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestParsePartyCanonicalRejectsInvalidDocuments(t *testing.T) {
|
|
|
|
|
|
valid := `schema_version: narratio.party.v1
|
|
|
|
|
|
characters:
|
|
|
|
|
|
arannis:
|
|
|
|
|
|
player:
|
|
|
|
|
|
name: Eric
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: Arannis
|
|
|
|
|
|
classes:
|
|
|
|
|
|
- name: wizard
|
|
|
|
|
|
level: 8
|
|
|
|
|
|
`
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
yaml string
|
|
|
|
|
|
want string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{name: "unsupported version", yaml: strings.Replace(valid, PartySchemaVersion, "narratio.party.v2", 1), want: "unsupported"},
|
2026-09-16 04:29:05 +00:00
|
|
|
|
{name: "unsupported integer version", yaml: strings.Replace(valid, PartySchemaVersion, "1", 1), want: "unsupported"},
|
|
|
|
|
|
{name: "boolean version", yaml: strings.Replace(valid, PartySchemaVersion, "true", 1), want: "version.type"},
|
2026-09-07 04:02:21 +00:00
|
|
|
|
{name: "unknown top field", yaml: valid + "unknown: true\n", want: "decode.unknown_field"},
|
|
|
|
|
|
{name: "unknown nested field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n role: GM\n", 1), want: "decode.unknown_field"},
|
|
|
|
|
|
{name: "trailing document", yaml: valid + "---\ncharacters: {}\n", want: "yaml.multiple_documents"},
|
2026-08-30 13:51:12 +00:00
|
|
|
|
{name: "empty characters", yaml: "schema_version: narratio.party.v1\ncharacters: {}\n", want: "must be non-empty"},
|
|
|
|
|
|
{name: "invalid id", yaml: strings.Replace(valid, " arannis:", " bad-id:", 1), want: "invalid character id"},
|
|
|
|
|
|
{name: "whitespace id", yaml: strings.Replace(valid, " arannis:", " ' arannis':", 1), want: "invalid character id"},
|
2026-09-07 04:02:21 +00:00
|
|
|
|
{name: "missing player name", yaml: strings.Replace(valid, "player:\n name: Eric", "player: {}", 1), want: "/characters/arannis/player/name"},
|
|
|
|
|
|
{name: "missing character name", yaml: strings.Replace(valid, " name: Arannis\n", "", 1), want: "/characters/arannis/character/name"},
|
2026-08-30 13:51:12 +00:00
|
|
|
|
{name: "blank player name", yaml: strings.Replace(valid, "name: Eric", "name: ' Eric'", 1), want: "non-empty and trimmed"},
|
|
|
|
|
|
{name: "control character", yaml: strings.Replace(valid, "name: Arannis", "name: \"Ara\\tnnis\"", 1), want: "control characters"},
|
2026-09-07 04:02:21 +00:00
|
|
|
|
{name: "non-string display", yaml: strings.Replace(valid, "name: Eric", "name: 42", 1), want: "decode.type"},
|
|
|
|
|
|
{name: "missing classes", yaml: strings.Replace(valid, " classes:\n - name: wizard\n level: 8\n", "", 1), want: "classes must be a non-empty list"},
|
2026-08-30 13:51:12 +00:00
|
|
|
|
{name: "empty classes", yaml: strings.Replace(valid, " classes:\n - name: wizard\n level: 8\n", " classes: []\n", 1), want: "non-empty list"},
|
|
|
|
|
|
{name: "zero level", yaml: strings.Replace(valid, "level: 8", "level: 0", 1), want: "positive integer"},
|
|
|
|
|
|
{name: "negative level", yaml: strings.Replace(valid, "level: 8", "level: -2", 1), want: "positive integer"},
|
2026-09-07 04:02:21 +00:00
|
|
|
|
{name: "non-integer level", yaml: strings.Replace(valid, "level: 8", "level: eight", 1), want: "decode.type"},
|
2026-08-30 13:51:12 +00:00
|
|
|
|
{name: "duplicate class", yaml: strings.Replace(valid, " level: 8\n", " level: 8\n - name: WIZARD\n", 1), want: "duplicate class"},
|
2026-09-07 04:02:21 +00:00
|
|
|
|
{name: "duplicate field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n name: Erin\n", 1), want: "yaml.duplicate_key"},
|
|
|
|
|
|
{name: "yaml alias", yaml: strings.Replace(strings.Replace(valid, " name: Eric", " name: &player Eric", 1), " name: Arannis", " name: *player", 1), want: "yaml.anchor"},
|
2026-08-30 13:51:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, test := range tests {
|
|
|
|
|
|
t.Run(test.name, func(t *testing.T) {
|
|
|
|
|
|
_, err := ParseParty([]byte(test.yaml))
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("ParseParty() error = nil, want rejection")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), test.want) {
|
|
|
|
|
|
t.Fatalf("ParseParty() error = %q, want %q", err, test.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestParsePartyRejectsGlobalAliasAmbiguityWithUnicodeCaseFolding(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
yaml string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "alias equals primary on same character",
|
|
|
|
|
|
yaml: `schema_version: narratio.party.v1
|
|
|
|
|
|
characters:
|
|
|
|
|
|
arannis:
|
|
|
|
|
|
player: {name: Eric}
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: Arannis
|
|
|
|
|
|
alias: [arannis]
|
|
|
|
|
|
classes: [{name: wizard}]
|
|
|
|
|
|
`,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "primary collides with prior alias",
|
|
|
|
|
|
yaml: `schema_version: narratio.party.v1
|
|
|
|
|
|
characters:
|
|
|
|
|
|
arannis:
|
|
|
|
|
|
player: {name: Eric}
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: Arannis
|
|
|
|
|
|
alias: [Ari]
|
|
|
|
|
|
classes: [{name: wizard}]
|
|
|
|
|
|
brenna:
|
|
|
|
|
|
player: {name: Jane}
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: ari
|
|
|
|
|
|
classes: [{name: paladin}]
|
|
|
|
|
|
`,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "aliases collide on one character",
|
|
|
|
|
|
yaml: `schema_version: narratio.party.v1
|
|
|
|
|
|
characters:
|
|
|
|
|
|
arannis:
|
|
|
|
|
|
player: {name: Eric}
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: Arannis
|
|
|
|
|
|
alias: [Ari, ari]
|
|
|
|
|
|
classes: [{name: wizard}]
|
|
|
|
|
|
`,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "unicode simple fold collision",
|
|
|
|
|
|
yaml: `schema_version: narratio.party.v1
|
|
|
|
|
|
characters:
|
|
|
|
|
|
kelvin:
|
|
|
|
|
|
player: {name: Eric}
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: Kelvin
|
|
|
|
|
|
alias: [Knight]
|
|
|
|
|
|
classes: [{name: wizard}]
|
|
|
|
|
|
knight:
|
|
|
|
|
|
player: {name: Jane}
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: knight
|
|
|
|
|
|
classes: [{name: paladin}]
|
|
|
|
|
|
`,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, test := range tests {
|
|
|
|
|
|
t.Run(test.name, func(t *testing.T) {
|
|
|
|
|
|
_, err := ParseParty([]byte(test.yaml))
|
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "ambiguous") {
|
|
|
|
|
|
t.Fatalf("ParseParty() error = %v, want ambiguity rejection", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestParsePartyAllowsRepeatedPlayersAndEmptyAliasList(t *testing.T) {
|
|
|
|
|
|
document, err := ParseParty([]byte(`schema_version: narratio.party.v1
|
|
|
|
|
|
characters:
|
|
|
|
|
|
arannis:
|
|
|
|
|
|
player: {name: Eric}
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: Arannis
|
|
|
|
|
|
alias: []
|
|
|
|
|
|
classes: [{name: wizard}]
|
|
|
|
|
|
brenna:
|
|
|
|
|
|
player: {name: Eric}
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: Brenna
|
|
|
|
|
|
classes: [{name: paladin}]
|
|
|
|
|
|
`))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ParseParty() error = %v", err)
|
|
|
|
|
|
}
|
2026-09-02 12:04:35 +00:00
|
|
|
|
if got := len(document.Characters); got != 2 {
|
2026-08-30 13:51:12 +00:00
|
|
|
|
t.Fatalf("character count = %d, want 2", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestParsePartyRejectsEmptyAlias(t *testing.T) {
|
|
|
|
|
|
_, err := ParseParty([]byte(`schema_version: narratio.party.v1
|
|
|
|
|
|
characters:
|
|
|
|
|
|
arannis:
|
|
|
|
|
|
player: {name: Eric}
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: Arannis
|
|
|
|
|
|
alias: [""]
|
|
|
|
|
|
classes: [{name: wizard}]
|
|
|
|
|
|
`))
|
|
|
|
|
|
if err == nil || !strings.Contains(err.Error(), "non-empty") {
|
|
|
|
|
|
t.Fatalf("ParseParty() error = %v, want empty alias rejection", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 11:57:51 +00:00
|
|
|
|
func TestParsePartyRejectsMissingOrUnknownSchemaVersion(t *testing.T) {
|
|
|
|
|
|
for _, test := range []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
yaml string
|
|
|
|
|
|
want string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{name: "missing", yaml: "characters: {}\n", want: "schema_version is required"},
|
|
|
|
|
|
{name: "unknown", yaml: "schema_version: narratio.party.v2\ncharacters: {}\n", want: "supported schema_version is \"narratio.party.v1\""},
|
|
|
|
|
|
} {
|
|
|
|
|
|
t.Run(test.name, func(t *testing.T) {
|
|
|
|
|
|
if _, err := ParseParty([]byte(test.yaml)); err == nil || !strings.Contains(err.Error(), test.want) {
|
|
|
|
|
|
t.Fatalf("ParseParty() error = %v, want %q", err, test.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-08-30 13:51:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestCanonicalPartyPlayersYAML(t *testing.T) {
|
|
|
|
|
|
document, err := ParseParty([]byte(`schema_version: narratio.party.v1
|
|
|
|
|
|
characters:
|
|
|
|
|
|
zeta:
|
|
|
|
|
|
player: {name: Shared Player}
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: Zeta
|
|
|
|
|
|
alias: [Z, The Last]
|
|
|
|
|
|
classes: [{name: wizard, level: 8}]
|
|
|
|
|
|
alpha:
|
|
|
|
|
|
player: {name: Shared Player}
|
|
|
|
|
|
character:
|
|
|
|
|
|
name: Alpha
|
|
|
|
|
|
classes: [{name: ranger}]
|
|
|
|
|
|
`))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("ParseParty() error = %v", err)
|
|
|
|
|
|
}
|
2026-09-07 04:02:21 +00:00
|
|
|
|
if len(document.Characters) != 2 || document.Characters[0].ID != "zeta" || document.Characters[1].ID != "alpha" {
|
|
|
|
|
|
t.Fatalf("authored character order = %#v, want zeta then alpha", document.Characters)
|
|
|
|
|
|
}
|
2026-08-30 13:51:12 +00:00
|
|
|
|
got, err := document.PlayersYAML()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("PlayersYAML() error = %v", err)
|
|
|
|
|
|
}
|
2026-09-02 12:04:35 +00:00
|
|
|
|
again, err := document.PlayersYAML()
|
2026-08-30 13:51:12 +00:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("PlayersYAML() repeat error = %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !bytes.Equal(got, again) {
|
|
|
|
|
|
t.Fatalf("PlayersYAML() was not deterministic:\nfirst:\n%s\nsecond:\n%s", got, again)
|
|
|
|
|
|
}
|
|
|
|
|
|
want := `schema_version: narratio.players.v1
|
|
|
|
|
|
players:
|
|
|
|
|
|
- name: Shared Player
|
|
|
|
|
|
character:
|
|
|
|
|
|
id: alpha
|
|
|
|
|
|
name: Alpha
|
|
|
|
|
|
- name: Shared Player
|
|
|
|
|
|
character:
|
|
|
|
|
|
id: zeta
|
|
|
|
|
|
name: Zeta
|
|
|
|
|
|
alias:
|
|
|
|
|
|
- Z
|
|
|
|
|
|
- The Last
|
|
|
|
|
|
`
|
|
|
|
|
|
if string(got) != want {
|
|
|
|
|
|
t.Fatalf("PlayersYAML() =\n%s\nwant:\n%s", got, want)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.HasSuffix(string(got), "\n") || strings.Contains(string(got), "classes") {
|
|
|
|
|
|
t.Fatalf("players projection leaked unsupported data: %q", got)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestCanonicalPartyPlayersYAMLRequiresCanonicalParty(t *testing.T) {
|
|
|
|
|
|
var party *CanonicalParty
|
|
|
|
|
|
if _, err := party.PlayersYAML(); err == nil {
|
|
|
|
|
|
t.Fatal("PlayersYAML() error = nil, want canonical party requirement")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|