First commit, done on M6 from the PLAN.MD
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"countrygame/web"
|
||||
)
|
||||
|
||||
var (
|
||||
docTypes = set([]string{"memo", "telegram", "petition", "report", "letter"})
|
||||
cardKeys = set([]string{"id", "category", "doc", "sender", "weight", "once", "cooldownDays", "followUpOnly", "conditions", "title", "body", "yes", "no"})
|
||||
optionKeys = set([]string{"label", "effects", "setFlags", "clearFlags", "followUp"})
|
||||
condKeys = set([]string{"min", "max", "flags", "notFlags", "dayMin", "dayMax"})
|
||||
effectKeys = set([]string{"stat", "sector", "revenue", "oneOff", "extraSpending", "delta", "days", "bias"})
|
||||
)
|
||||
|
||||
func isLocalized(v any) bool {
|
||||
m, ok := v.(obj)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, l := range []string{"en", "fr"} {
|
||||
s, ok := m[l].(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(m) == 2
|
||||
}
|
||||
|
||||
func isWholeNumber(v any) bool {
|
||||
f, ok := v.(float64)
|
||||
return ok && f >= 1 && f == math.Trunc(f)
|
||||
}
|
||||
|
||||
// cardChecker collects cross-card facts while validating one card at a time.
|
||||
type cardChecker struct {
|
||||
t *testing.T
|
||||
sectors map[string]bool
|
||||
stats map[string]bool
|
||||
condStats map[string]bool
|
||||
revenues map[string]bool
|
||||
ids map[string]bool
|
||||
setFlags map[string]bool
|
||||
usedFlags map[string]string
|
||||
followTargets map[string]string
|
||||
}
|
||||
|
||||
func (c *cardChecker) effects(id, side string, effects any) {
|
||||
t := c.t
|
||||
list, _ := effects.([]any)
|
||||
for i, raw := range list {
|
||||
where := id + "." + side + ".effects[" + string(rune('0'+i)) + "]"
|
||||
e, ok := raw.(obj)
|
||||
if !ok {
|
||||
t.Errorf("%s is not an object", where)
|
||||
continue
|
||||
}
|
||||
for k := range e {
|
||||
if !effectKeys[k] {
|
||||
t.Errorf("%s: unknown field %q", where, k)
|
||||
}
|
||||
}
|
||||
kinds := 0
|
||||
for _, k := range []string{"stat", "sector", "revenue", "oneOff", "extraSpending"} {
|
||||
if _, ok := e[k]; ok {
|
||||
kinds++
|
||||
}
|
||||
}
|
||||
if kinds != 1 {
|
||||
t.Errorf("%s: needs exactly one of stat/sector/revenue/oneOff/extraSpending", where)
|
||||
}
|
||||
if s, ok := e["stat"].(string); ok && !c.stats[s] {
|
||||
t.Errorf("%s: unknown stat %q", where, s)
|
||||
}
|
||||
if s, ok := e["sector"].(string); ok && !c.sectors[s] {
|
||||
t.Errorf("%s: unknown sector %q", where, s)
|
||||
}
|
||||
if s, ok := e["revenue"].(string); ok && !c.revenues[s] {
|
||||
t.Errorf("%s: unknown revenue %q", where, s)
|
||||
}
|
||||
_, isStat := e["stat"]
|
||||
_, isSector := e["sector"]
|
||||
_, isRevenue := e["revenue"]
|
||||
if isStat || isSector || isRevenue {
|
||||
if _, ok := e["delta"].(float64); !ok {
|
||||
t.Errorf("%s: missing numeric delta", where)
|
||||
}
|
||||
}
|
||||
if d, ok := e["days"]; ok && !isWholeNumber(d) {
|
||||
t.Errorf("%s: days must be a whole number >= 1", where)
|
||||
}
|
||||
if b, ok := e["bias"]; ok {
|
||||
if _, isBool := b.(bool); !isBool {
|
||||
t.Errorf("%s: bias must be a boolean", where)
|
||||
}
|
||||
if !isStat && !isSector {
|
||||
t.Errorf("%s: bias only applies to stat/sector effects", where)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cardChecker) conditions(id string, cond obj) {
|
||||
t := c.t
|
||||
for k := range cond {
|
||||
if !condKeys[k] {
|
||||
t.Errorf("%s.conditions: unknown field %q", id, k)
|
||||
}
|
||||
}
|
||||
for _, bound := range []string{"min", "max"} {
|
||||
m, _ := cond[bound].(obj)
|
||||
for k, v := range m {
|
||||
okKey := c.condStats[k] || (strings.HasPrefix(k, "q:") && c.sectors[strings.TrimPrefix(k, "q:")])
|
||||
if !okKey {
|
||||
t.Errorf("%s.conditions.%s: unknown stat %q", id, bound, k)
|
||||
}
|
||||
if _, isNum := v.(float64); !isNum {
|
||||
t.Errorf("%s.conditions.%s.%s must be a number", id, bound, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, fl := range []string{"flags", "notFlags"} {
|
||||
list, _ := cond[fl].([]any)
|
||||
for _, x := range list {
|
||||
c.usedFlags[x.(string)] = id
|
||||
}
|
||||
}
|
||||
lo, hasLo := cond["dayMin"].(float64)
|
||||
hi, hasHi := cond["dayMax"].(float64)
|
||||
if hasLo && hasHi && lo > hi {
|
||||
t.Errorf("%s.conditions: dayMin > dayMax", id)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cardChecker) option(id, side string, opt obj) {
|
||||
t := c.t
|
||||
for k := range opt {
|
||||
if !optionKeys[k] {
|
||||
t.Errorf("%s.%s: unknown field %q", id, side, k)
|
||||
}
|
||||
}
|
||||
if !isLocalized(opt["label"]) {
|
||||
t.Errorf("%s.%s.label needs non-empty en and fr", id, side)
|
||||
}
|
||||
c.effects(id, side, opt["effects"])
|
||||
flags, _ := opt["setFlags"].([]any)
|
||||
for _, x := range flags {
|
||||
c.setFlags[x.(string)] = true
|
||||
}
|
||||
if fu, ok := opt["followUp"].(obj); ok {
|
||||
target, _ := fu["id"].(string)
|
||||
c.followTargets[target] = id
|
||||
if !isWholeNumber(fu["inDays"]) {
|
||||
t.Errorf("%s.%s.followUp.inDays must be a whole number >= 1", id, side)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCards(t *testing.T) {
|
||||
scn := load(t, "data/scenarios/default.json")
|
||||
bal := load(t, "data/balance.json")
|
||||
chars := load(t, "data/characters.json")
|
||||
index := load(t, "data/cards/index.json")
|
||||
|
||||
c := &cardChecker{
|
||||
t: t,
|
||||
sectors: set(keys(scn["sectors"].(obj))),
|
||||
stats: set(append(append([]string{}, indIDs...), varIDs...)),
|
||||
condStats: set(append([]string{"debt", "deficit"}, append(append([]string{}, indIDs...), varIDs...)...)),
|
||||
revenues: set(append([]string{"other"}, taxIDs...)),
|
||||
ids: map[string]bool{},
|
||||
setFlags: map[string]bool{},
|
||||
usedFlags: map[string]string{},
|
||||
followTargets: map[string]string{},
|
||||
}
|
||||
categories := set(append(keys(bal["cards"].(obj)["boost"].(obj)), "crises"))
|
||||
|
||||
// Every card file must be in the manifest and vice versa.
|
||||
listed := map[string]bool{}
|
||||
for _, f := range index["files"].([]any) {
|
||||
listed[f.(string)] = true
|
||||
}
|
||||
entries, err := web.Files.ReadDir("data/cards")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() != "index.json" && !listed[e.Name()] {
|
||||
t.Errorf("cards/%s is not listed in cards/index.json", e.Name())
|
||||
}
|
||||
delete(listed, e.Name())
|
||||
}
|
||||
for f := range listed {
|
||||
t.Errorf("cards/index.json lists %s but the file does not exist", f)
|
||||
}
|
||||
|
||||
followOnly := map[string]bool{}
|
||||
total := 0
|
||||
for _, f := range index["files"].([]any) {
|
||||
file := f.(string)
|
||||
raw, err := web.Files.ReadFile("data/cards/" + file)
|
||||
if err != nil {
|
||||
t.Errorf("read %s: %v", file, err)
|
||||
continue
|
||||
}
|
||||
var list []obj
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
t.Errorf("parse %s: %v", file, err)
|
||||
continue
|
||||
}
|
||||
stem := strings.TrimSuffix(file, ".json")
|
||||
for _, card := range list {
|
||||
id, _ := card["id"].(string)
|
||||
if id == "" {
|
||||
t.Errorf("%s: card without id", file)
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if c.ids[id] {
|
||||
t.Errorf("duplicate card id %s", id)
|
||||
}
|
||||
c.ids[id] = true
|
||||
if only, _ := card["followUpOnly"].(bool); only {
|
||||
followOnly[id] = true
|
||||
}
|
||||
for k := range card {
|
||||
if !cardKeys[k] {
|
||||
t.Errorf("%s: unknown field %q", id, k)
|
||||
}
|
||||
}
|
||||
if cat, _ := card["category"].(string); cat != stem || !categories[cat] {
|
||||
t.Errorf("%s: category %q must match its file (%s) and be a known category", id, card["category"], stem)
|
||||
}
|
||||
if d, _ := card["doc"].(string); !docTypes[d] {
|
||||
t.Errorf("%s: unknown doc type %q", id, card["doc"])
|
||||
}
|
||||
if s, _ := card["sender"].(string); chars[s] == nil {
|
||||
t.Errorf("%s: unknown sender %q", id, card["sender"])
|
||||
}
|
||||
if !isLocalized(card["title"]) || !isLocalized(card["body"]) {
|
||||
t.Errorf("%s: title and body need non-empty en and fr", id)
|
||||
}
|
||||
if w, ok := card["weight"]; ok {
|
||||
if f, isNum := w.(float64); !isNum || f <= 0 {
|
||||
t.Errorf("%s: weight must be positive", id)
|
||||
}
|
||||
}
|
||||
if cd, ok := card["cooldownDays"]; ok && !isWholeNumber(cd) {
|
||||
t.Errorf("%s: cooldownDays must be a whole number >= 1", id)
|
||||
}
|
||||
if cond, ok := card["conditions"].(obj); ok {
|
||||
c.conditions(id, cond)
|
||||
}
|
||||
for _, side := range []string{"yes", "no"} {
|
||||
opt, ok := card[side].(obj)
|
||||
if !ok {
|
||||
t.Errorf("%s: missing %s option", id, side)
|
||||
continue
|
||||
}
|
||||
c.option(id, side, opt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for target, from := range c.followTargets {
|
||||
if !c.ids[target] {
|
||||
t.Errorf("%s: follow-up %q does not exist", from, target)
|
||||
}
|
||||
}
|
||||
for id := range followOnly {
|
||||
if c.followTargets[id] == "" {
|
||||
t.Errorf("%s is followUpOnly but no card leads to it", id)
|
||||
}
|
||||
}
|
||||
for fl, by := range c.usedFlags {
|
||||
if !c.setFlags[fl] {
|
||||
t.Errorf("%s: flag %q is used in conditions but never set by any card", by, fl)
|
||||
}
|
||||
}
|
||||
if total < 60 {
|
||||
t.Errorf("only %d cards; M4 expects at least 60", total)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user