ui/phase-19: legacy parser learns Your Groups / Your Fleets / Incoming Groups

The parity rule from ui/PLAN.md says every UI phase that decodes a
new Report field must extend the legacy converter in lockstep.
Phase 19 brings ship groups (LocalGroup / OtherGroup /
UnidentifiedGroup / IncomingGroup) and LocalFleet onto the wire-
compatible UI surface; this commit teaches
tools/local-dev/legacy-report to populate the three sections that
exist in the legacy text format:

  - "Your Groups" → []LocalGroup. Cargo type, load, fleet name,
    state, on-planet vs hyperspace position (origin / range) all
    decoded; LocalGroup.ID is synthesised deterministically from
    the per-report group index so re-running the converter
    produces byte-identical JSON. Speed is left zero — the legacy
    table doesn't expose it.
  - "Your Fleets" → []LocalFleet. Origin / range / state mirror
    the row layout used by Killer / Tancordia variants; gplus's
    state-less rows still resolve.
  - "Incoming Groups" → []IncomingGroup. Origin / destination
    names — and `#NN` by-id references — resolve against the
    parsed planet tables. Because the section can land before
    "Your Planets" in some engines, group / fleet / incoming rows
    are buffered and resolved in `parser.finish` after every
    planet is known.

Battles, OtherGroup (only ever in battle rosters), and
UnidentifiedGroup stay out of scope — README.md spells out what
remains not-derivable.

