refactor cmd: thin main, testable run(io.Writer), AllStrategies in engine

The strategy list moves into the engine (AllStrategies), where the API
and frontend will read it too: one source of truth, per the handoff.
main shrinks to the standard Go shell pattern: all work happens in
run(out io.Writer) error; main only maps the error to stderr and the
exit code. Writing to an interface instead of stdout is what lets
main_test.go capture output in a bytes.Buffer.
errcheck flagged every unchecked Fprintf, so formatting became pure
Sprintf string building with one checked write at the end: nicer than
discarding four errors with '_, _ ='.
This commit is contained in:
Justin Visser 2026-06-10 12:39:03 +02:00
parent e73db0bdd4
commit 2a5b78cb9a
3 changed files with 63 additions and 20 deletions

View file

@ -5,31 +5,47 @@ package main
import (
"fmt"
"io"
"os"
"strings"
"github.com/JustinZeus/spreadlab/internal/engine"
)
// main stays a thin shell: the work lives in run, which takes its output
// as an io.Writer so tests can capture it.
func main() {
config := engine.DefaultConfig()
fmt.Printf("spreadlab: %d students, educate %d, forwarding probability %.2f\n",
config.NumStudents, config.NumEducated, config.ForwardProb)
fmt.Println("(illustrative, not validated)")
fmt.Println()
strategies := []engine.Strategy{
engine.StrategyNone,
engine.StrategyRandom,
engine.StrategyMostConnected,
}
for _, strategy := range strategies {
result, err := engine.RunScenario(config, strategy)
if err != nil {
if err := run(os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "spreadlab:", err)
os.Exit(1)
}
fmt.Printf("%-15s educated=%3d reached=%3d/%d (%2.0f%%) in %d rounds\n",
}
// run executes the three-scenario comparison in the default world and
// writes the table to out. Formatting is pure string building; the single
// write at the end is the only error to handle.
func run(out io.Writer) error {
config := engine.DefaultConfig()
lines := []string{
fmt.Sprintf("spreadlab: %d students, educate %d, forwarding probability %.2f",
config.NumStudents, config.NumEducated, config.ForwardProb),
"(illustrative, not validated)",
"",
}
for _, strategy := range engine.AllStrategies() {
result, err := engine.RunScenario(config, strategy)
if err != nil {
return err
}
lines = append(lines, formatResult(config, result))
}
_, err := fmt.Fprintln(out, strings.Join(lines, "\n"))
return err
}
// formatResult renders one scenario's outcome as a table row.
func formatResult(config engine.Config, result engine.Result) string {
return fmt.Sprintf("%-15s educated=%3d reached=%3d/%d (%2.0f%%) in %d rounds",
result.Strategy, len(result.Educated), result.NumReached,
config.NumStudents, result.ReachedPct, result.NumRounds)
}
}

View file

@ -0,0 +1,21 @@
package main
import (
"bytes"
"strings"
"testing"
)
// run writes to an io.Writer instead of straight to stdout precisely so
// this test can hand it a buffer and inspect the output.
func TestRunPrintsAllScenarios(t *testing.T) {
var output bytes.Buffer
if err := run(&output); err != nil {
t.Fatal(err)
}
for _, want := range []string{"none", "random", "most-connected", "illustrative"} {
if !strings.Contains(output.String(), want) {
t.Errorf("output is missing %q:\n%s", want, output.String())
}
}
}

View file

@ -11,6 +11,12 @@ const (
StrategyMostConnected Strategy = "most-connected"
)
// AllStrategies lists every strategy in display order. The CLI, and later
// the API and frontend, read this one list; nothing redefines it.
func AllStrategies() []Strategy {
return []Strategy{StrategyNone, StrategyRandom, StrategyMostConnected}
}
// Config fully describes one simulation world. It is the single source of
// truth for parameters: API and frontend types will be generated from the
// structs in this file, never redefined by hand. Identical configs produce