Files
Cyrene_v2/internal/services/gateway/session_parallel_test.go
T
AskaEth ef3dcb699b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,loop,tool): 解耦「工具互斥身份」与「checkpoint 归属」(修普通回合不受保护)
缺口:「1 普通回合 + 1 并行路」时普通回合既不登记也不被检查 —— 因为互斥身份取自
loop.ParallelLegFrom,而网关只给 parallel=true 那一路盖它。直接给普通回合盖 WithParallelLeg
会把它的 checkpoint 键从 sess 变成 sess#turn-x(改崩溃恢复语义),故改为两条独立通道:
- 工具互斥身份:loop.WithToolLegID / ToolLegIDFrom(新增),网关在 beginLegLocked 内【无条件】盖,
  取值 = 本路 turnID ⇒ 普通回合 / 队首续跑路 / 并行路 三条路都覆盖
- checkpoint 归属:WithParallelLeg 一行未改,仍只有真并行路 ⇒ 恢复语义零变化
守门断言 TestCheckpointOwnerIgnoresToolLegID:普通回合 ctx 下 AgentID 必须逐字仍是 sess。
变异 3 条:互斥身份回退只认 ParallelLegFrom(判忙失效)/ 顺手把 checkpoint 改读新通道
(守门断言红,证明确实解耦)/ 普通回合不盖身份(判忙失效)。
另修 race:-race -count=2 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
2026-10-05 09:53:08 +08:00

