Files
galaxy-game/ui/core/calc/ship_test.go
T
Ilia Denisov 3626998a33 ui/phase-20: ship-group inspector actions
Eight ship-group operations land on the inspector behind a single
inline-form panel: split, send, load, unload, modernize, dismantle,
transfer, join fleet. Each action either appends a typed command to
the local order draft or surfaces a tooltip explaining the
disabled state. Partial-ship operations emit an implicit
breakShipGroup command before the targeted action so the engine
sees a clean (Break, Action) pair on the wire.

`pkg/calc.BlockUpgradeCost` migrates from
`game/internal/controller/ship_group_upgrade.go` so the calc
bridge can wrap a pure pkg/calc formula; the controller now
imports it. The bridge surfaces the function as
`core.blockUpgradeCost`, which the inspector calls once per ship
block to render the modernize cost preview.

`GameReport.otherRaces` is decoded from the report's player block
(non-extinct, ≠ self) and feeds the transfer-to-race picker. The
planet inspector's stationed-ship rows become clickable for own
groups so the actions panel is reachable from the standard click
flow (the renderer continues to hide on-planet groups).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 16:27:55 +02:00

239 lines
6.3 KiB
Go

package calc_test
import (
"testing"
source "galaxy/calc"
bridge "galaxy/core/calc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// shipFixture is the input set passed to every parity check. Values
// are picked to exercise both the typical mid-tech ship and the
// invalid weapons/armament pairing path of EmptyMass /
// WeaponsBlockMass.
type shipFixture struct {
name string
drive float64
armament uint
weapons float64
shields float64
cargo float64
driveTech float64
cargoTech float64
}
func fixtures() []shipFixture {
return []shipFixture{
{
name: "all_zero",
drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0,
driveTech: 1, cargoTech: 1,
},
{
name: "typical_mid_tech",
drive: 8, armament: 2, weapons: 5, shields: 3, cargo: 4,
driveTech: 1.5, cargoTech: 1.2,
},
{
name: "heavy_armoured",
drive: 3, armament: 5, weapons: 12, shields: 20, cargo: 1,
driveTech: 0.8, cargoTech: 0.5,
},
{
name: "invalid_weapons_no_armament",
drive: 5, armament: 0, weapons: 4, shields: 1, cargo: 2,
driveTech: 1, cargoTech: 1,
},
{
name: "invalid_armament_no_weapons",
drive: 5, armament: 3, weapons: 0, shields: 1, cargo: 2,
driveTech: 1, cargoTech: 1,
},
}
}
func TestDriveEffectiveParity(t *testing.T) {
t.Parallel()
for _, f := range fixtures() {
t.Run(f.name, func(t *testing.T) {
t.Parallel()
want := source.DriveEffective(f.drive, f.driveTech)
got := bridge.DriveEffective(f.drive, f.driveTech)
assert.Equal(t, want, got)
})
}
}
func TestEmptyMassParity(t *testing.T) {
t.Parallel()
for _, f := range fixtures() {
t.Run(f.name, func(t *testing.T) {
t.Parallel()
wantMass, wantOk := source.EmptyMass(f.drive, f.weapons, f.armament, f.shields, f.cargo)
gotMass, gotOk := bridge.EmptyMass(f.drive, f.weapons, f.armament, f.shields, f.cargo)
assert.Equal(t, wantOk, gotOk)
assert.Equal(t, wantMass, gotMass)
})
}
}
func TestWeaponsBlockMassParity(t *testing.T) {
t.Parallel()
for _, f := range fixtures() {
t.Run(f.name, func(t *testing.T) {
t.Parallel()
wantMass, wantOk := source.WeaponsBlockMass(f.weapons, f.armament)
gotMass, gotOk := bridge.WeaponsBlockMass(f.weapons, f.armament)
assert.Equal(t, wantOk, gotOk)
assert.Equal(t, wantMass, gotMass)
})
}
}
func TestFullMassParity(t *testing.T) {
t.Parallel()
cases := []struct {
name string
emptyMass float64
carrying float64
}{
{"zero", 0, 0},
{"empty_only", 25, 0},
{"loaded", 25, 12.5},
{"negative_carrying_clamped_by_caller", 25, -3},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
want := source.FullMass(c.emptyMass, c.carrying)
got := bridge.FullMass(c.emptyMass, c.carrying)
assert.Equal(t, want, got)
})
}
}
func TestSpeedParity(t *testing.T) {
t.Parallel()
cases := []struct {
name string
driveEffective float64
fullMass float64
}{
{"zero_mass_returns_zero", 12, 0},
{"negative_mass_returns_zero", 12, -1},
{"typical", 12, 30},
{"fast_light_ship", 50, 5},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
want := source.Speed(c.driveEffective, c.fullMass)
got := bridge.Speed(c.driveEffective, c.fullMass)
assert.Equal(t, want, got)
})
}
}
func TestCargoCapacityParity(t *testing.T) {
t.Parallel()
for _, f := range fixtures() {
t.Run(f.name, func(t *testing.T) {
t.Parallel()
want := source.CargoCapacity(f.cargo, f.cargoTech)
got := bridge.CargoCapacity(f.cargo, f.cargoTech)
assert.Equal(t, want, got)
})
}
}
func TestCarryingMassParity(t *testing.T) {
t.Parallel()
cases := []struct {
name string
load float64
cargoTech float64
}{
{"zero_load", 0, 1},
{"negative_load_returns_zero", -5, 1},
{"typical_high_tech", 24, 2},
{"low_tech_amplifies", 24, 0.5},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
want := source.CarryingMass(c.load, c.cargoTech)
got := bridge.CarryingMass(c.load, c.cargoTech)
assert.Equal(t, want, got)
})
}
}
func TestBlockUpgradeCostParity(t *testing.T) {
t.Parallel()
cases := []struct {
name string
blockMass float64
currentTech float64
targetTech float64
}{
{"zero_block_mass", 0, 1, 2},
{"target_equal_to_current", 5, 2, 2},
{"target_below_current", 5, 2, 1},
{"doubling_tech_on_mass_5", 5, 1, 2},
{"partial_step_2_to_2_5", 5, 2, 2.5},
{"high_tech_to_higher_tech", 12, 4, 6},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
want := source.BlockUpgradeCost(c.blockMass, c.currentTech, c.targetTech)
got := bridge.BlockUpgradeCost(c.blockMass, c.currentTech, c.targetTech)
assert.Equal(t, want, got)
})
}
}
// TestDesignerPreviewComposition exercises the exact composition the
// ship-class designer performs: empty mass, full-load mass via
// CarryingMass(CargoCapacity), max speed at empty, and range at full
// load. Catches regressions if a future bridge tweak silently changes
// the composition shape.
func TestDesignerPreviewComposition(t *testing.T) {
t.Parallel()
const (
drive = 8.0
armament = uint(2)
weapons = 5.0
shields = 3.0
cargo = 4.0
driveTech = 1.5
cargoTech = 1.2
)
emptyMass, ok := bridge.EmptyMass(drive, weapons, armament, shields, cargo)
require.True(t, ok)
cargoCap := bridge.CargoCapacity(cargo, cargoTech)
carryAtFull := bridge.CarryingMass(cargoCap, cargoTech)
fullLoadMass := bridge.FullMass(emptyMass, carryAtFull)
driveEff := bridge.DriveEffective(drive, driveTech)
maxSpeed := bridge.Speed(driveEff, emptyMass)
rangePerTurn := bridge.Speed(driveEff, fullLoadMass)
wantEmpty, _ := source.EmptyMass(drive, weapons, armament, shields, cargo)
wantCap := source.CargoCapacity(cargo, cargoTech)
wantCarry := source.CarryingMass(wantCap, cargoTech)
wantFull := source.FullMass(wantEmpty, wantCarry)
wantDE := source.DriveEffective(drive, driveTech)
wantMaxSpeed := source.Speed(wantDE, wantEmpty)
wantRange := source.Speed(wantDE, wantFull)
assert.Equal(t, wantEmpty, emptyMass)
assert.Equal(t, wantCap, cargoCap)
assert.Equal(t, wantCarry, carryAtFull)
assert.Equal(t, wantFull, fullLoadMass)
assert.Equal(t, wantMaxSpeed, maxSpeed)
assert.Equal(t, wantRange, rangePerTurn)
}