287 lines
9.1 KiB
Go
287 lines
9.1 KiB
Go
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)
|
||
}
|
||
if !document.IsCanonical() || document.Mode != PartyModeCanonical {
|
||
t.Fatalf("document mode = %#v, want canonical", document)
|
||
}
|
||
if !bytes.Equal(document.Canonical.Raw, data) {
|
||
t.Fatal("canonical raw bytes were not preserved")
|
||
}
|
||
characters := document.Canonical.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"},
|
||
{name: "non-string version", yaml: strings.Replace(valid, PartySchemaVersion, "1", 1), want: "must be a non-empty string"},
|
||
{name: "unknown top field", yaml: valid + "unknown: true\n", want: "unknown field"},
|
||
{name: "unknown nested field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n role: GM\n", 1), want: "unknown field"},
|
||
{name: "trailing document", yaml: valid + "---\ncharacters: {}\n", want: "exactly one YAML document"},
|
||
{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: "player.name is required"},
|
||
{name: "missing character name", yaml: strings.Replace(valid, " name: Arannis\n", "", 1), want: "character.name is required"},
|
||
{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: "display string"},
|
||
{name: "missing classes", yaml: strings.Replace(valid, " classes:\n - name: wizard\n level: 8\n", "", 1), want: "classes is required"},
|
||
{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: "positive integer"},
|
||
{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: "duplicate field"},
|
||
{name: "yaml alias", yaml: strings.Replace(strings.Replace(valid, " name: Eric", " name: &player Eric", 1), " name: Arannis", " name: *player", 1), want: "must not use YAML aliases"},
|
||
}
|
||
|
||
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)
|
||
}
|
||
if got := len(document.Canonical.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)
|
||
}
|
||
}
|
||
|
||
func TestParsePartyClassifiesUnversionedDocumentAsLegacy(t *testing.T) {
|
||
document, err := ParseParty([]byte("players:\n - not canonical\n---\nlegacy: remains opaque\n"))
|
||
if err != nil {
|
||
t.Fatalf("ParseParty() error = %v", err)
|
||
}
|
||
if document.Mode != PartyModeLegacy || document.Canonical != nil || document.IsCanonical() {
|
||
t.Fatalf("document = %#v, want opaque legacy classification", document)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
got, err := document.PlayersYAML()
|
||
if err != nil {
|
||
t.Fatalf("PlayersYAML() error = %v", err)
|
||
}
|
||
again, err := document.Canonical.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")
|
||
}
|
||
legacy, err := ParseParty([]byte("legacy: party\n"))
|
||
if err != nil {
|
||
t.Fatalf("ParseParty() legacy error = %v", err)
|
||
}
|
||
if _, err := legacy.PlayersYAML(); err == nil {
|
||
t.Fatal("legacy PlayersYAML() error = nil, want canonical party requirement")
|
||
}
|
||
}
|