541 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"fmt"
"log/slog"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是 design/15 §2.5「忙时并行回合(多路)」**基础管线**的回归套件
//(主人 2026-10-05 裁决 B)。它钉住六件事:
//
// ① 省略 `parallel` ⇒ 行为逐字不变(不并行、不新增帧键、报空闲时点不变);
// ② 显式 `parallel` 且额度未满 ⇒ 真的起第二路(两条不同的 turnId、各自的路标识);
// ③ 额度满 ⇒ 落回排队(不报错、不丢消息),并如实给出 reason;
// ④ 单路时 `session_idle` 的时点不变;
// ⑤ 并行时 `session_idle` 等到**所有路**收尾;
// ⑥ turnId 一致性(编排器生成的那个 = 交给 Run、并由流帧盖上壳的那个)。
//
// 夹具刻意用**可逐路放行**的 runner(放行顺序即收尾顺序),因此「谁先收尾」是
// 确定性的,断言的是时序而不是睡眠。
// gatedLegRunner 是可控的 TurnRunner:每条路的 Run 都阻塞在自己的放行闸上,
// 测试按启动顺序逐路放行,因此能精确断言「一路收尾而另一路仍在飞」的那一刻。
type gatedLegRunner struct {
mu sync.Mutex
calls []callRecord
gates []chan struct{}
started chan struct{}
}
func newGatedLegRunner() *gatedLegRunner {
return &gatedLegRunner{started: make(chan struct{}, 8)}
}
func (g *gatedLegRunner) Run(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
gate := make(chan struct{})
g.mu.Lock()
idx := len(g.calls)
g.calls = append(g.calls, callRecord{sessionID: sessionID, messages: append([]model.Message(nil), messages...), env: env, ctx: ctx})
g.gates = append(g.gates, gate)
g.mu.Unlock()
g.started <- struct{}{}
select {
case <-gate:
case <-ctx.Done():
return nil, ctx.Err()
}
return &loop.Result{Text: fmt.Sprintf("reply-%d", idx+1), Steps: 1, StopReason: "stop"}, nil
}
// waitStarted 等到至少 n 条路真的进了 runner(而不是只被登记)。
func (g *gatedLegRunner) waitStarted(n int) []callRecord {
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
g.mu.Lock()
got := len(g.calls)
out := append([]callRecord(nil), g.calls...)
g.mu.Unlock()
if got >= n {
return out
}
time.Sleep(time.Millisecond)
}
panic("timed out waiting for leg starts")
}
// release 放行第 i 条启动的路(i 从 0 起)。它幂等(重复关闭会 panic,
// 因此用 select 保护),使测试的放行顺序就是收尾顺序。
func (g *gatedLegRunner) release(i int) {
g.mu.Lock()
gate := g.gates[i]
g.mu.Unlock()
select {
case <-gate:
default:
close(gate)
}
}
// turnStateRec 是一次状态帧(含帧壳上的 turnId,可能为空)。
type turnStateRec struct {
sessionID string
turnID string
typ string
data map[string]any
}
// turnSink 同时实现 StateSink 与 TurnStateSink,把两类帧记在同一个序列里,
// 使「带 turnId 的变体真的把 turnId 盖到帧壳上」可被直接断言。
type turnSink struct {
mu sync.Mutex
events []turnStateRec
}
func (s *turnSink) Emit(sessionID, typ string, data map[string]any) {
s.record(turnStateRec{sessionID: sessionID, typ: typ, data: data})
}
func (s *turnSink) EmitTurn(sessionID, turnID, typ string, data map[string]any) {
s.record(turnStateRec{sessionID: sessionID, turnID: turnID, typ: typ, data: data})
}
func (s *turnSink) record(rec turnStateRec) {
s.mu.Lock()
s.events = append(s.events, rec)
s.mu.Unlock()
}
func (s *turnSink) snapshot() []turnStateRec {
s.mu.Lock()
defer s.mu.Unlock()
return append([]turnStateRec(nil), s.events...)
}
// ofType 返回某类状态帧(按发生顺序)。
func (s *turnSink) ofType(typ string) []turnStateRec {
var out []turnStateRec
for _, e := range s.snapshot() {
if e.typ == typ {
out = append(out, e)
}
}
return out
}
// parallelFixture 是并行管线用例的公共夹具。
type parallelFixture struct {
o *Orchestrator
run *gatedLegRunner
sink *turnSink
history *fakeHistory
}
// newParallelFixture 按给定的队列容量与并行上限构建编排器(Log 走丢弃型,
// 避免并行用例互相污染日志断言)。
func newParallelFixture(maxPending, maxParallel int) *parallelFixture {
run := newGatedLegRunner()
sink := &turnSink{}
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Emit: sink.Emit,
EmitTurn: sink.EmitTurn,
Log: slog.New(slog.NewTextHandler(discardWriter{}, nil)),
MaxPending: maxPending,
MaxTail: 20,
History: hist,
MaxParallelTurns: maxParallel,
})
return &parallelFixture{o: o, run: run, sink: sink, history: hist}
}
// discardWriter 接收被丢弃的日志字节(用例只关心状态帧与历史行)。
type discardWriter struct{}
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
// waitActiveTurns 等到某会话的在途路数等于 want(超时即失败),使断言不靠睡眠。
func waitActiveTurns(t *testing.T, o *Orchestrator, sessionID string, want int) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if o.ActiveTurns(sessionID) == want {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("在途路数 = %d, want %d", o.ActiveTurns(sessionID), want)
}
// stateInt 从状态帧的 data 里取一个整数键(缺失或类型不符时返回 -1)。
func stateInt(rec turnStateRec, key string) int {
v, ok := rec.data[key].(int)
if !ok {
return -1
}
return v
}
// stateKeyPresent 报告状态帧的 data 里某个键是否**在场**(用于「该键缺席」的断言:
// 缺席与空串对客户端含义不同,绝不伪造)。
func stateKeyPresent(rec turnStateRec, key string) bool {
_, ok := rec.data[key]
return ok
}
// assistantContents 返回已落库记录里的助手回复正文(按顺序)。
func assistantContents(msgs []history.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
if m.Role == history.RoleAssistant {
out = append(out, m.Content)
}
}
return out
}
// windowText 返回一路播种到的模型消息正文(按顺序),供断言 I4「每路一份窗口」。
func windowText(msgs []model.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
out = append(out, messageText(m))
}
return out
}
// parallelCtx 返回一条**显式声明并行**的提交上下文(与 WS/REST 边界同源的 ctx 通道)。
func parallelCtx() context.Context { return withParallelTurn(context.Background(), true) }
// ---------------------------------------------------------------------------
// ① 省略 parallel ⇒ 逐字不变
// ---------------------------------------------------------------------------
// TestParallelOmittedKeepsQueueingBehaviour 钉住「老客户端零影响」:不声明 parallel
// 的忙时消息仍然入队,帧形状不变(**没有** reason 键),且始终只有一条在途路。
func TestParallelOmittedKeepsQueueingBehaviour(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
if sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv()); !sr.Immediate || sr.Parallel {
t.Fatalf("空闲提交 = %+v, want Immediate 且非 Parallel", sr)
}
f.run.waitStarted(1)
if got := f.o.ActiveTurns("web"); got != 1 {
t.Fatalf("在途路数 = %d, want 1", got)
}
// 忙时第二条(未声明并行)⇒ 排队,且帧上没有任何新键。
sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
if !sr.Pending || sr.Parallel || sr.QueueDepth != 1 {
t.Fatalf("忙时未声明并行的提交 = %+v, want Pending(1) 且非 Parallel", sr)
}
if got := f.o.ActiveTurns("web"); got != 1 {
t.Fatalf("排队时在途路数 = %d, want 1(绝不新开路)", got)
}
acks := f.sink.ofType(EventPendingAck)
if len(acks) != 1 {
t.Fatalf("pending_ack 帧数 = %d, want 1", len(acks))
}
if stateKeyPresent(acks[0], "reason") {
t.Fatalf("未声明并行的排队不该带 reason,得到 %v", acks[0].data)
}
if stateInt(acks[0], "queueDepth") != 1 {
t.Fatalf("pending_ack queueDepth = %v, want 1", acks[0].data["queueDepth"])
}
if stateInt(acks[0], "activeTurns") != -1 {
t.Fatalf("pending_ack 不该带 activeTurns(契约里只有 session_busy/idle/drain/interrupted 带它):%v", acks[0].data)
}
// 放行第一路 ⇒ 弹队首续跑(仍是串行,一条在途路)。
f.run.release(0)
calls := f.run.waitStarted(2)
if got := f.o.ActiveTurns("web"); got != 1 {
t.Fatalf("续跑时在途路数 = %d, want 1", got)
}
if got := loop.ParallelLegFrom(calls[1].ctx); got != "" {
t.Fatalf("续跑路的路标识 = %q, want 空串(串行路不挂并发路标识)", got)
}
if loop.TurnIDFrom(calls[1].ctx) == loop.TurnIDFrom(calls[0].ctx) {
t.Fatal("续跑路必须是一条**新路**,turnId 不能与上一路相同")
}
// 工具互斥身份是**另一条通道**(design/15 §2.5.7):续跑路同样必须带上它,
// 取值就是它自己的 turnId —— 否则串行续跑路在写类工具上不受保护。它与
// checkpoint 归属键无关,所以上面那条「空串」断言不该被它推翻。
if got := loop.ToolLegIDFrom(calls[1].ctx); got != loop.TurnIDFrom(calls[1].ctx) {
t.Fatalf("续跑路的互斥身份 = %q, want %q(每一路都要盖)", got, loop.TurnIDFrom(calls[1].ctx))
}
if loop.ToolLegIDFrom(calls[1].ctx) == loop.ToolLegIDFrom(calls[0].ctx) {
t.Fatal("两条路的互斥身份必须不同,否则同文件写会被当成「同一路自己的调用」而放行")
}
drains := f.sink.ofType(EventPendingDrain)
if len(drains) != 1 || stateInt(drains[0], "activeTurns") != 1 {
t.Fatalf("pending_drain = %v, want 一帧且 activeTurns=1", drains)
}
f.run.release(1)
if !waitIdle(f.o, "web") {
t.Fatal("会话没有回到空闲")
}
idle := f.sink.ofType(EventSessionIdle)
if len(idle) != 1 || stateInt(idle[0], "activeTurns") != 0 {
t.Fatalf("session_idle = %v, want 一帧且 activeTurns=0", idle)
}
busy := f.sink.ofType(EventSessionBusy)
if len(busy) != 1 || stateInt(busy[0], "activeTurns") != 1 {
t.Fatalf("session_busy = %v, want 一帧且 activeTurns=1", busy)
}
if got := len(assistantContents(f.history.records())); got != 2 {
t.Fatalf("助手回复行数 = %d, want 2(每条消息各答一次)", got)
}
}
// TestSessionIdleTimingSingleLegUnchanged 钉住④:单路(含排队续跑)的
// `session_idle` 时点与现状一致 —— 队列空且本路收尾就报,绝不多等、绝不少报。
func TestSessionIdleTimingSingleLegUnchanged(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
if sr := f.o.Submit(context.Background(), "web", []model.Message{userMsg("甲")}, loop.OwnerEnv()); !sr.Immediate {
t.Fatalf("提交 = %+v, want Immediate", sr)
}
f.run.waitStarted(1)
if got := f.sink.ofType(EventSessionIdle); len(got) != 0 {
t.Fatalf("回合在飞时不该报空闲,得到 %v", got)
}
if got := f.o.ActiveTurns("web"); got != 1 {
t.Fatalf("在途路数 = %d, want 1", got)
}
f.run.release(0)
if !waitIdle(f.o, "web") {
t.Fatal("会话没有回到空闲")
}
if got := f.sink.ofType(EventSessionIdle); len(got) != 1 {
t.Fatalf("session_idle 帧数 = %d, want 1", len(got))
}
if got := f.o.ActiveTurns("web"); got != 0 {
t.Fatalf("收尾后在途路数 = %d, want 0", got)
}
}
// ---------------------------------------------------------------------------
// ② 显式 parallel 且额度未满 ⇒ 真的起第二路
// ---------------------------------------------------------------------------
// TestParallelDeclaredStartsSecondLeg 钉住②与⑥:第二条显式声明并行的消息**立即**
// 起一条独立的路(两路 turnId 不同、并行路带自己的路标识),两路同时生长,各自
// 落一行助手回复,且 `session_idle` 只在两路都收尾后出现一次。
func TestParallelDeclaredStartsSecondLeg(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
first := f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
if !first.Immediate || first.TurnID == "" {
t.Fatalf("第一路 = %+v, want Immediate 且带 turnId", first)
}
f.run.waitStarted(1)
second := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
if !second.Immediate || !second.Parallel {
t.Fatalf("显式并行提交 = %+v, want Immediate 且 Parallel", second)
}
if second.TurnID == "" || second.TurnID == first.TurnID {
t.Fatalf("第二路 turnId = %q,第一路 = %q:必须各自不同", second.TurnID, first.TurnID)
}
if f.o.QueueDepth("web") != 0 {
t.Fatalf("并行路不该入队,队列深度 = %d", f.o.QueueDepth("web"))
}
calls := f.run.waitStarted(2)
waitActiveTurns(t, f.o, "web", 2)
// I4:每一路都有**自己的一份窗口** —— 各自带来自己那条用户消息,且看不到
// 对方在跑的东西(对方的答复尚未落库,因此不在任何一路的转录里)。
if got := windowText(calls[0].messages); len(got) != 1 || got[0] != "第一问" {
t.Fatalf("第一路的窗口 = %v, want [第一问]", got)
}
if got := windowText(calls[1].messages); len(got) != 1 || got[0] != "第二问" {
t.Fatalf("第二路的窗口 = %v, want [第二问]", got)
}
// ⑥ 两路各自的 turnId:第一路不带并发路标识(checkpoint 归属键仍是 sessionID),
// 第二路带自己的(归属键升为 {sessionID}#{turnID}),且都与提交时交回的 turnId 同值。
if got := loop.TurnIDFrom(calls[0].ctx); got != first.TurnID {
t.Fatalf("第一路 ctx turnId = %q,提交回报 %q:两处必须同值", got, first.TurnID)
}
if got := loop.ParallelLegFrom(calls[0].ctx); got != "" {
t.Fatalf("第一路不该挂并发路标识,得到 %q", got)
}
if got := loop.TurnIDFrom(calls[1].ctx); got != second.TurnID {
t.Fatalf("第二路 ctx turnId = %q,提交回报 %q:两处必须同值", got, second.TurnID)
}
if got := loop.ParallelLegFrom(calls[1].ctx); got != second.TurnID {
t.Fatalf("第二路路标识 = %q, want %q(checkpoint 归属键按路隔离)", got, second.TurnID)
}
// 忙态帧如实报告两路在途。
busy := f.sink.ofType(EventSessionBusy)
if len(busy) != 2 || stateInt(busy[1], "activeTurns") != 2 {
t.Fatalf("session_busy 帧 = %v, want 两帧且第二帧 activeTurns=2", busy)
}
// ⑤ 先收尾第二路:会话仍不空闲(第一路还在飞)。
f.run.release(1)
waitActiveTurns(t, f.o, "web", 1)
if got := f.sink.ofType(EventSessionIdle); len(got) != 0 {
t.Fatalf("还有一路在飞时不该报空闲,得到 %v", got)
}
f.run.release(0)
if !waitIdle(f.o, "web") {
t.Fatal("所有路收尾后会话没有回到空闲")
}
idle := f.sink.ofType(EventSessionIdle)
if len(idle) != 1 || stateInt(idle[0], "activeTurns") != 0 {
t.Fatalf("session_idle = %v, want 恰好一帧且 activeTurns=0", idle)
}
// 两路各落一行助手回复(I5:绝不合并成一行、不覆盖对方),且每条用户消息只被
// 作答一次(两条 user 行、两条 assistant 行)。
rows := f.history.records()
if got := len(assistantContents(rows)); got != 2 {
t.Fatalf("助手回复行数 = %d, want 2", got)
}
users := 0
for _, m := range rows {
if m.Role == history.RoleUser {
users++
}
}
if users != 2 {
t.Fatalf("用户消息行数 = %d, want 2(恰好一次)", users)
}
}
// TestSessionIdleWaitsForAllLegs 是⑤的**最小时序**回归:并行两路,任意顺序收尾,
// 只有第二路也收尾时才出现唯一的 session_idle。
func TestSessionIdleWaitsForAllLegs(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
f.o.Submit(context.Background(), "web", []model.Message{userMsg("A")}, loop.OwnerEnv())
f.run.waitStarted(1)
f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("B")}, loop.OwnerEnv())
f.run.waitStarted(2)
waitActiveTurns(t, f.o, "web", 2)
// 先收尾**第一**路(与上一个用例相反的收尾顺序,证明判据不是「最后启动的那一路」)。
f.run.release(0)
waitActiveTurns(t, f.o, "web", 1)
if got := f.sink.ofType(EventSessionIdle); len(got) != 0 {
t.Fatalf("一路收尾、另一路仍在飞 ⇒ 不该报空闲,得到 %v", got)
}
f.run.release(1)
if !waitIdle(f.o, "web") {
t.Fatal("所有路收尾后会话没有回到空闲")
}
if got := f.sink.ofType(EventSessionIdle); len(got) != 1 {
t.Fatalf("session_idle 帧数 = %d, want 1", len(got))
}
}
// ---------------------------------------------------------------------------
// ③ 额度满 ⇒ 落回排队
// ---------------------------------------------------------------------------
// TestParallelQuotaFullFallsBackToQueue 钉住③:额度(默认 2)用满后,再来的显式
// 并行消息**落回既有排队语义** —— 不报错、不打断、不丢,并如实给出
// `pending_ack{reason:"parallel_limit"}`;被排队的消息最终仍被作答一次。
func TestParallelQuotaFullFallsBackToQueue(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 2)
f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
f.run.waitStarted(1)
if sr := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv()); !sr.Parallel {
t.Fatalf("额度未满时应当照准并行,得到 %+v", sr)
}
f.run.waitStarted(2)
waitActiveTurns(t, f.o, "web", 2)
// 第三条声明并行的消息:额度已满 ⇒ 排队。
third := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第三问")}, loop.OwnerEnv())
if !third.Accepted || !third.Pending || third.Parallel {
t.Fatalf("额度满的并行提交 = %+v, want Accepted+Pending 且非 Parallel", third)
}
if third.QueueDepth != 1 {
t.Fatalf("队列深度 = %d, want 1", third.QueueDepth)
}
acks := f.sink.ofType(EventPendingAck)
if len(acks) != 1 || acks[0].data["reason"] != pendingAckReasonParallelLimit {
t.Fatalf("pending_ack = %v, want reason=%q", acks, pendingAckReasonParallelLimit)
}
if got := f.o.ActiveTurns("web"); got != 2 {
t.Fatalf("落回排队不该改变在途路数,得到 %d", got)
}
// 逐路收尾:排队的那条由结算中的一路续跑(起一条**串行**新路),最终被作答。
f.run.release(0)
f.run.waitStarted(3)
f.run.release(1)
f.run.release(2)
if !waitIdle(f.o, "web") {
t.Fatal("会话没有回到空闲")
}
if got := len(assistantContents(f.history.records())); got != 3 {
t.Fatalf("助手回复行数 = %d, want 3(排队的第三条也必须被作答一次)", got)
}
}
// TestParallelDisabledByLimitBelowTwo 钉住「<2 = 关闭真并行 ⇒ 完全回落到现状」:
// 上限为 1 时显式并行也一律排队,在途路数恒不越过 1。
func TestParallelDisabledByLimitBelowTwo(t *testing.T) {
t.Parallel()
f := newParallelFixture(5, 1)
f.o.Submit(context.Background(), "web", []model.Message{userMsg("第一问")}, loop.OwnerEnv())
f.run.waitStarted(1)
sr := f.o.Submit(parallelCtx(), "web", []model.Message{userMsg("第二问")}, loop.OwnerEnv())
if !sr.Pending || sr.Parallel {
t.Fatalf("真并行被关闭时 = %+v, want Pending 且非 Parallel", sr)
}
if got := f.o.ActiveTurns("web"); got != 1 {
t.Fatalf("在途路数 = %d, want 1(关闭真并行)", got)
}
acks := f.sink.ofType(EventPendingAck)
if len(acks) != 1 || acks[0].data["reason"] != pendingAckReasonParallelLimit {
t.Fatalf("pending_ack = %v, want 仍如实给出 reason", acks)
}
f.run.release(0)
f.run.waitStarted(2)
f.run.release(1)
if !waitIdle(f.o, "web") {
t.Fatal("会话没有回到空闲")
}
}
// TestResolveMaxParallelTurns 钉住额度解析的三档边界(默认 / 关闭 / 硬上限)。
func TestResolveMaxParallelTurns(t *testing.T) {
t.Parallel()
cases := []struct {
in int
want int
}{
{-5, defaultMaxParallelTurns},
{0, defaultMaxParallelTurns},
{1, 1},
{2, 2},
{3, 3},
{99, maxParallelTurnsHardCap},
}
for _, c := range cases {
if got := resolveMaxParallelTurns(c.in); got != c.want {
t.Fatalf("resolveMaxParallelTurns(%d) = %d, want %d", c.in, got, c.want)
}
}
}