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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"countrygame/web"
|
||||
)
|
||||
|
||||
var (
|
||||
indIDs = []string{"order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty"}
|
||||
varIDs = []string{"growth", "unemployment", "inflation"}
|
||||
taxIDs = []string{"income", "corporate", "vat"}
|
||||
devKeys = []string{"growth", "unemployment", "inflation", "debt", "deficit", "taxPressure", "taxGrowthDrag", "crackdown"}
|
||||
)
|
||||
|
||||
type obj = map[string]any
|
||||
|
||||
func load(t *testing.T, path string) obj {
|
||||
t.Helper()
|
||||
raw, err := web.Files.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var out obj
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func keys(m obj) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func set(list []string) map[string]bool {
|
||||
m := make(map[string]bool, len(list))
|
||||
for _, s := range list {
|
||||
m[s] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func sameKeys(t *testing.T, what string, got obj, want []string) {
|
||||
t.Helper()
|
||||
w := append([]string(nil), want...)
|
||||
sort.Strings(w)
|
||||
g := keys(got)
|
||||
if len(g) != len(w) {
|
||||
t.Errorf("%s: keys %v, want %v", what, g, w)
|
||||
return
|
||||
}
|
||||
for i := range g {
|
||||
if g[i] != w[i] {
|
||||
t.Errorf("%s: keys %v, want %v", what, g, w)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkWeights verifies every key of def[field] is in the allowed set.
|
||||
func checkWeights(t *testing.T, who string, def obj, field string, allowed map[string]bool) {
|
||||
t.Helper()
|
||||
m, ok := def[field].(obj)
|
||||
if !ok {
|
||||
return // optional section
|
||||
}
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
t.Errorf("%s.%s references unknown %q", who, field, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceAndScenario(t *testing.T) {
|
||||
bal := load(t, "data/balance.json")
|
||||
scn := load(t, "data/scenarios/default.json")
|
||||
|
||||
sectors := scn["sectors"].(obj)
|
||||
sectorSet := make(map[string]bool)
|
||||
for id := range sectors {
|
||||
sectorSet[id] = true
|
||||
}
|
||||
|
||||
// Scenario consistency.
|
||||
sameKeys(t, "scenario.indicators", scn["indicators"].(obj), indIDs)
|
||||
sameKeys(t, "scenario.economy.taxes", scn["economy"].(obj)["taxes"].(obj), taxIDs)
|
||||
limit := bal["limits"].(obj)["allocMaxRatio"].(float64)
|
||||
for id, raw := range sectors {
|
||||
s := raw.(obj)
|
||||
need, alloc := s["need"].(float64), s["alloc"].(float64)
|
||||
if need <= 0 || alloc < 0 || alloc > need*limit {
|
||||
t.Errorf("scenario sector %s: need=%v alloc=%v out of range", id, need, alloc)
|
||||
}
|
||||
}
|
||||
for id, v := range scn["indicators"].(obj) {
|
||||
if f := v.(float64); f < 0 || f > 100 {
|
||||
t.Errorf("scenario indicator %s=%v outside 0..100", id, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Balance coverage.
|
||||
sameKeys(t, "balance.sectorTau", bal["sectorTau"].(obj), keys(sectors))
|
||||
sameKeys(t, "balance.indicators", bal["indicators"].(obj), indIDs)
|
||||
sameKeys(t, "balance.vars", bal["vars"].(obj), varIDs)
|
||||
sameKeys(t, "balance.tax", bal["tax"].(obj), taxIDs)
|
||||
for id, tau := range bal["sectorTau"].(obj) {
|
||||
if tau.(float64) <= 0 {
|
||||
t.Errorf("sectorTau.%s must be positive", id)
|
||||
}
|
||||
}
|
||||
|
||||
// Every weight points at something that exists.
|
||||
dev, ind := set(devKeys), set(indIDs)
|
||||
groups := []obj{bal["indicators"].(obj), bal["vars"].(obj)}
|
||||
for _, g := range groups {
|
||||
for id, raw := range g {
|
||||
def := raw.(obj)
|
||||
if def["tau"].(float64) <= 0 {
|
||||
t.Errorf("%s.tau must be positive", id)
|
||||
}
|
||||
checkWeights(t, id, def, "sectors", sectorSet)
|
||||
checkWeights(t, id, def, "alloc", sectorSet)
|
||||
checkWeights(t, id, def, "econ", dev)
|
||||
checkWeights(t, id, def, "ind", ind)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
earlyEnds = []string{"revolution", "coup", "default", "impeachment"}
|
||||
metricIDs = []string{"order", "stability", "civilRights", "opinion", "economy", "hardPower", "softPower", "unrest", "armyLoyalty", "growth", "unemployment", "inflation", "debt", "score", "years"}
|
||||
tokenPattern = regexp.MustCompile(`\{(\w+)\}`)
|
||||
// Pronoun tokens each language knows how to fill (see i18n.js, tp()).
|
||||
allowedTokens = map[string]map[string]bool{
|
||||
"en": set([]string{"sub", "Sub", "obj", "pos", "is", "has", "was", "country"}),
|
||||
"fr": set([]string{"sub", "Sub", "tonic", "e", "country"}),
|
||||
}
|
||||
)
|
||||
|
||||
func checkTokens(t *testing.T, where string, v any) {
|
||||
t.Helper()
|
||||
m, _ := v.(obj)
|
||||
for lang, raw := range m {
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, match := range tokenPattern.FindAllStringSubmatch(s, -1) {
|
||||
if !allowedTokens[lang][match[1]] {
|
||||
t.Errorf("%s.%s: unknown token {%s}", where, lang, match[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndgame(t *testing.T) {
|
||||
eg := load(t, "data/endgame.json")
|
||||
bal := load(t, "data/balance.json")
|
||||
|
||||
// Banners: the term plus one per way of losing.
|
||||
over := eg["over"].(obj)
|
||||
sameKeys(t, "endgame.over", over, append([]string{"term"}, earlyEnds...))
|
||||
for k, v := range over {
|
||||
e := v.(obj)
|
||||
if !isLocalized(e["h"]) || !isLocalized(e["d"]) {
|
||||
t.Errorf("over.%s: h and d need non-empty en and fr", k)
|
||||
}
|
||||
}
|
||||
|
||||
// Urgent telegrams: one per danger zone.
|
||||
alerts := eg["alerts"].(obj)
|
||||
sameKeys(t, "endgame.alerts", alerts, earlyEnds)
|
||||
for k, v := range alerts {
|
||||
a := v.(obj)
|
||||
if !isLocalized(a["title"]) || !isLocalized(a["body"]) {
|
||||
t.Errorf("alerts.%s: title and body need non-empty en and fr", k)
|
||||
}
|
||||
}
|
||||
|
||||
// Historian titles.
|
||||
metrics := set(metricIDs)
|
||||
ids := map[string]bool{}
|
||||
perEnd := map[string]int{}
|
||||
titles := eg["titles"].([]any)
|
||||
var lastTerm obj
|
||||
for i, raw := range titles {
|
||||
ti := raw.(obj)
|
||||
id, _ := ti["id"].(string)
|
||||
if id == "" || ids[id] {
|
||||
t.Errorf("titles[%d]: missing or duplicate id %q", i, id)
|
||||
}
|
||||
ids[id] = true
|
||||
|
||||
title, _ := ti["title"].(obj)
|
||||
if s, _ := title["en"].(string); strings.TrimSpace(s) == "" {
|
||||
t.Errorf("%s: title.en is empty", id)
|
||||
}
|
||||
fr, _ := title["fr"].(obj)
|
||||
for _, form := range []string{"he", "she", "they"} {
|
||||
if s, _ := fr[form].(string); strings.TrimSpace(s) == "" {
|
||||
t.Errorf("%s: title.fr.%s is empty", id, form)
|
||||
}
|
||||
}
|
||||
if !isLocalized(ti["verdict"]) {
|
||||
t.Errorf("%s: verdict needs non-empty en and fr", id)
|
||||
}
|
||||
checkTokens(t, id+".verdict", ti["verdict"])
|
||||
|
||||
cond, hasCond := ti["conditions"].(obj)
|
||||
for _, bound := range []string{"min", "max"} {
|
||||
m, _ := cond[bound].(obj)
|
||||
for k := range m {
|
||||
if !metrics[k] {
|
||||
t.Errorf("%s.conditions.%s: unknown metric %q", id, bound, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if end, ok := ti["over"].(string); ok {
|
||||
if !set(earlyEnds)[end] {
|
||||
t.Errorf("%s: unknown over %q", id, end)
|
||||
}
|
||||
perEnd[end]++
|
||||
if hasCond {
|
||||
t.Errorf("%s: titles for an early end take no conditions", id)
|
||||
}
|
||||
} else {
|
||||
lastTerm = ti
|
||||
}
|
||||
}
|
||||
for _, end := range earlyEnds {
|
||||
if perEnd[end] != 1 {
|
||||
t.Errorf("early end %q needs exactly one title, has %d", end, perEnd[end])
|
||||
}
|
||||
}
|
||||
if _, has := lastTerm["conditions"]; has || lastTerm == nil {
|
||||
t.Errorf("the last term title must have no conditions so every term gets one")
|
||||
}
|
||||
|
||||
// Balance sections the review and the game-over rules rely on.
|
||||
g := bal["gameOver"].(obj)
|
||||
sameKeys(t, "balance.gameOver", g, []string{"default", "impeachment", "coup", "revolution"})
|
||||
sc := bal["score"].(obj)
|
||||
valid := set(append(append([]string{}, indIDs...), "calm"))
|
||||
for k := range sc["weights"].(obj) {
|
||||
if !valid[k] {
|
||||
t.Errorf("score.weights: unknown key %q", k)
|
||||
}
|
||||
}
|
||||
grades := sc["grades"].([]any)
|
||||
prev := 101.0
|
||||
for _, raw := range grades {
|
||||
pair := raw.([]any)
|
||||
min := pair[0].(float64)
|
||||
if min >= prev {
|
||||
t.Errorf("score.grades must be in strictly descending order of threshold")
|
||||
}
|
||||
prev = min
|
||||
}
|
||||
if prev != 0 {
|
||||
t.Errorf("the last grade must have threshold 0 so every score gets a grade")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"countrygame/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
// Used by the compose healthcheck: the distroless image has no curl.
|
||||
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
|
||||
resp, err := http.Get("http://127.0.0.1:" + port + "/healthz")
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
root, err := fs.Sub(web.Files, ".")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
mux.Handle("/", noCache(http.FileServer(http.FS(root))))
|
||||
|
||||
log.Printf("country game listening on :%s", port)
|
||||
log.Fatal(http.ListenAndServe(":"+port, mux))
|
||||
}
|
||||
|
||||
// noCache keeps iteration simple: data and scripts are always revalidated.
|
||||
func noCache(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"countrygame/web"
|
||||
)
|
||||
|
||||
var placeholder = regexp.MustCompile(`\{(\w+)\}`)
|
||||
|
||||
func placeholders(s string) string {
|
||||
var found []string
|
||||
for _, m := range placeholder.FindAllStringSubmatch(s, -1) {
|
||||
found = append(found, m[1])
|
||||
}
|
||||
sort.Strings(found)
|
||||
return strings.Join(found, ",")
|
||||
}
|
||||
|
||||
// English and French must offer exactly the same strings with the same {placeholders}.
|
||||
func TestI18nParity(t *testing.T) {
|
||||
en := load(t, "js/i18n/en.json")
|
||||
fr := load(t, "js/i18n/fr.json")
|
||||
|
||||
for k, v := range en {
|
||||
other, ok := fr[k]
|
||||
if !ok {
|
||||
t.Errorf("fr.json is missing %q", k)
|
||||
continue
|
||||
}
|
||||
enText, _ := v.(string)
|
||||
frText, _ := other.(string)
|
||||
if strings.TrimSpace(enText) == "" || strings.TrimSpace(frText) == "" {
|
||||
t.Errorf("%q has an empty string in en or fr", k)
|
||||
}
|
||||
if placeholders(enText) != placeholders(frText) {
|
||||
t.Errorf("%q: placeholders differ (en %q, fr %q)", k, placeholders(enText), placeholders(frText))
|
||||
}
|
||||
}
|
||||
for k := range fr {
|
||||
if _, ok := en[k]; !ok {
|
||||
t.Errorf("en.json is missing %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every scenario in the index exists, and every scenario file is in the index.
|
||||
func TestScenarioIndex(t *testing.T) {
|
||||
index := load(t, "data/scenarios/index.json")
|
||||
listed := map[string]bool{}
|
||||
for _, id := range index["scenarios"].([]any) {
|
||||
listed[id.(string)] = true
|
||||
sc := load(t, "data/scenarios/"+id.(string)+".json")
|
||||
if sc["id"] != id {
|
||||
t.Errorf("scenarios/%s.json has id %v", id, sc["id"])
|
||||
}
|
||||
if !isLocalized(sc["name"]) {
|
||||
t.Errorf("scenario %s needs a name in en and fr", id)
|
||||
}
|
||||
}
|
||||
if len(listed) == 0 {
|
||||
t.Error("scenarios/index.json lists no scenario")
|
||||
}
|
||||
entries, err := web.Files.ReadDir("data/scenarios")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
id := strings.TrimSuffix(e.Name(), ".json")
|
||||
if e.Name() != "index.json" && !listed[id] {
|
||||
t.Errorf("scenarios/%s is not listed in scenarios/index.json", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user