Adds Killer031–033 / TSERCON_Z032–033 / Tancordia036–039 fixtures
to the dg directory and exercises three of them through new
TestParseDg{Killer031,Tancordia037,KNNTS041} smoke tests, plus
inline tests for each new section parser. Drops the stale
KNNTS039.json artefact left over from Phase 18 development.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 13:23:17 +02:00
parent 132ed4e0db
commit 8839f46c25
14 changed files with 42416 additions and 6327 deletions
+47 -10
View File
@@ -60,22 +60,47 @@ already decodes from server responses
| `UninhabitedPlanet[]` | `Uninhabited Planets` | | `UninhabitedPlanet[]` | `Uninhabited Planets` |
| `UnidentifiedPlanet[]`| `Unidentified Planets` | | `UnidentifiedPlanet[]`| `Unidentified Planets` |
| `LocalShipClass[]` | `Your Ship Types` | | `LocalShipClass[]` | `Your Ship Types` |
| `LocalGroup[]` | `Your Groups` (Phase 19) |
| `LocalFleet[]` | `Your Fleets` (Phase 19) |
| `IncomingGroup[]` | `Incoming Groups` (Phase 19) |
Players whose name in the legacy file ends with `_RIP` are emitted with Players whose name in the legacy file ends with `_RIP` are emitted with
the suffix stripped and `Extinct: true`. the suffix stripped and `Extinct: true`.
`LocalGroup.ID` is synthesised deterministically from the per-report
group index via `uuid.NewSHA1`, so re-running the converter on the same
input file yields byte-identical JSON.
`LocalGroup.Speed` is left at zero — the legacy "Your Groups" table does
not expose ship speed; the UI can derive it from `pkg/calc.Speed` if
ever required.
Origin / Range names that don't resolve against the parsed planet
tables (foreign-only knowledge the local player lacks) cause the entire
group / fleet / incoming row to be dropped — preferable to fabricating
a destination.
## Skipped sections (today) ## Skipped sections (today)
These exist in legacy reports but have no UI decoder yet, so the These exist in legacy reports but either have no UI decoder yet or
parser ignores them. Each becomes in-scope as soon as its UI phase cannot be derived from the legacy text format at all. Each becomes
lands (see "Adding a new field" below). in-scope as soon as its UI phase lands (see "Adding a new field"
below).
- Foreign / other ship types (`<Race> Ship Types`) - Foreign / other ship types (`<Race> Ship Types`)
- Sciences, both local (`Your Sciences`) and foreign (`<Race> Sciences`) - Sciences, both local (`Your Sciences`) and foreign (`<Race> Sciences`)
- Battles (`Battle at (#N) Name`, `Battle Protocol`) - Battles (`Battle at (#N) Name`, `Battle Protocol`) — battle rosters
inside these blocks carry minimal columns (no origin / range /
destination) and are intentionally skipped: parsing them would
produce mostly-empty `OtherGroup` records that drift away from the
typed contract.
- Bombings (`Bombings`) - Bombings (`Bombings`)
- Approaching / foreign groups (`Approaching Groups`, `<Race> Groups`)
- Ships in production (`Ships In Production`) - Ships in production (`Ships In Production`)
- `OtherGroup[]` — no top-level legacy section. Foreign groups appear
only inside battle rosters (see above), with stripped columns; the
synthetic JSON emits `otherGroup: []`.
- `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON
emits `unidentifiedGroup: []`.
- `OtherShipClass[]` — present in legacy as `<Race> Ship Types`, but
no UI decoder yet; synthetic JSON emits `otherShipClass: []`.
- Cargo routes — no dedicated section in the legacy text format; the - Cargo routes — no dedicated section in the legacy text format; the
synthetic JSON emits `route: []`. The UI's overlay path synthetic JSON emits `route: []`. The UI's overlay path
(`applyOrderOverlay`) supports running on top of an empty `routes`. (`applyOrderOverlay`) supports running on top of an empty `routes`.
@@ -114,8 +139,20 @@ go test ./tools/local-dev/legacy-report/...
``` ```
Inline fixtures exercise the per-section row parsers; smoke tests Inline fixtures exercise the per-section row parsers; smoke tests
parse the real `dg/KNNTS039.REP` and `gplus/40.REP` and assert parse the real fixtures under `tools/local-dev/reports/dg/` and
top-level counts (number of planets, players, extinct races, ship `tools/local-dev/reports/gplus/` and assert top-level counts. The
classes). Field-level fidelity is the inline tests' responsibility; current smoke set spans:
the smoke tests catch regressions where a refactor of the section
classifier silently drops a whole table. - **dg/KNNTS039041** — KnightErrants saga; `041` is the only one
with `Incoming Groups`, exercising deferred name resolution.
- **dg/Killer031** — Killer engine variant with two `Your Fleets`
entries (`Fl1`, `F2`).
- **dg/Tancordia037** — the richest fixture: 311 local groups in
30 fleets, two incoming groups, "Incoming Groups" landing before
"Your Planets".
- **gplus/40.REP** — gplus variant; tabs in headers, pseudo-cyrillic
ship class names, single fleet, ten incoming groups.
Field-level fidelity is the inline tests' responsibility; the smoke
tests catch regressions where a refactor of the section classifier
silently drops a whole table.
+1 -1
View File
@@ -4,6 +4,6 @@ go 1.26.0
require galaxy/model v0.0.0 require galaxy/model v0.0.0
require github.com/google/uuid v1.6.0 // indirect require github.com/google/uuid v1.6.0
replace galaxy/model => ../../../pkg/model replace galaxy/model => ../../../pkg/model
+314 -1
View File
@@ -3,7 +3,8 @@
// //
// Scope is intentionally narrow: only the fields the UI client decodes // Scope is intentionally narrow: only the fields the UI client decodes
// from server reports today (planets, players, own ship classes, // from server reports today (planets, players, own ship classes,
// header data). Everything else in the legacy file is silently // header data, plus — added in Phase 19 — own ship groups, own fleets
// and incoming groups). Everything else in the legacy file is silently
// skipped. The synthetic-report parity rule in ui/PLAN.md is the // skipped. The synthetic-report parity rule in ui/PLAN.md is the
// source of truth for when to extend this parser; the package's // source of truth for when to extend this parser; the package's
// README.md tracks every legacy section that could be wired up later // README.md tracks every legacy section that could be wired up later
@@ -18,6 +19,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/google/uuid"
"galaxy/model/report" "galaxy/model/report"
) )
@@ -51,6 +54,9 @@ const (
sectionUninhabitedPlanets sectionUninhabitedPlanets
sectionUnidentifiedPlanets sectionUnidentifiedPlanets
sectionYourShipTypes sectionYourShipTypes
sectionYourGroups
sectionYourFleets
sectionIncomingGroups
) )
type parser struct { type parser struct {
@@ -60,6 +66,50 @@ type parser struct {
skipHeader bool skipHeader bool
sawHeader bool sawHeader bool
sawSize bool sawSize bool
// Group/fleet/incoming rows are buffered during the scan because
// they carry destination/origin planet names that may resolve
// against the planet tables only after the whole file has been
// read — "Incoming Groups" can appear before "Your Planets" in
// some engine variants.
pendingGroups []pendingGroup
pendingFleets []pendingFleet
pendingIncomings []pendingIncoming
}
type pendingGroup struct {
g uint
number uint
class string
drive float64
weapons float64
shields float64
cargoTech float64
cargoType string
load float64
destinationName string
originName string // empty when "-"
rangeStr string // empty when "-"
mass float64
fleet string // empty when "-"
state string
}
type pendingFleet struct {
name string
groups uint
destinationName string
originName string // empty when "-"
rangeStr string // empty when "-"
state string
}
type pendingIncoming struct {
originName string
destinationName string
distance float64
speed float64
mass float64
} }
func newParser() *parser { func newParser() *parser {
@@ -121,6 +171,12 @@ func (p *parser) handle(line string) error {
p.parseUnidentifiedPlanet(fields) p.parseUnidentifiedPlanet(fields)
case sectionYourShipTypes: case sectionYourShipTypes:
p.parseShipClass(fields) p.parseShipClass(fields)
case sectionYourGroups:
p.parseYourGroup(fields)
case sectionYourFleets:
p.parseYourFleet(fields)
case sectionIncomingGroups:
p.parseIncomingGroup(fields)
} }
return nil return nil
} }
@@ -129,6 +185,7 @@ func (p *parser) finish() (report.Report, error) {
if !p.sawHeader { if !p.sawHeader {
return report.Report{}, errors.New("legacyreport: missing report header line") return report.Report{}, errors.New("legacyreport: missing report header line")
} }
p.resolvePending()
return p.rep, nil return p.rep, nil
} }
@@ -190,6 +247,12 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
return sectionYourPlanets, "", true return sectionYourPlanets, "", true
case "Your Ship Types": case "Your Ship Types":
return sectionYourShipTypes, "", true return sectionYourShipTypes, "", true
case "Your Groups":
return sectionYourGroups, "", true
case "Your Fleets":
return sectionYourFleets, "", true
case "Incoming Groups":
return sectionIncomingGroups, "", true
case "Uninhabited Planets": case "Uninhabited Planets":
return sectionUninhabitedPlanets, "", true return sectionUninhabitedPlanets, "", true
case "Unidentified Planets": case "Unidentified Planets":
@@ -432,6 +495,256 @@ func (p *parser) parseShipClass(fields []string) {
}) })
} }
// parseYourGroup buffers a "Your Groups" row for post-processing in
// [parser.finish]. Columns (16 fields, last is state):
//
// G # T D W S C T Q D F R P M L state
//
// where the second D is the destination planet name, F is the origin
// planet name (or "-" for on-planet groups), R is the remaining
// distance, and L is the fleet membership (or "-").
func (p *parser) parseYourGroup(fields []string) {
if len(fields) < 16 {
return
}
g, err := strconv.ParseUint(fields[0], 10, 32)
if err != nil {
return
}
number, err := strconv.ParseUint(fields[1], 10, 32)
if err != nil {
return
}
drive, _ := parseFloat(fields[3])
weapons, _ := parseFloat(fields[4])
shields, _ := parseFloat(fields[5])
cargoTech, _ := parseFloat(fields[6])
load, _ := parseFloat(fields[8])
mass, _ := parseFloat(fields[13])
p.pendingGroups = append(p.pendingGroups, pendingGroup{
g: uint(g),
number: uint(number),
class: fields[2],
drive: drive,
weapons: weapons,
shields: shields,
cargoTech: cargoTech,
cargoType: fields[7],
load: load,
destinationName: fields[9],
originName: dashOrEmpty(fields[10]),
rangeStr: dashOrEmpty(fields[11]),
mass: mass,
fleet: dashOrEmpty(fields[14]),
state: fields[15],
})
}
// parseYourFleet buffers a "Your Fleets" row. Columns vary by engine
// — Killer/Tancordia ship 8 fields including a trailing state token,
// gplus emits 7 (no state). Layout:
//
// # N G D F R P [state]
//
// where D is the destination planet name, F is the origin planet
// name (or "-"), and R is the remaining distance.
func (p *parser) parseYourFleet(fields []string) {
if len(fields) < 7 {
return
}
groups, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil {
return
}
state := ""
if len(fields) >= 8 {
state = fields[7]
}
p.pendingFleets = append(p.pendingFleets, pendingFleet{
name: fields[1],
groups: uint(groups),
destinationName: fields[3],
originName: dashOrEmpty(fields[4]),
rangeStr: dashOrEmpty(fields[5]),
state: state,
})
}
// parseIncomingGroup buffers an "Incoming Groups" row. Columns:
//
// O D R S M
func (p *parser) parseIncomingGroup(fields []string) {
if len(fields) < 5 {
return
}
distance, err := parseFloat(fields[2])
if err != nil {
return
}
speed, _ := parseFloat(fields[3])
mass, _ := parseFloat(fields[4])
p.pendingIncomings = append(p.pendingIncomings, pendingIncoming{
originName: fields[0],
destinationName: fields[1],
distance: distance,
speed: speed,
mass: mass,
})
}
// resolvePending walks the buffered group/fleet/incoming rows and
// emits the typed entries on the report. Names that resolve neither
// against the parsed planet tables nor the "#NN" id syntax are
// skipped silently — they typically point at planets not visible to
// the local player. Stable LocalGroup IDs are derived from the
// per-report group index so repeated conversions of the same file
// produce byte-identical JSON.
func (p *parser) resolvePending() {
for _, pg := range p.pendingGroups {
dest, ok := p.lookupPlanetNumber(pg.destinationName)
if !ok {
continue
}
var origin *uint
if pg.originName != "" {
if n, ok := p.lookupPlanetNumber(pg.originName); ok {
v := n
origin = &v
}
}
var rng *report.Float
if pg.rangeStr != "" {
if r, err := parseFloat(pg.rangeStr); err == nil {
v := report.F(r)
rng = &v
}
}
var fleet *string
if pg.fleet != "" {
f := pg.fleet
fleet = &f
}
tech := map[string]report.Float{
"drive": report.F(pg.drive),
"weapons": report.F(pg.weapons),
"shields": report.F(pg.shields),
"cargo": report.F(pg.cargoTech),
}
p.rep.LocalGroup = append(p.rep.LocalGroup, report.LocalGroup{
OtherGroup: report.OtherGroup{
Number: pg.number,
Class: pg.class,
Tech: tech,
Cargo: pg.cargoType,
Load: report.F(pg.load),
Destination: dest,
Origin: origin,
Range: rng,
Mass: report.F(pg.mass),
},
ID: syntheticGroupID(pg.g),
State: pg.state,
Fleet: fleet,
})
}
for _, pf := range p.pendingFleets {
dest, ok := p.lookupPlanetNumber(pf.destinationName)
if !ok {
continue
}
var origin *uint
if pf.originName != "" {
if n, ok := p.lookupPlanetNumber(pf.originName); ok {
v := n
origin = &v
}
}
var rng *report.Float
if pf.rangeStr != "" {
if r, err := parseFloat(pf.rangeStr); err == nil {
v := report.F(r)
rng = &v
}
}
p.rep.LocalFleet = append(p.rep.LocalFleet, report.LocalFleet{
Name: pf.name,
Groups: pf.groups,
Destination: dest,
Origin: origin,
Range: rng,
State: pf.state,
})
}
for _, pi := range p.pendingIncomings {
origin, ok := p.lookupPlanetNumber(pi.originName)
if !ok {
continue
}
dest, ok := p.lookupPlanetNumber(pi.destinationName)
if !ok {
continue
}
p.rep.IncomingGroup = append(p.rep.IncomingGroup, report.IncomingGroup{
Origin: origin,
Destination: dest,
Distance: report.F(pi.distance),
Speed: report.F(pi.speed),
Mass: report.F(pi.mass),
})
}
}
// lookupPlanetNumber resolves a legacy planet reference — either a
// "#NN" by-id form or a planet name from one of the parsed planet
// tables. Returns false when the planet is not visible to the local
// player (the caller drops the row).
func (p *parser) lookupPlanetNumber(s string) (uint, bool) {
if strings.HasPrefix(s, "#") {
n, err := strconv.ParseUint(s[1:], 10, 32)
if err != nil {
return 0, false
}
return uint(n), true
}
for _, lp := range p.rep.LocalPlanet {
if lp.Name == s {
return lp.Number, true
}
}
for _, op := range p.rep.OtherPlanet {
if op.Name == s {
return op.Number, true
}
}
for _, up := range p.rep.UninhabitedPlanet {
if up.Name == s {
return up.Number, true
}
}
return 0, false
}
// syntheticGroupNamespace seeds [uuid.NewSHA1] for the per-report
// group-index → UUID derivation. The constant value is arbitrary;
// any UUID would work as long as it stays stable across releases so
// re-running the converter on the same input file yields the same
// LocalGroup IDs.
var syntheticGroupNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000001")
func syntheticGroupID(g uint) uuid.UUID {
return uuid.NewSHA1(syntheticGroupNamespace, fmt.Appendf(nil, "legacy-local-group-%d", g))
}
func dashOrEmpty(s string) string {
if s == "-" {
return ""
}
return s
}
func parseFloat(s string) (float64, error) { func parseFloat(s string) (float64, error) {
return strconv.ParseFloat(s, 64) return strconv.ParseFloat(s, 64)
} }
+299 -110
View File
@@ -244,133 +244,322 @@ func TestParseSkipsBattlesAndBombings(t *testing.T) {
} }
} }
// TestParseYourGroups exercises the local-group section. Two rows
// cover the on-planet ("In_Orbit", origin "-") and in-space ("In_Space",
// origin name + range) variants, plus a cargo-loaded row to assert the
// load-type / load-quantity columns are wired through. A planet
// table is mounted upfront so destination/origin name resolution
// has something to bind against.
func TestParseYourGroups(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Your Planets",
"",
" # X Y N S P I R P $ M C L",
" 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Capital 0.00 0.68 88.78 1000.00",
" 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
"",
"Your Groups",
"",
" G # T D W S C T Q D F R P M L",
" 0 2 Frontier 5.05 0.00 0.00 1.0 - 0 Castle - - 92.84 12.37 - In_Orbit",
" 1 1 Bow105 11.19 4.76 7.09 1.0 COL 1 Castle - - 111.9 149.54 - In_Orbit",
" 2 1 Tormoz 11.19 0.00 0.00 1.0 CAP 4 North Castle 7.5 60.66 49.50 - In_Space",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalGroup), 3; got != want {
t.Fatalf("len(LocalGroup) = %d, want %d", got, want)
}
first := rep.LocalGroup[0]
if first.Number != 2 || first.Class != "Frontier" {
t.Errorf("first group = (%d, %q), want (2, Frontier)", first.Number, first.Class)
}
if got, want := float64(first.Tech["drive"]), 5.05; got != want {
t.Errorf("first.Tech[drive] = %v, want %v", got, want)
}
if first.Destination != 17 {
t.Errorf("first.Destination = %d, want 17 (Castle resolved)", first.Destination)
}
if first.Origin != nil || first.Range != nil {
t.Errorf("first.{Origin,Range} = (%v, %v), want both nil for In_Orbit", first.Origin, first.Range)
}
if first.State != "In_Orbit" {
t.Errorf("first.State = %q, want In_Orbit", first.State)
}
if first.Fleet != nil {
t.Errorf("first.Fleet = %v, want nil for `-`", first.Fleet)
}
loaded := rep.LocalGroup[1]
if loaded.Cargo != "COL" || float64(loaded.Load) != 1.0 {
t.Errorf("loaded cargo/load = (%q, %v), want (COL, 1.0)", loaded.Cargo, float64(loaded.Load))
}
flying := rep.LocalGroup[2]
if flying.State != "In_Space" {
t.Errorf("flying.State = %q, want In_Space", flying.State)
}
if flying.Origin == nil || *flying.Origin != 17 {
t.Errorf("flying.Origin = %v, want 17 (Castle)", flying.Origin)
}
if flying.Range == nil || float64(*flying.Range) != 7.5 {
t.Errorf("flying.Range = %v, want 7.5", flying.Range)
}
if flying.Destination != 87 {
t.Errorf("flying.Destination = %d, want 87 (North)", flying.Destination)
}
}
func TestParseYourFleets(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Your Planets",
"",
" # X Y N S P I R P $ M C L",
" 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Capital 0.00 0.68 88.78 1000.00",
" 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
"",
"Your Fleets",
"",
" # N G D F R P",
" 0 Fast 3 Castle - - 45 In_Orbit",
" 1 Far 2 North Castle 4.50 20 In_Space",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.LocalFleet), 2; got != want {
t.Fatalf("len(LocalFleet) = %d, want %d", got, want)
}
fast := rep.LocalFleet[0]
if fast.Name != "Fast" || fast.Groups != 3 || fast.Destination != 17 {
t.Errorf("fast = %+v, want Name=Fast Groups=3 Destination=17", fast)
}
if fast.State != "In_Orbit" {
t.Errorf("fast.State = %q, want In_Orbit", fast.State)
}
if fast.Origin != nil || fast.Range != nil {
t.Errorf("fast.{Origin,Range} = (%v, %v), want both nil", fast.Origin, fast.Range)
}
far := rep.LocalFleet[1]
if far.Origin == nil || *far.Origin != 17 {
t.Errorf("far.Origin = %v, want 17 (Castle)", far.Origin)
}
if far.Range == nil || float64(*far.Range) != 4.5 {
t.Errorf("far.Range = %v, want 4.5", far.Range)
}
}
func TestParseIncomingGroups(t *testing.T) {
// Origin is a `#NN` by-id reference; destination resolves
// against the local planet table that was parsed earlier in
// this synthetic file. The order is intentionally swapped — in
// real legacy reports "Incoming Groups" can land before
// "Your Planets", which is why the parser buffers rows for
// post-processing.
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Incoming Groups",
"",
"O D R S M",
"#98 Castle 136.16 190 1",
"North Castle 42.12 99 2",
"",
"Your Planets",
"",
" # X Y N S P I R P $ M C L",
" 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Capital 0.00 0.68 88.78 1000.00",
" 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
"",
}, "\n")
rep, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(rep.IncomingGroup), 2; got != want {
t.Fatalf("len(IncomingGroup) = %d, want %d", got, want)
}
a := rep.IncomingGroup[0]
if a.Origin != 98 || a.Destination != 17 {
t.Errorf("a (Origin, Destination) = (%d, %d), want (98, 17)", a.Origin, a.Destination)
}
if float64(a.Distance) != 136.16 || float64(a.Speed) != 190 {
t.Errorf("a (Distance, Speed) = (%v, %v), want (136.16, 190)", float64(a.Distance), float64(a.Speed))
}
b := rep.IncomingGroup[1]
if b.Origin != 87 || b.Destination != 17 {
t.Errorf("b (Origin, Destination) = (%d, %d), want (87, 17)", b.Origin, b.Destination)
}
}
// --- smoke tests -----------------------------------------------------
type smokeWant struct {
race string
turn uint
mapW, mapH, planetCount uint32
voteFor string
votes float64
players, extinct, local, other int
uninhabited, unidentified, shipClasses int
localGroups, localFleets, incomingGroups int
}
func runSmoke(t *testing.T, path string, want smokeWant) {
t.Helper()
rep, err := parseFile(t, path)
if err != nil {
if os.IsNotExist(err) {
t.Skipf("legacy report fixture missing: %s", path)
}
t.Fatalf("Parse %s: %v", path, err)
}
if rep.Race != want.race || rep.Turn != want.turn {
t.Errorf("(race, turn) = (%q, %d), want (%q, %d)", rep.Race, rep.Turn, want.race, want.turn)
}
if rep.Width != want.mapW || rep.Height != want.mapH || rep.PlanetCount != want.planetCount {
t.Errorf("(W, H, planets) = (%d, %d, %d), want (%d, %d, %d)",
rep.Width, rep.Height, rep.PlanetCount, want.mapW, want.mapH, want.planetCount)
}
if want.voteFor != "" {
if rep.VoteFor != want.voteFor || float64(rep.Votes) != want.votes {
t.Errorf("(voteFor, votes) = (%q, %v), want (%q, %v)",
rep.VoteFor, float64(rep.Votes), want.voteFor, want.votes)
}
}
extinct := 0
for _, pl := range rep.Player {
if pl.Extinct {
extinct++
}
}
checks := []struct {
name string
got int
want int
}{
{"Player", len(rep.Player), want.players},
{"extinct", extinct, want.extinct},
{"LocalPlanet", len(rep.LocalPlanet), want.local},
{"OtherPlanet", len(rep.OtherPlanet), want.other},
{"UninhabitedPlanet", len(rep.UninhabitedPlanet), want.uninhabited},
{"UnidentifiedPlanet", len(rep.UnidentifiedPlanet), want.unidentified},
{"LocalShipClass", len(rep.LocalShipClass), want.shipClasses},
{"LocalGroup", len(rep.LocalGroup), want.localGroups},
{"LocalFleet", len(rep.LocalFleet), want.localFleets},
{"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups},
}
for _, c := range checks {
if c.got != c.want {
t.Errorf("%s = %d, want %d", c.name, c.got, c.want)
}
}
}
// TestParseDgKNNTS039 is a smoke test: the parser must produce // TestParseDgKNNTS039 is a smoke test: the parser must produce
// stable top-line counts from the real dg/KNNTS039.REP fixture. // stable top-line counts from the real dg/KNNTS039.REP fixture.
// Field-level fidelity is asserted in the unit tests above; this // Field-level fidelity is asserted in the unit tests above; this
// test catches regressions where a section-classifier change // test catches regressions where a section-classifier change
// silently drops half the data. // silently drops half the data.
func TestParseDgKNNTS039(t *testing.T) { func TestParseDgKNNTS039(t *testing.T) {
const path = "../reports/dg/KNNTS039.REP" runSmoke(t, "../reports/dg/KNNTS039.REP", smokeWant{
rep, err := parseFile(t, path)
if err != nil {
if os.IsNotExist(err) {
t.Skipf("legacy report fixture missing: %s", path)
}
t.Fatalf("Parse %s: %v", path, err)
}
want := struct {
race string
turn uint
mapW, mapH, planetCount uint32
voteFor string
votes float64
players, extinct, local, other, uninhabited, unidentified, shipClasses int
}{
race: "KnightErrants", turn: 39, race: "KnightErrants", turn: 39,
mapW: 800, mapH: 800, planetCount: 700, mapW: 800, mapH: 800, planetCount: 700,
voteFor: "KnightErrants", votes: 16.02, voteFor: "KnightErrants", votes: 16.02,
players: 91, extinct: 49, players: 91, extinct: 49,
local: 22, other: 89, uninhabited: 17, unidentified: 572, local: 22, other: 89, uninhabited: 17, unidentified: 572,
shipClasses: 24, shipClasses: 24,
} localGroups: 171,
if rep.Race != want.race || rep.Turn != want.turn { localFleets: 0,
t.Errorf("(race, turn) = (%q, %d), want (%q, %d)", rep.Race, rep.Turn, want.race, want.turn) incomingGroups: 0,
} })
if rep.Width != want.mapW || rep.Height != want.mapH || rep.PlanetCount != want.planetCount {
t.Errorf("(W, H, planets) = (%d, %d, %d), want (%d, %d, %d)",
rep.Width, rep.Height, rep.PlanetCount, want.mapW, want.mapH, want.planetCount)
}
if rep.VoteFor != want.voteFor || float64(rep.Votes) != want.votes {
t.Errorf("(voteFor, votes) = (%q, %v), want (%q, %v)",
rep.VoteFor, float64(rep.Votes), want.voteFor, want.votes)
}
extinct := 0
for _, pl := range rep.Player {
if pl.Extinct {
extinct++
}
}
if got, exp := len(rep.Player), want.players; got != exp {
t.Errorf("len(Player) = %d, want %d", got, exp)
}
if extinct != want.extinct {
t.Errorf("extinct = %d, want %d", extinct, want.extinct)
}
if got, exp := len(rep.LocalPlanet), want.local; got != exp {
t.Errorf("len(LocalPlanet) = %d, want %d", got, exp)
}
if got, exp := len(rep.OtherPlanet), want.other; got != exp {
t.Errorf("len(OtherPlanet) = %d, want %d", got, exp)
}
if got, exp := len(rep.UninhabitedPlanet), want.uninhabited; got != exp {
t.Errorf("len(UninhabitedPlanet) = %d, want %d", got, exp)
}
if got, exp := len(rep.UnidentifiedPlanet), want.unidentified; got != exp {
t.Errorf("len(UnidentifiedPlanet) = %d, want %d", got, exp)
}
if got, exp := len(rep.LocalShipClass), want.shipClasses; got != exp {
t.Errorf("len(LocalShipClass) = %d, want %d", got, exp)
}
} }
// TestParseGplus40 mirrors TestParseDgKNNTS039 for the gplus engine func TestParseDgKNNTS040(t *testing.T) {
// fixture so the variant difference (tabs vs spaces in headers) is runSmoke(t, "../reports/dg/KNNTS040.REP", smokeWant{
// exercised on a real file. race: "KnightErrants", turn: 40,
mapW: 800, mapH: 800, planetCount: 700,
players: 91, extinct: 49,
local: 22, other: 93, uninhabited: 27, unidentified: 558,
shipClasses: 34,
localGroups: 207,
localFleets: 0,
incomingGroups: 0,
})
}
// TestParseDgKNNTS041 covers a turn with active "Incoming Groups"
// entries (12 rows) appearing before the "Your Planets" table —
// exercises the deferred name-resolution path in [parser.finish].
func TestParseDgKNNTS041(t *testing.T) {
runSmoke(t, "../reports/dg/KNNTS041.REP", smokeWant{
race: "KnightErrants", turn: 41,
mapW: 800, mapH: 800, planetCount: 700,
players: 91, extinct: 50,
local: 29, other: 103, uninhabited: 23, unidentified: 545,
shipClasses: 36,
localGroups: 285,
localFleets: 0,
incomingGroups: 12,
})
}
// TestParseGplus40 exercises the gplus engine variant (tabs in
// section headers, pseudo-cyrillic ASCII names) and a single fleet.
// gplus also sneaks "Incoming Groups" between sections.
func TestParseGplus40(t *testing.T) { func TestParseGplus40(t *testing.T) {
const path = "../reports/gplus/40.REP" runSmoke(t, "../reports/gplus/40.REP", smokeWant{
rep, err := parseFile(t, path)
if err != nil {
if os.IsNotExist(err) {
t.Skipf("legacy report fixture missing: %s", path)
}
t.Fatalf("Parse %s: %v", path, err)
}
want := struct {
race string
turn uint
mapW, mapH, planetCount uint32
players, extinct, local, other, uninhabited, unidentified, shipClasses int
}{
race: "MbI", turn: 40, race: "MbI", turn: 40,
mapW: 350, mapH: 350, planetCount: 300, mapW: 350, mapH: 350, planetCount: 300,
players: 26, extinct: 0, players: 26, extinct: 0,
local: 26, other: 116, uninhabited: 7, unidentified: 152, local: 26, other: 116, uninhabited: 7, unidentified: 151,
shipClasses: 56, shipClasses: 56,
} localGroups: 255,
if rep.Race != want.race || rep.Turn != want.turn { localFleets: 1,
t.Errorf("(race, turn) = (%q, %d), want (%q, %d)", rep.Race, rep.Turn, want.race, want.turn) incomingGroups: 10,
} })
if rep.Width != want.mapW || rep.Height != want.mapH || rep.PlanetCount != want.planetCount { }
t.Errorf("(W, H, planets) = (%d, %d, %d), want (%d, %d, %d)",
rep.Width, rep.Height, rep.PlanetCount, want.mapW, want.mapH, want.planetCount) // TestParseDgKiller031 exercises the Killer engine variant which
} // ships "Your Fleets" + "Your Groups" with the Fl1/F2 fleet
extinct := 0 // membership shape (no "Incoming Groups" this turn).
for _, pl := range rep.Player { func TestParseDgKiller031(t *testing.T) {
if pl.Extinct { runSmoke(t, "../reports/dg/Killer031.rep", smokeWant{
extinct++ race: "Killer", turn: 31,
} mapW: 250, mapH: 250, planetCount: 175,
} players: 25, extinct: 12,
if got, exp := len(rep.Player), want.players; got != exp { local: 18, other: 127, uninhabited: 20, unidentified: 10,
t.Errorf("len(Player) = %d, want %d", got, exp) shipClasses: 11,
} localGroups: 175,
if extinct != want.extinct { localFleets: 2,
t.Errorf("extinct = %d, want %d", extinct, want.extinct) incomingGroups: 0,
} })
if got, exp := len(rep.LocalPlanet), want.local; got != exp { }
t.Errorf("len(LocalPlanet) = %d, want %d", got, exp)
} // TestParseDgTancordia037 is the richest smoke fixture: it carries
if got, exp := len(rep.OtherPlanet), want.other; got != exp { // 311 local groups across 30 fleets, two incoming groups, and the
t.Errorf("len(OtherPlanet) = %d, want %d", got, exp) // "Incoming Groups" section appears before "Your Planets" (so the
} // deferred name resolution is exercised in production conditions).
if got, exp := len(rep.UninhabitedPlanet), want.uninhabited; got != exp { func TestParseDgTancordia037(t *testing.T) {
t.Errorf("len(UninhabitedPlanet) = %d, want %d", got, exp) runSmoke(t, "../reports/dg/Tancordia037.rep", smokeWant{
} race: "Tancordia", turn: 37,
if got, exp := len(rep.UnidentifiedPlanet), want.unidentified; got != exp { mapW: 210, mapH: 210, planetCount: 140,
t.Errorf("len(UnidentifiedPlanet) = %d, want %d", got, exp) players: 18, extinct: 7,
} local: 23, other: 62, uninhabited: 26, unidentified: 29,
if got, exp := len(rep.LocalShipClass), want.shipClasses; got != exp { shipClasses: 40,
t.Errorf("len(LocalShipClass) = %d, want %d", got, exp) localGroups: 311,
} localFleets: 30,
incomingGroups: 2,
})
} }
func parseFile(t *testing.T, rel string) (report.Report, error) { func parseFile(t *testing.T, rel string) (report.Report, error) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff