Files
narratio/internal/config/party_test.go

288 lines
9.2 KiB
Go
Raw Normal View History

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) {
t.Fatal("canonical raw bytes were not preserved")
}
2026-09-02 12:04:35 +00:00
characters := document.Characters
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"},
{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"},
{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"},
{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"},
{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"},
{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"},
{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"},
{name: "non-integer level", yaml: strings.Replace(valid, "level: 8", "level: eight", 1), want: "decode.type"},
{name: "duplicate class", yaml: strings.Replace(valid, " level: 8\n", " level: 8\n - name: WIZARD\n", 1), want: "duplicate class"},
{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"},
}
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 {
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)
}
})
}
}
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)
}
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)
}
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()
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")
}
}