package legacyreport import ( "os" "path/filepath" "strings" "testing" "galaxy/model/report" ) // TestParseHeaderAndSize covers the standalone single-line preamble. func TestParseHeaderAndSize(t *testing.T) { in := strings.Join([]string{ " KnightErrants Report for Galaxy PLUS dg283 Turn 39 Thu Jul 06 09:01:16 2000", "", " Size: 800 Planets: 700 Players: 91", "", }, "\n") rep, err := Parse(strings.NewReader(in)) if err != nil { t.Fatalf("Parse: %v", err) } if got, want := rep.Race, "KnightErrants"; got != want { t.Errorf("Race = %q, want %q", got, want) } if got, want := rep.Turn, uint(39); got != want { t.Errorf("Turn = %d, want %d", got, want) } if got, want := rep.Width, uint32(800); got != want { t.Errorf("Width = %d, want %d", got, want) } if got, want := rep.Height, uint32(800); got != want { t.Errorf("Height = %d, want %d", got, want) } if got, want := rep.PlanetCount, uint32(700); got != want { t.Errorf("PlanetCount = %d, want %d", got, want) } } // TestParseStatusOfPlayers exercises the alive / extinct distinction // that drives the races view. func TestParseStatusOfPlayers(t *testing.T) { in := strings.Join([]string{ "Race Report for Galaxy PLUS Turn 1", "", "Status of Players (total 10.00 votes)", "", "N D W S C P I # R V", "Alpha 4.51 2.24 1.80 1.00 100.00 80.00 3 War 3.00", "Bravo 9.03 5.62 2.16 1.53 200.00 150.00 5 Peace 5.00", "Gone_RIP 1.00 1.00 1.00 1.00 0.00 0.00 0 War 0.00", "", }, "\n") rep, err := Parse(strings.NewReader(in)) if err != nil { t.Fatalf("Parse: %v", err) } if got, want := len(rep.Player), 3; got != want { t.Fatalf("len(Player) = %d, want %d", got, want) } alpha := rep.Player[0] if alpha.Name != "Alpha" || alpha.Extinct { t.Errorf("Alpha = %+v, want Name=Alpha extinct=false", alpha) } if alpha.Planets != 3 || alpha.Relation != "War" { t.Errorf("Alpha planets/relation = %d/%q, want 3/War", alpha.Planets, alpha.Relation) } if got, want := float64(alpha.Drive), 4.51; got != want { t.Errorf("Alpha.Drive = %v, want %v", got, want) } gone := rep.Player[2] if gone.Name != "Gone" || !gone.Extinct { t.Errorf("Gone = %+v, want Name=Gone extinct=true (suffix _RIP stripped)", gone) } } func TestParseYourVote(t *testing.T) { in := strings.Join([]string{ "Race Report for Galaxy PLUS Turn 1", "", "Your vote:", "", "R V", "KnightErrants 16.02", "", }, "\n") rep, err := Parse(strings.NewReader(in)) if err != nil { t.Fatalf("Parse: %v", err) } if rep.VoteFor != "KnightErrants" { t.Errorf("VoteFor = %q, want KnightErrants", rep.VoteFor) } if got, want := float64(rep.Votes), 16.02; got != want { t.Errorf("Votes = %v, want %v", got, want) } } func TestParseLocalAndOtherPlanets(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 Drive_Research 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", "", "Monstrai Planets", "", " # X Y N S P I R P $ M C L", " 12 303.84 579.23 Skarabei 500.00 500.00 500.00 10.00 Capital 0.00 70.99 20.03 341.78", "", }, "\n") rep, err := Parse(strings.NewReader(in)) if err != nil { t.Fatalf("Parse: %v", err) } if got, want := len(rep.LocalPlanet), 2; got != want { t.Fatalf("len(LocalPlanet) = %d, want %d", got, want) } castle := rep.LocalPlanet[0] if castle.Number != 17 || castle.Name != "Castle" { t.Errorf("Castle = (%d, %q), want (17, Castle)", castle.Number, castle.Name) } if castle.Production != "Drive_Research" { t.Errorf("Castle.Production = %q, want Drive_Research", castle.Production) } if got, want := float64(castle.Size), 1000.0; got != want { t.Errorf("Castle.Size = %v, want %v", got, want) } if got, want := len(rep.OtherPlanet), 1; got != want { t.Fatalf("len(OtherPlanet) = %d, want %d", got, want) } skarabei := rep.OtherPlanet[0] if skarabei.Owner != "Monstrai" { t.Errorf("Skarabei.Owner = %q, want Monstrai", skarabei.Owner) } if skarabei.Number != 12 || skarabei.Name != "Skarabei" { t.Errorf("Skarabei = (%d, %q), want (12, Skarabei)", skarabei.Number, skarabei.Name) } } func TestParseUninhabitedAndUnidentified(t *testing.T) { in := strings.Join([]string{ "Race Report for Galaxy PLUS Turn 1", "", "Uninhabited Planets", "", " # X Y N S R $ M", " 9 117.87 795.21 Dw2 500.00 10.00 0.00 500.00", "", "Unidentified Planets", "", " # X Y", " 0 738.08 600.26", " 1 579.12 489.37", "", }, "\n") rep, err := Parse(strings.NewReader(in)) if err != nil { t.Fatalf("Parse: %v", err) } if got, want := len(rep.UninhabitedPlanet), 1; got != want { t.Fatalf("len(UninhabitedPlanet) = %d, want %d", got, want) } dw2 := rep.UninhabitedPlanet[0] if dw2.Number != 9 || dw2.Name != "Dw2" { t.Errorf("Dw2 = (%d, %q), want (9, Dw2)", dw2.Number, dw2.Name) } if got, want := len(rep.UnidentifiedPlanet), 2; got != want { t.Fatalf("len(UnidentifiedPlanet) = %d, want %d", got, want) } first := rep.UnidentifiedPlanet[0] if first.Number != 0 || float64(first.X) != 738.08 || float64(first.Y) != 600.26 { t.Errorf("Unidentified[0] = %+v, want (0, 738.08, 600.26)", first) } } func TestParseShipClasses(t *testing.T) { in := strings.Join([]string{ "Race Report for Galaxy PLUS Turn 1", "", "Your Ship Types", "", "N D A W S C M", "Frontier 11.37 0 0.00 0.00 1.00 12.37", "Bow105 74.77 105 1.00 19.72 1.00 148.49", "", "Monstrai Ship Types", "", "N D A W S C M", "Dragon 16.70 1 1.10 1.00 1 19.80", "", }, "\n") rep, err := Parse(strings.NewReader(in)) if err != nil { t.Fatalf("Parse: %v", err) } if got, want := len(rep.LocalShipClass), 2; got != want { t.Fatalf("len(LocalShipClass) = %d, want %d (foreign types must be ignored)", got, want) } bow := rep.LocalShipClass[1] if bow.Name != "Bow105" || bow.Armament != 105 { t.Errorf("Bow105 name/armament = %q/%d, want Bow105/105", bow.Name, bow.Armament) } if got, want := float64(bow.Drive), 74.77; got != want { t.Errorf("Bow105.Drive = %v, want %v", got, want) } } func TestParseSkipsBattlesAndBombings(t *testing.T) { in := strings.Join([]string{ "Race Report for Galaxy PLUS Turn 1", "", "Battle at (#7) B-007", "", "# T D W S C T Q L", "1 PeaceShip 4 0 0 0 - 0 1 Out_Battle", "", "Battle Protocol", "", "Foo fires on Bar : Destroyed", "", "Bombings", "", "# data line", "", "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", "", }, "\n") rep, err := Parse(strings.NewReader(in)) if err != nil { t.Fatalf("Parse: %v", err) } if got, want := len(rep.LocalPlanet), 1; got != want { t.Fatalf("len(LocalPlanet) = %d, want %d (battle/bombing rows must not leak in)", got, want) } } // 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 // stable top-line counts from the real dg/KNNTS039.REP fixture. // Field-level fidelity is asserted in the unit tests above; this // test catches regressions where a section-classifier change // silently drops half the data. func TestParseDgKNNTS039(t *testing.T) { runSmoke(t, "../reports/dg/KNNTS039.REP", smokeWant{ race: "KnightErrants", turn: 39, mapW: 800, mapH: 800, planetCount: 700, voteFor: "KnightErrants", votes: 16.02, players: 91, extinct: 49, local: 22, other: 89, uninhabited: 17, unidentified: 572, shipClasses: 24, localGroups: 171, localFleets: 0, incomingGroups: 0, }) } func TestParseDgKNNTS040(t *testing.T) { runSmoke(t, "../reports/dg/KNNTS040.REP", smokeWant{ 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) { runSmoke(t, "../reports/gplus/40.REP", smokeWant{ race: "MbI", turn: 40, mapW: 350, mapH: 350, planetCount: 300, players: 26, extinct: 0, local: 26, other: 116, uninhabited: 7, unidentified: 151, shipClasses: 56, localGroups: 255, localFleets: 1, incomingGroups: 10, }) } // TestParseDgKiller031 exercises the Killer engine variant which // ships "Your Fleets" + "Your Groups" with the Fl1/F2 fleet // membership shape (no "Incoming Groups" this turn). func TestParseDgKiller031(t *testing.T) { runSmoke(t, "../reports/dg/Killer031.rep", smokeWant{ race: "Killer", turn: 31, mapW: 250, mapH: 250, planetCount: 175, players: 25, extinct: 12, local: 18, other: 127, uninhabited: 20, unidentified: 10, shipClasses: 11, localGroups: 175, localFleets: 2, incomingGroups: 0, }) } // TestParseDgTancordia037 is the richest smoke fixture: it carries // 311 local groups across 30 fleets, two incoming groups, and the // "Incoming Groups" section appears before "Your Planets" (so the // deferred name resolution is exercised in production conditions). func TestParseDgTancordia037(t *testing.T) { runSmoke(t, "../reports/dg/Tancordia037.rep", smokeWant{ race: "Tancordia", turn: 37, mapW: 210, mapH: 210, planetCount: 140, players: 18, extinct: 7, local: 23, other: 62, uninhabited: 26, unidentified: 29, shipClasses: 40, localGroups: 311, localFleets: 30, incomingGroups: 2, }) } func parseFile(t *testing.T, rel string) (report.Report, error) { t.Helper() abs, err := filepath.Abs(rel) if err != nil { return report.Report{}, err } f, err := os.Open(abs) if err != nil { return report.Report{}, err } defer func() { _ = f.Close() }() return Parse(f) }