Skip to content

Commit 5aff78e

Browse files
committed
Add engine-integration tests (parser unit + analysis e2e)
Unit-test parseAnalysis against the golden transcripts: winrate normalization for both dialects, scoreLead present/absent, non-negative visits (real lz-analyze emits visits 0 for prior-only moves), and pv parsing. Add a replay fake engine (test/engines/replayEngine.js) that streams a recorded transcript over GTP, and an e2e spec driving attach -> analyze -> SBKV written onto the node. Registers the engine-analysis Playwright project.
1 parent abe5a65 commit 5aff78e

4 files changed

Lines changed: 384 additions & 0 deletions

File tree

e2e/engine-analysis.spec.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
const {expect} = require('@playwright/test')
2+
const path = require('path')
3+
const {test} = require('./fixtures/electron-app')
4+
const {
5+
loadSgfAndWait,
6+
attachAndWaitForEngines,
7+
detachAndWait,
8+
} = require('./helpers')
9+
10+
// End-to-end coverage of the analysis pipeline: attach an engine, start
11+
// analysis, and confirm the engine's reported win rate is written onto the
12+
// current node as the SBKV property.
13+
//
14+
// The engine here is test/engines/replayEngine.js, which replays a GOLDEN
15+
// TRANSCRIPT — real `info ...` lines recorded from KataGo by
16+
// scripts/engine-transcripts/capture.mjs. So this exercises the genuine
17+
// attach → GTP analyze → parseAnalysis → SBKV path against real engine output,
18+
// deterministically and without needing an engine (or GPU) at test time.
19+
20+
const RES = path.resolve(
21+
__dirname,
22+
'..',
23+
'test',
24+
'resources',
25+
'engine-transcripts',
26+
)
27+
const REPLAY_ENGINE = path.resolve(
28+
__dirname,
29+
'..',
30+
'test',
31+
'engines',
32+
'replayEngine.js',
33+
)
34+
const SGF = path.join(RES, 'sgf', 'opening-19.sgf')
35+
const TRANSCRIPT = path.join(
36+
RES,
37+
'katago-1.16.4',
38+
'opening-19.kata-analyze.txt',
39+
)
40+
41+
test.describe('Engine Analysis Integration', () => {
42+
test('replayed KataGo analysis writes SBKV onto the current node', async ({
43+
page,
44+
}) => {
45+
await loadSgfAndWait(page, SGF)
46+
await page.evaluate(() => window.__sabaki.goToEnd())
47+
48+
const [syncerId] = await attachAndWaitForEngines(page, [
49+
{
50+
name: 'ReplayEngine',
51+
path: process.execPath,
52+
args: `${REPLAY_ENGINE} --transcript ${TRANSCRIPT} --analyze-command kata-analyze`,
53+
},
54+
])
55+
56+
// The engine advertises kata-analyze, so Sabaki will choose it.
57+
const supportsAnalyze = await page.evaluate((id) => {
58+
const s = window.__sabaki.state.attachedEngineSyncers.find(
59+
(x) => x.id === id,
60+
)
61+
return s != null && s.commands.includes('kata-analyze')
62+
}, syncerId)
63+
expect(supportsAnalyze).toBe(true)
64+
65+
await page.evaluate((id) => window.__sabaki.startAnalysis(id), syncerId)
66+
67+
// Win rate from the replayed analysis should be written onto the node.
68+
await page.waitForFunction(
69+
() => {
70+
const s = window.__sabaki
71+
const tree = s.state.gameTrees[s.state.gameIndex]
72+
const node = tree.get(s.state.treePosition)
73+
return node != null && node.data != null && node.data.SBKV != null
74+
},
75+
{timeout: 15000},
76+
)
77+
78+
const sbkv = await page.evaluate(() => {
79+
const s = window.__sabaki
80+
const tree = s.state.gameTrees[s.state.gameIndex]
81+
return tree.get(s.state.treePosition).data.SBKV[0]
82+
})
83+
84+
const winrate = parseFloat(sbkv)
85+
expect(Number.isFinite(winrate)).toBe(true)
86+
expect(winrate).toBeGreaterThanOrEqual(0)
87+
expect(winrate).toBeLessThanOrEqual(100)
88+
89+
await page.evaluate(() => window.__sabaki.stopAnalysis())
90+
await detachAndWait(page, [syncerId])
91+
})
92+
})

