engine: realistic config bounds, enforced and served

ConfigBounds (students 10-500, edgesPerNode 1-8, forwardProb 0-0.9,
triangleProb 0-1) caps what RunScenario accepts: a student does not
keep 150 close friendships and a guaranteed forward is not a real
base rate. numEducated gains its missing upper limit (numStudents).
Bounds-side validation also protects the public server from absurd
numStudents values. GET /api/config/default now returns
{config, bounds} so the frontend can drive its controls from the
same numbers; the frontend does not call it yet, so this commit
changes no UI behaviour.
This commit is contained in:
Justin Visser 2026-06-11 15:54:07 +02:00
parent 232faf892e
commit bd890384e5
7 changed files with 194 additions and 15 deletions

View file

@ -0,0 +1,67 @@
package engine
import (
"strings"
"testing"
)
// Bounds are realism limits agreed 2026-06-11 (e.g. a student does not
// have 150 close friendships, and a guaranteed forward is not a real
// base rate), enforced engine-side so the public API is covered too.
func TestConfigBoundsContainDefaultConfig(t *testing.T) {
if err := ValidateConfig(DefaultConfig()); err != nil {
t.Fatalf("ValidateConfig(DefaultConfig()) = %v, want nil", err)
}
}
func TestValidateConfigRejectsOutOfBounds(t *testing.T) {
cases := []struct {
name string
mutate func(*Config)
wantMention string // the offending field's JSON name, for the UI's inline message
}{
{"too few students", func(c *Config) { c.NumStudents = 9 }, "numStudents"},
{"too many students", func(c *Config) { c.NumStudents = 501 }, "numStudents"},
{"no friendships", func(c *Config) { c.EdgesPerNode = 0 }, "edgesPerNode"},
{"implausibly many friendships", func(c *Config) { c.EdgesPerNode = 9 }, "edgesPerNode"},
{"negative triangle probability", func(c *Config) { c.TriangleProb = -0.1 }, "triangleProb"},
{"triangle probability above one", func(c *Config) { c.TriangleProb = 1.1 }, "triangleProb"},
{"negative forward probability", func(c *Config) { c.ForwardProb = -0.1 }, "forwardProb"},
{"guaranteed forwarding", func(c *Config) { c.ForwardProb = 0.95 }, "forwardProb"},
{"negative education budget", func(c *Config) { c.NumEducated = -1 }, "numEducated"},
{"educating more students than exist", func(c *Config) { c.NumEducated = 121 }, "numEducated"},
{"negative origin", func(c *Config) { c.Origin = -1 }, "origin"},
{"origin beyond the last student", func(c *Config) { c.Origin = 120 }, "origin"},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
config := DefaultConfig()
testCase.mutate(&config)
err := ValidateConfig(config)
if err == nil {
t.Fatalf("ValidateConfig accepted %+v, want error", config)
}
if !strings.Contains(err.Error(), testCase.wantMention) {
t.Errorf("error %q does not name field %q", err, testCase.wantMention)
}
if _, err := RunScenario(config, StrategyNone); err == nil {
t.Error("RunScenario accepted the same config, want error")
}
})
}
}
func TestValidateConfigAcceptsBoundaryValues(t *testing.T) {
config := DefaultConfig()
bounds := ConfigBounds()
config.NumStudents = bounds.NumStudents.Max
config.EdgesPerNode = bounds.EdgesPerNode.Max
config.ForwardProb = bounds.ForwardProb.Max
config.TriangleProb = bounds.TriangleProb.Max
config.NumEducated = config.NumStudents
config.Origin = config.NumStudents - 1
if err := ValidateConfig(config); err != nil {
t.Fatalf("ValidateConfig at the upper bounds = %v, want nil", err)
}
}