docs(test_framework): add comprehensive integration test guide and fix runner blocking issue

- Document exception handling, zero-value BotResult trap, shared adversary bots
- Add offline parsing example using parseServerOutput
- Skip tests gracefully when JARs missing (guard before suite blocks)
- Fix blocking readLine in runner_process.nim: poll with 50ms sleep + atEnd check
  (was preventing timeout enforcement, now blocks correctly during battle)
- Add test task to QBot.nimble and config.nims setup docs to AGENTS.md
- Add debug logging to TestBattleRunner for bot identity tracking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 10:54:56 +02:00
parent 2bfa8eb6a5
commit 7e1f1483a4
19 changed files with 281 additions and 6 deletions
+77
View File
@@ -143,10 +143,56 @@ BotRoundResult = object
survived*: bool
```
### Compilation behavior
`compileBots` runs on every `runBattle()` call — no caching. Each test that calls `runBattle()` recompiles all bots. Compile timeout is **30s per bot**, hardcoded, independent of `runBattle()`'s `timeout` parameter.
### Exceptions
`runBattle()` raises:
| Exception | Cause |
|---|---|
| `OSError` | Bot compilation failed — compiler output included in message |
| `IOError` | Battle runner exited non-zero |
| `TimeoutError` | Any timeout: server startup (15s), compilation (30s/bot), battle runner (per `timeout` param) |
```nim
try:
let r = runBattle(@[myBotDir, sittingDuck], rounds = 3)
check "MyBot" in r.winners
except OSError as e:
echo "Compile failed: ", e.msg
except IOError as e:
echo "Battle runner failed: ", e.msg
except TimeoutError as e:
echo "Timed out: ", e.msg
```
### Zero-value `BotResult` trap
Searching `r.results` by name returns a zero-initialized `BotResult` if the name doesn't match — no error raised. Guard against it:
```nim
var myBot: BotResult
for b in r.results:
if b.name == "MyBot": myBot = b
check myBot.name == "MyBot" # catches name mismatch / missing bot
```
---
## Writing adversary bots
### Shared adversary bots
Pre-built adversaries (SittingDuck, OscillatorBot) live at `common_libs/test_framework/adversaries/` — import them directly, no need to copy per garage:
```nim
const sittingDuck = currentSourcePath().parentDir.parentDir.parentDir /
"common_libs" / "test_framework" / "adversaries" / "SittingDuck"
```
Minimal bot — does nothing, useful as a baseline target:
**`tests/bots/SittingDuck/src/SittingDuck.nim`**
@@ -184,6 +230,37 @@ Place adversary bots under `tests/bots/<BotName>/src/`. The framework infers the
---
## Testing parsing offline
`parseServerOutput` is exported from `battle_result.nim` and can be imported directly. Use it for fast offline tests — no Java server needed:
```nim
import test_framework/battle_result
const fixture = """
{"event":"game_started","bots":["MyBot","Enemy"]}
{"event":"round_ended","round":1,"results":[{"name":"MyBot","score":200,"rank":1,"survived":true},{"name":"Enemy","score":0,"rank":2,"survived":false}]}
{"event":"battle_ended","results":[{"name":"MyBot","totalScore":200,"rank":1,"firstPlaces":1,"survivalCount":1},{"name":"Enemy","totalScore":0,"rank":2,"firstPlaces":0,"survivalCount":0}]}
"""
let r = parseServerOutput(fixture)
assert r.winners == @["MyBot"]
```
---
## Skipping when JARs are unavailable
```nim
import std/os
if not existsEnv("TR_SERVER_JAR") or not existsEnv("TR_BATTLE_RUNNER"):
echo "Skipping integration tests: TR_SERVER_JAR / TR_BATTLE_RUNNER not set"
quit(0)
```
Put this at the top of your test file, before any `suite` blocks.
---
## Troubleshooting
**`TR server JAR not found`**
@@ -0,0 +1,11 @@
{
"name": "OscillatorBot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "Predictable zigzag sparring partner for gun testing",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
@@ -0,0 +1,3 @@
#!/bin/sh
cd "$(dirname "$0")"
exec ./out/OscillatorBot 2>> /tmp/oscillatorbot_stderr.log
@@ -0,0 +1,2 @@
--path:"../../.."
switch("outdir", "out")
@@ -0,0 +1,11 @@
{
"name": "OscillatorBot",
"version": "0.1.0",
"authors": ["Davide Cappellini"],
"description": "Predictable zigzag sparring partner for gun testing",
"homepage": "",
"countryCodes": ["IT"],
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
@@ -0,0 +1,48 @@
# OscillatorBot — predictable zigzag sparring partner for GA gun testing.
# Reverses direction + turn every PERIOD ticks. Head-on targeting only.
import std/[math, os]
import tankroyale_botapi
const botJsonPath = currentSourcePath().parentDir / "OscillatorBot.json"
const
SPEED = 7.0 # forward/backward speed
PERIOD = 25 # ticks between direction reversals
type OscillatorBot = ref object of Bot
tickCount: int
moveSign: float # +1 forward, -1 backward
turnSign: float # +1 right, -1 left
method onRoundStarted*(bot: OscillatorBot, e: RoundStartedEvent) =
setAdjustGunForBodyTurn(true)
setAdjustRadarForBodyTurn(true)
setAdjustRadarForGunTurn(true)
bot.tickCount = 0
bot.moveSign = 1.0
bot.turnSign = 1.0
method onScannedBot*(bot: OscillatorBot, e: ScannedBotEvent) =
# Head-on targeting: aim gun directly at enemy, fire medium power
let bearing = directionTo(getX(), getY(), e.x, e.y)
let gunDelta = normalizeRelativeAngle(bearing - getGunDirection())
setGunTurnRate(gunDelta.clamp(-MAX_GUN_TURN_RATE, MAX_GUN_TURN_RATE))
if abs(gunDelta) < 10.0 and getGunHeat() <= 0.0:
discard setFire(2.0)
method run*(bot: OscillatorBot) =
while isRunning():
inc bot.tickCount
if bot.tickCount mod PERIOD == 0:
bot.moveSign *= -1.0
bot.turnSign *= -1.0
setTargetSpeed(bot.moveSign * SPEED)
setTurnRate(bot.turnSign * 4.0)
setRadarTurnRate(45.0) # spin radar to keep scanning
go()
when isMainModule:
var bot = OscillatorBot(moveSign: 1.0, turnSign: 1.0)
start(bot, botJsonPath)
@@ -0,0 +1,9 @@
{
"name": "SittingDuck",
"version": "0.1.0",
"authors": ["Test"],
"description": "Does nothing — test adversary",
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
@@ -0,0 +1,3 @@
#!/bin/sh
cd "$(dirname "$0")"
exec ./out/SittingDuck 2>> /tmp/sittingduck_stderr.log
@@ -0,0 +1,2 @@
--path:"../../.."
switch("outdir", "out")
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "SittingDuck",
"version": "0.1.0",
"authors": ["Test"],
"description": "Does nothing — test adversary",
"gameTypes": ["classic", "1v1"],
"platform": "Nim",
"programmingLang": "Nim"
}
@@ -0,0 +1,15 @@
# SittingDuck — does nothing, used as a test adversary.
import std/os
import tankroyale_botapi
const botJsonPath = currentSourcePath().parentDir / "SittingDuck.json"
type SittingDuck = ref object of Bot
method run*(bot: SittingDuck) =
while isRunning():
go()
when isMainModule:
var bot = SittingDuck()
start(bot, botJsonPath)
Binary file not shown.
@@ -29,14 +29,15 @@ proc runBattleRunner*(serverUrl: string, botDirs: seq[string], rounds: int,
let deadline = epochTime() + timeout.float / 1000.0
var stdout = ""
var line: string
# ponytail: poll instead of blocking readLine; 50ms sleep is fine for battle timescales
while epochTime() < deadline:
if p.peekExitCode() != -1:
# Process finished; drain remaining output
while p.outputStream.readLine(line):
# Process finished; drain remaining output without blocking
while p.outputStream.atEnd == false:
discard p.outputStream.readLine(line)
stdout.add(line & "\n")
break
if p.outputStream.readLine(line):
stdout.add(line & "\n")
sleep(50)
if epochTime() >= deadline and p.peekExitCode() == -1:
p.terminate()