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") } }