playwright.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ module.exports = defineConfig({
1414
dependencies: ['smoke'],
1515
},
1616
{name: 'engine', testMatch: /engine\.spec\.js/, dependencies: ['smoke']},
17+
{
18+
name: 'engine-analysis',
19+
testMatch: /engine-analysis\.spec\.js/,
20+
dependencies: ['smoke'],
21+
},
1722
{
1823
name: 'move-numbers',
1924
testMatch: /move-numbers\.spec\.js/,

test/analysisTests.js

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import assert from 'assert'
2+
import {readFileSync, existsSync} from 'fs'
3+
import {join} from 'path'
4+
import {fromDimensions as newBoard} from '@sabaki/go-board'
5+
6+
import {parseAnalysis} from '../src/modules/analysis.js'
7+
8+
// These tests verify the GTP analysis parser (the SBKV / scoreLead extraction
9+
// path) against GOLDEN TRANSCRIPTS (real `info ...` lines recorded from real
10+
// engines by scripts/engine-transcripts/capture.mjs). The fixtures are replayed
11+
// here so we test the exact output dialects engines emit, deterministically and
12+
// without an engine at test time.
13+
//
14+
// Regenerate the fixtures with `npm run gen:engine-transcripts` after adding an engine
15+
// version or SGF to scripts/engine-transcripts/engines.config.mjs.
16+
17+
const RES = join(__dirname, 'resources', 'engine-transcripts')
18+
const manifestPath = join(RES, 'manifest.json')
19+
20+
// The last `info` line of a transcript is the most-settled analysis update —
21+
// the one whose values ultimately get written to the node as SBKV/SBKS.
22+
function lastInfoLine(file) {
23+
let lines = readFileSync(join(RES, file), 'utf8')
24+
.split('\n')
25+
.filter((l) => l.startsWith('info '))
26+
return lines[lines.length - 1]
27+
}
28+
29+
if (!existsSync(manifestPath)) {
30+
describe('parseAnalysis (golden engine transcripts)', () => {
31+
it('requires generated transcripts — run `npm run gen:engine-transcripts`', () => {
32+
assert.fail(`missing ${manifestPath}`)
33+
})
34+
})
35+
} else {
36+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
37+
38+
describe('parseAnalysis (golden engine transcripts)', () => {
39+
it('has at least one captured transcript', () => {
40+
assert(manifest.cells.length > 0, 'no transcript cells in manifest')
41+
})
42+
43+
for (const cell of manifest.cells) {
44+
describe(`${cell.engineId} · ${cell.sgf} · ${cell.command}`, () => {
45+
const board = newBoard(cell.boardSize, cell.boardSize)
46+
const variations = parseAnalysis(lastInfoLine(cell.file), board)
47+
48+
it('parses at least one variation', () => {
49+
assert(Array.isArray(variations) && variations.length > 0)
50+
})
51+
52+
it('extracts winrate as a percentage in [0, 100] (the SBKV domain)', () => {
53+
for (const v of variations) {
54+
assert(
55+
typeof v.winrate === 'number' && isFinite(v.winrate),
56+
`non-finite winrate: ${v.winrate}`,
57+
)
58+
assert(
59+
v.winrate >= 0 && v.winrate <= 100,
60+
`winrate out of range: ${v.winrate}`,
61+
)
62+
}
63+
})
64+
65+
it('extracts visits as non-negative integers', () => {
66+
// Engines legitimately report moves with `visits 0` (evaluated by
67+
// policy prior only, no playouts), as observed in real lz-analyze
68+
// output, so the invariant is non-negative, not strictly positive.
69+
for (const v of variations) {
70+
assert(
71+
Number.isInteger(v.visits) && v.visits >= 0,
72+
`bad visits: ${v.visits}`,
73+
)
74+
}
75+
})
76+
77+
it('extracts a legal top-move vertex and a pv move list', () => {
78+
for (const v of variations) {
79+
assert(
80+
v.vertex == null ||
81+
(Array.isArray(v.vertex) && v.vertex.length === 2),
82+
`bad vertex: ${JSON.stringify(v.vertex)}`,
83+
)
84+
assert(Array.isArray(v.moves), 'pv moves should be an array')
85+
}
86+
})
87+
88+
if (cell.command === 'kata-analyze') {
89+
it('extracts scoreLead as a finite number (KataGo dialect → SBKS)', () => {
90+
for (const v of variations) {
91+
assert(
92+
typeof v.scoreLead === 'number' && isFinite(v.scoreLead),
93+
`expected numeric scoreLead, got: ${v.scoreLead}`,
94+
)
95+
}
96+
})
97+
} else if (cell.command === 'lz-analyze') {
98+
it('reports scoreLead as null (Leela-Zero dialect has no score)', () => {
99+
for (const v of variations) {
100+
assert.strictEqual(
101+
v.scoreLead,
102+
null,
103+
`expected null scoreLead, got: ${v.scoreLead}`,
104+
)
105+
}
106+
})
107+
}
108+
})
109+
}
110+
})
111+
}
112+
113+
// Focused unit cases for the parser's own normalization logic. These use minimal
114+
// hand-written lines (not engine-format assumptions) to pin specific branches
115+
// that a captured snapshot may not deterministically contain.
116+
describe('parseAnalysis (parser logic)', () => {
117+
const board = newBoard(19, 19)
118+
119+
it('returns [] when the line carries no info segments', () => {
120+
assert.deepStrictEqual(parseAnalysis('= ', board), [])
121+
})
122+
123+
it('normalizes integer (Leela-Zero) winrate ten-thousandths to a percentage', () => {
124+
let [v] = parseAnalysis(
125+
'info move Q16 visits 10 winrate 5213 pv Q16 D4',
126+
board,
127+
)
128+
assert(Math.abs(v.winrate - 52.13) < 1e-9, `got ${v.winrate}`)
129+
assert.strictEqual(v.scoreLead, null)
130+
})
131+
132+
it('normalizes float (KataGo) winrate and reads scoreLead', () => {
133+
let [v] = parseAnalysis(
134+
'info move Q16 visits 10 winrate 0.5213 scoreLead 1.5 pv Q16 D4',
135+
board,
136+
)
137+
assert(Math.abs(v.winrate - 52.13) < 1e-9, `got ${v.winrate}`)
138+
assert(Math.abs(v.scoreLead - 1.5) < 1e-9, `got ${v.scoreLead}`)
139+
})
140+
141+
it('parses multiple variations from one line', () => {
142+
let vs = parseAnalysis(
143+
'info move Q16 visits 20 winrate 0.55 scoreLead 2 pv Q16 D4 ' +
144+
'info move D4 visits 10 winrate 0.45 scoreLead -1 pv D4 Q16',
145+
board,
146+
)
147+
assert.strictEqual(vs.length, 2)
148+
assert(Math.abs(vs[0].winrate - 55) < 1e-9)
149+
assert(Math.abs(vs[1].scoreLead - -1) < 1e-9)
150+
})
151+
152+
it('truncates the principal variation at a pass', () => {
153+
let [v] = parseAnalysis(
154+
'info move Q16 visits 10 winrate 0.5 pv Q16 pass D4',
155+
board,
156+
)
157+
assert.strictEqual(v.moves.length, 1)
158+
})
159+
})

test/engines/replayEngine.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// A fake GTP engine that REPLAYS a recorded analysis transcript.
2+
//
3+
// Used by the engine-analysis e2e test to drive Sabaki's real
4+
// analyze → parseAnalysis → SBKV pipeline deterministically, using genuine
5+
// engine `info ...` lines captured by scripts/engine-transcripts/capture.mjs —
6+
// no live engine (or GPU) needed at test time.
7+
//
8+
// It speaks GTP directly (rather than via @sabaki/gtp's Engine helper) because
9+
// the analyze protocol streams `info` lines while `stop` must be handled
10+
// concurrently — the helper processes commands strictly sequentially and would
11+
// deadlock. Response framing matches what KataGo emits:
12+
// - normal command: "= <content>\n\n"
13+
// - analyze command: "=\n" then streamed "info ...\n" lines, with the blank
14+
// line terminator withheld until `stop` arrives.
15+
//
16+
// Usage (args, parsed loosely):
17+
// node replayEngine.js --transcript <path> [--analyze-command kata-analyze]
18+
19+
const {createInterface} = require('readline')
20+
const {readFileSync} = require('fs')
21+
22+
const args = process.argv.slice(2)
23+
const opt = (name, fallback) => {
24+
let i = args.indexOf(name)
25+
return i >= 0 && i + 1 < args.length ? args[i + 1] : fallback
26+
}
27+
28+
const transcriptPath = opt('--transcript')
29+
const analyzeCommand = opt('--analyze-command', 'kata-analyze')
30+
31+
const infoLines = transcriptPath
32+
? readFileSync(transcriptPath, 'utf8')
33+
.split('\n')
34+
.filter((l) => l.startsWith('info '))
35+
: []
36+
37+
const baseCommands = [
38+
'protocol_version',
39+
'name',
40+
'version',
41+
'known_command',
42+
'list_commands',
43+
'quit',
44+
'boardsize',
45+
'clear_board',
46+
'komi',
47+
'play',
48+
'undo',
49+
'genmove',
50+
'stop',
51+
]
52+
const supported = [...new Set([...baseCommands, analyzeCommand])]
53+
54+
const out = (s) => process.stdout.write(s)
55+
const ok = (content = '') => out(`= ${content}\n\n`)
56+
const err = (msg) => out(`? ${msg}\n\n`)
57+
58+
let streamTimer = null
59+
let streamIndex = 0
60+
61+
function startStreaming() {
62+
if (infoLines.length === 0) {
63+
// No recorded lines: still open a valid (empty) analyze response.
64+
out('=\n')
65+
return
66+
}
67+
// Open the analyze response, then emit one recorded update per tick, cycling,
68+
// until `stop` closes it.
69+
out('=\n')
70+
let tick = () => {
71+
out(infoLines[streamIndex % infoLines.length] + '\n')
72+
streamIndex++
73+
}
74+
tick()
75+
streamTimer = setInterval(tick, 100)
76+
}
77+
78+
function stopStreaming() {
79+
if (streamTimer != null) {
80+
clearInterval(streamTimer)
81+
streamTimer = null
82+
}
83+
// Blank line terminates the still-open analyze response.
84+
out('\n')
85+
}
86+
87+
createInterface({input: process.stdin}).on('line', (raw) => {
88+
let line = raw.replace(/#.*$/, '').trim()
89+
if (line === '') return
90+
91+
// Strip an optional leading command id (GTP allows it; our controller omits).
92+
let parts = line.split(/\s+/)
93+
if (/^\d+$/.test(parts[0])) parts.shift()
94+
let [name, ...rest] = parts
95+
96+
if (name === analyzeCommand) {
97+
startStreaming()
98+
return
99+
}
100+
101+
if (name === 'stop') {
102+
if (streamTimer != null || infoLines.length > 0) stopStreaming()
103+
ok()
104+
return
105+
}
106+
107+
switch (name) {
108+
case 'protocol_version':
109+
return ok('2')
110+
case 'name':
111+
return ok('Replay Engine')
112+
case 'version':
113+
return ok('1.0')
114+
case 'list_commands':
115+
return ok(supported.join('\n'))
116+
case 'known_command':
117+
return ok(supported.includes(rest[0]) ? 'true' : 'false')
118+
case 'genmove':
119+
return ok('pass')
120+
case 'quit':
121+
ok()
122+
return process.exit(0)
123+
default:
124+
// Be lenient about board-setup commands (boardsize, clear_board, komi,
125+
// play, undo, handicap, …) so position sync never fails.
126+
return ok()
127+
}
128+
})

0 commit comments

Comments
 (0)