textmachine/backend/internal/pipeline/waverun_test.go

471 lines
22 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 pipeline
import (
"context"
"errors"
"math"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/obs"
)
// waverun_test.go: the R1 wave-driver behaviours the 1-chunk-per-chapter fixtures cannot exercise — a
// MULTI-CHUNK edit unit (the editor collapses several draft chunks into one edit call over their
// concatenation, addressed by the leader), parallel-worker money conservation, a flagged member draft
// skipping the unit's edit, and the bank-mining stop gate.
// cjkSource builds a single-chapter ja source of `paras` paragraphs, each `hanPerPara` Han chars + a full
// stop, separated by a blank line — enough to make the chunker split it into several draft chunks.
func cjkSource(paras, hanPerPara int) string {
p := make([]string, paras)
for i := range p {
p[i] = strings.Repeat("文", hanPerPara) + "。"
}
return strings.Join(p, "\n\n")
}
// TestWaveMultiChunkEditUnit is the load-bearing R1 pin: a chapter that splits into TWO draft chunks but
// groups into ONE edit unit. The editor must run ONCE over the concatenation of the two members' drafts
// (never re-rendering either draft), the unit's outcome is a single ChunkOutcome at the leader, and export
// projects one row — not two "pending" chunks.
func TestWaveMultiChunkEditUnit(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// para1 est_out ≈ 1677 (≤ draft budget 1797 → its own chunk); para2 est_out ≈ 1318 forces a second
// chunk; together ≈ 2995 ≤ edit ceiling 3200 → ONE edit unit with both members.
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(1, 1400) + "\n\n" + strings.Repeat("字", 1100) + "。", regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
// Confirm the fixture actually produces the 2-chunk / 1-unit shape the test targets.
r0 := newRunner(t, bookPath)
manifest, err := r0.bookChunks()
r0.Close()
if err != nil {
t.Fatal(err)
}
units := buildEditUnits(manifest)
if len(manifest) != 2 || len(units) != 1 || len(units[0].Members) != 2 {
t.Fatalf("fixture must be 2 draft chunks in 1 edit unit, got %d chunks / %d units", len(manifest), len(units))
}
r1 := newRunner(t, bookPath)
res, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
r1.Close()
// One outcome PER UNIT, at the leader chunk, carrying both member drafts + the single edit stage.
if len(res.Chunks) != 1 {
t.Fatalf("a 2-chunk unit must yield ONE unit outcome, got %d", len(res.Chunks))
}
oc := res.Chunks[0]
if oc.ChunkIdx != 0 {
t.Fatalf("the unit outcome must address the leader chunk (0), got %d", oc.ChunkIdx)
}
if len(oc.Stages) != 3 {
t.Fatalf("the unit outcome must carry 2 member drafts + 1 edit, got %d stages", len(oc.Stages))
}
if oc.Stages[0].Role != roleTranslator || oc.Stages[1].Role != roleTranslator || oc.Stages[2].Role != roleEditor {
t.Fatalf("stages must be [draft, draft, edit], got %s/%s/%s", oc.Stages[0].Role, oc.Stages[1].Role, oc.Stages[2].Role)
}
if oc.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("the unit's final text is the single edit output, got %q", oc.FinalText)
}
// EXACTLY 3 provider calls: 2 drafts + 1 edit — the editor did NOT re-render either draft.
if rec.count() != 3 {
t.Fatalf("multi-chunk unit must be 2 draft + 1 edit calls, got %d", rec.count())
}
// The editor's request carried the CONCATENATION of both members' drafts (joined by the unit separator).
var editBody string
for _, b := range rec.all() {
if isEditBody(b) {
editBody = b
}
}
// Both members' drafts appear in the editor's input (the body JSON-escapes the join, so count the
// draft marker rather than match the raw separator).
if n := strings.Count(editBody, "ЧЕРНОВИК ПЕРЕВОДА"); n != 2 {
t.Fatalf("the editor must read BOTH member drafts concatenated, found %d in the edit body", n)
}
// Export projects ONE unit row (not 2, and not a phantom "pending" for the non-leader member).
r2 := newRunner(t, bookPath)
exp, err := r2.Export(true)
if err != nil {
t.Fatal(err)
}
if exp.TotalUnits != 1 || exp.PendingUnits != 0 || len(exp.Chunks) != 1 {
t.Fatalf("export must be 1 unit / 0 pending, got total=%d pending=%d rows=%d", exp.TotalUnits, exp.PendingUnits, len(exp.Chunks))
}
if exp.Chunks[0].FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("export unit text mismatch: %q", exp.Chunks[0].FinalText)
}
// --pairs source is the members' joined source (the src↔target pair aligns to the editor's input).
if !strings.Contains(exp.Chunks[0].Source, unitJoinSeparator) {
t.Fatalf("export --pairs source must be the joined unit source, got %q", exp.Chunks[0].Source[:min(40, len(exp.Chunks[0].Source))])
}
// Status counts the unit as ONE done chunk (not two).
st, err := r2.Status(ctx)
if err != nil {
t.Fatal(err)
}
if st.TotalUnits != 1 || st.Done != 1 {
t.Fatalf("status must be 1 unit done, got total=%d done=%d", st.TotalUnits, st.Done)
}
r2.Close()
// Resume is $0 (all checkpoints hit; the editor re-derives its content-addressed id for free).
r3 := newRunner(t, bookPath)
defer r3.Close()
before := rec.count()
res2, err := r3.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if rec.count() != before || res2.TotalUSD != 0 {
t.Fatalf("resume must be $0 with no new calls: calls=%d usd=%v", rec.count()-before, res2.TotalUSD)
}
}
// TestRunWaveSurfacesParentCancellation pins the cancellation contract: when the PARENT context is
// cancelled (a Ctrl-C / SIGTERM during a live run — main.go wires a signal.NotifyContext), runWave must
// surface that as an ERROR, never a silent nil. The feeder drops the remaining items on ctx.Done(), and a
// $0 resume worker never touches the cancellable ctx (its store ops use their own opContext), so firstErr
// stays nil — without a parent.Err() check runWave returns nil with items UNDONE, and the driver then
// indexes uninitialised result slots (an edit pipeline nil-derefs *oc; a draft-only run reports empty
// translations as exit-0 success). A cancelled parent → a non-nil error is the fix's whole point.
func TestRunWaveSurfacesParentCancellation(t *testing.T) {
r := &Runner{} // runWave touches no Runner field
ctx, cancel := context.WithCancel(context.Background())
cancel() // the operator hit Ctrl-C before/at the wave boundary
// Workers return nil (a $0 resume worker never observes the cancellable ctx) — so firstErr stays nil
// and ONLY a parent-cancellation check can turn this into the error the driver needs.
err := r.runWave(ctx, 2, 8, func(context.Context, int) error { return nil })
if err == nil {
t.Fatal("runWave returned nil on a cancelled parent ctx — items are left undone and the driver indexes uninitialised result slots (nil-deref panic on an edit pipeline / silent exit-0 partial success on draft-only)")
}
}
// TestWaveParallelWorkersMoneyConserved runs the wave driver with several parallel workers over a book of
// many chunks and asserts the money invariant survives concurrency: committed spend == the sum of the
// settled checkpoints (the single-writer store serializes Reserve/Settle), and the per-unit result is
// complete + deterministic. Run under `-race` this also proves the waves share only read-only state.
//
// It also exercises the REAL internal/llm HTTP adapter under concurrency (D39.17-errata nit): the 4 workers
// fire concurrent requests through the real transport at an httptest.Server (setupProjectOpts writes a
// models.yaml pointing at the live URL — not a fake in-process client), so a shared-client data race would
// trip -race here. stdlib http.Client is safe for concurrent use by design; this pins it end-to-end.
func TestWaveParallelWorkersMoneyConserved(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// 5 paragraphs of ~1400 Han each → 5 draft chunks, and since any two exceed the 3200 edit ceiling,
// 5 edit units → 10 wave work items fanned over 4 workers.
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(5, 1400), regenerate: 0, waveWorkers: 4, bookUSD: 100})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
res, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if len(res.Chunks) != 5 {
t.Fatalf("5 chapters-worth of single-chunk units expected, got %d", len(res.Chunks))
}
for _, oc := range res.Chunks {
if oc.Disposition != DispOK || oc.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("every parallel unit must be ok+edited, got %s / %q", oc.Disposition, oc.FinalText)
}
}
// Money invariant under parallelism: the ledger's committed spend matches the run's own aggregate and
// no reservation leaked — the single-writer store serialized every Reserve/Settle despite N workers
// (the store-level atomicity + kill-9 durability is separately pinned by store/kill9_test.go).
committed, reserved, err := r1.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
r1.Close()
if reserved != 0 {
t.Fatalf("no reservation may leak after a clean parallel run, reserved=%v", reserved)
}
if math.Abs(res.TotalUSD-committed) > 1e-9 {
t.Fatalf("run total %v != ledger committed %v under parallel workers — a settle raced/leaked", res.TotalUSD, committed)
}
}
// TestWaveEditUnitFlaggedMemberDraft: when ONE member draft of a multi-chunk unit flags, the flagged member is
// DROPPED but the editor STILL runs over the CLEAN member(s) (c-lite) — the good, already-paid draft is edited
// and shipped, the UNIT is flagged for review (the dropped member's reason), the good member's draft money
// stays committed, and export agrees. Regression guard: the pre-c-lite code blanked the whole chapter-scale unit.
func TestWaveEditUnitFlaggedMemberDraft(t *testing.T) {
rec := &reqRec{}
// The second paragraph carries a marker; the mock returns an untranslated CJK echo for it → cjk_artifact.
const echoMarker = "禁"
respond := func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
if strings.Contains(body, echoMarker) {
return "这是完全没有翻译的中文内容。", "stop" // fully-CJK → classify flags cjk_artifact
}
return "ЧЕРНОВИК ПЕРЕВОДА", "stop"
}
srv := newJSONProvider(rec, respond)
defer srv.Close()
src := strings.Repeat("文", 1400) + "。\n\n" + strings.Repeat(echoMarker, 1100) + "。"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: src, regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if len(res.Chunks) != 1 {
t.Fatalf("still one unit outcome, got %d", len(res.Chunks))
}
oc := res.Chunks[0]
// The unit is FLAGGED (a member dropped) with the dropped member's reason — but it SHIPS the edited clean
// remainder, not "" (c-lite: don't blank a chapter over one bad chunk).
if oc.Disposition != DispFlagged || oc.FlagReason != FlagCJKArtifact {
t.Fatalf("a dropped member draft must flag the unit (cjk_artifact), got %s/%s", oc.Disposition, oc.FlagReason)
}
if oc.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("the unit must ship the edited CLEAN remainder, got %q", oc.FinalText)
}
// 2 draft calls (one ok, one echo) + 1 edit over the CLEAN member — the flagged member was dropped, not the
// whole unit skipped.
if rec.count() != 3 {
t.Fatalf("a dropped-member unit runs 2 drafts + 1 clean edit, got %d calls", rec.count())
}
// The editor read ONLY the clean member's draft — never the echoed member.
var editBody string
for _, b := range rec.all() {
if isEditBody(b) {
editBody = b
}
}
if strings.Count(editBody, "ЧЕРНОВИК ПЕРЕВОДА") != 1 || strings.Contains(editBody, "完全没有翻译") {
t.Fatalf("the edit must carry ONLY the clean member's draft (not the dropped echo)")
}
r.Close()
// Export agrees: the unit is flagged (a member dropped) yet SHIPS the edited clean remainder.
r2 := newRunner(t, bookPath)
defer r2.Close()
exp, err := r2.Export(false)
if err != nil {
t.Fatal(err)
}
if len(exp.Chunks) != 1 {
t.Fatalf("export must project one unit, got %d", len(exp.Chunks))
}
if exp.Chunks[0].Disposition != string(DispFlagged) || exp.Chunks[0].FlagReason != string(FlagCJKArtifact) {
t.Fatalf("export unit must be flagged (dropped member), got %s/%s", exp.Chunks[0].Disposition, exp.Chunks[0].FlagReason)
}
if exp.Chunks[0].FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("export must ship the edited clean remainder for a dropped-member unit, got %q", exp.Chunks[0].FinalText)
}
// Echo split (D39.18): the dropped member's echo is a TRANSLATOR echo → it counts in echo_draft
// (read DIRECTLY off the draft row, no c-lite re-derivation). The editor recovered the unit over the
// clean remainder, so its own output did NOT echo → echo_edit is 0. This is the load-bearing case the
// split fixes: the draft echo stays visible while the delivered (edit) quality is honestly clean.
q, err := r2.QualityReport()
if err != nil {
t.Fatal(err)
}
if q.EchoDraftChunks != 1 || q.EchoDraftRate != 0.5 { // 1 of 2 draft chunks echoed
t.Fatalf("the dropped member's echo must count in echo_draft, got EchoDraftChunks=%d rate=%.3f (want 1 / 0.5)", q.EchoDraftChunks, q.EchoDraftRate)
}
if q.EchoEditUnits != 0 || q.EchoEditRate != 0 {
t.Fatalf("the editor recovered the clean remainder → echo_edit must be 0, got EchoEditUnits=%d rate=%.3f", q.EchoEditUnits, q.EchoEditRate)
}
}
// TestWaveEditUnitAllMembersFlagged: when EVERY member draft of a multi-chunk unit flags there is nothing clean
// to edit — the unit ships "" and runs ZERO edit calls (the one case c-lite still blanks a unit).
func TestWaveEditUnitAllMembersFlagged(t *testing.T) {
rec := &reqRec{}
const echoMarker = "禁"
respond := func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "这是完全没有翻译的中文内容。", "stop" // every draft echoes → cjk_artifact
}
srv := newJSONProvider(rec, respond)
defer srv.Close()
src := strings.Repeat(echoMarker, 1400) + "。\n\n" + strings.Repeat(echoMarker, 1100) + "。"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: src, regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if len(res.Chunks) != 1 {
t.Fatalf("one unit outcome, got %d", len(res.Chunks))
}
oc := res.Chunks[0]
if oc.Disposition != DispFlagged || oc.FinalText != "" {
t.Fatalf("an all-flagged unit ships nothing, got %s / %q", oc.Disposition, oc.FinalText)
}
// 2 draft calls (both echo), ZERO edit calls — nothing clean to edit.
if rec.count() != 2 {
t.Fatalf("an all-flagged unit runs 2 drafts + 0 edit, got %d calls", rec.count())
}
}
// TestWaveEscalationBudgetSerializedUnderParallelism pins the escMu: two draft chunks escalate
// CONCURRENTLY under a budget that admits only ONE hop. The escalation soft-cap is a non-atomic
// read-then-act over EscalationSpentUSD, so without escMu both parallel workers could read spent<budget and
// both be admitted (overshoot by a whole hop). escMu serializes the admission through the paid settle, so
// exactly one hop fires — the same guarantee the sequential TestRunnerEscalationBudgetExhaustionDeniesLaterHop
// gives, now under parallel workers. (Run under -race this also proves the shared spend path is race-clean.)
func TestWaveEscalationBudgetSerializedUnderParallelism(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, echoOrClean) // primary echoes (cjk_artifact); fake-fallback cleans
defer srv.Close()
bookPath := setupTwoChapterEscalation(t, srv.URL, 0.001) // budget 0.001 < one hop (≈0.00182)
// Bump to parallel workers so both chapters' drafts escalate at once.
pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(pipePath)
if err != nil {
t.Fatal(err)
}
writeFile(t, pipePath, strings.Replace(string(raw), "core: C1", "core: C1\nwaves: { workers: 4 }", 1))
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
esc, err := r.Store.EscalationSpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
if diff := esc - fakeCallUSD; diff > 1e-12 || diff < -1e-12 {
t.Fatalf("escMu must serialize escalation admission under parallel workers: want exactly one hop (%.6f), got %.6f", fakeCallUSD, esc)
}
// Exactly one unit escalated-OK and one stayed flagged (WHICH one wins the single hop is
// scheduling-dependent, so assert the totals, not the identity).
escalatedOK, flagged := 0, 0
for _, oc := range res.Chunks {
if oc.Disposition == DispOK && len(oc.Stages) > 0 && oc.Stages[0].Escalated {
escalatedOK++
}
if oc.Disposition == DispFlagged {
flagged++
}
}
if escalatedOK != 1 || flagged != 1 {
t.Fatalf("exactly one unit escalates-OK and one stays flagged, got ok=%d flagged=%d", escalatedOK, flagged)
}
}
// TestWaveMinedSignDoesNotRebillDraft is the «overpay ONCE» pin AT THE INJECTION LEVEL (the checkpoint-
// review found the split was implemented only at the version-hash level): signing a mined term and re-running
// must move ONLY the edit wave, leaving the draft wave's checkpoints byte-stable. The draft selects over the
// BASE bank (mined-excluded), so a mined addition does NOT change the draft's injected wire → its content_hash
// / final_hash / snapshot are unchanged → the draft resumes at $0. The editor selects over the ENRICHED bank,
// so the mined term DOES move the edit-wave snapshot (the single, --resnapshot-gated overpay). Reverting the
// fix (draft over the enriched bank) makes the draft content_hash change here → the test fails.
func TestWaveMinedSignDoesNotRebillDraft(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// Source carries a BASE seed term (魔法学院) and a to-be-mined term (方源); both are ≥2-char Han keys.
src := "方源走进了魔法学院的图书馆。"
seed := "terms:\n - src: 魔法学院\n dst: Академия магии\n status: approved\n"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: src, glossarySeed: seed, regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
draftBefore, err := r1.Store.GetChunkStatus("test-book", 1, 0, "draft")
if err != nil || draftBefore == nil {
t.Fatalf("draft chunk_status after run 1: %v / %v", draftBefore, err)
}
editBefore, err := r1.Store.GetChunkStatus("test-book", 1, 0, "edit")
if err != nil || editBefore == nil {
t.Fatalf("edit chunk_status after run 1: %v / %v", editBefore, err)
}
r1.Close()
// Owner signs 方源 → Фан Юань (approved) into a mined-delta file (loaded as Source:mined) and re-runs
// with --resnapshot (the edit wave's enriched snapshot legitimately moves; the draft's must not).
dir := filepath.Dir(bookPath)
writeFile(t, filepath.Join(dir, "test-book.mined-delta.yaml"), "terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n")
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true
// The mined sign IS the ratified single overpay, so it now also needs the Р6 consent to that amount
// (D20.2-Q2, rebill.go): --resnapshot grants the re-pin, --accept-rebill consents to the spend.
r2.AcceptRebill = RebillConsent{Given: true}
if _, err := r2.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
draftAfter, err := r2.Store.GetChunkStatus("test-book", 1, 0, "draft")
if err != nil || draftAfter == nil {
t.Fatalf("draft chunk_status after run 2: %v / %v", draftAfter, err)
}
editAfter, err := r2.Store.GetChunkStatus("test-book", 1, 0, "edit")
if err != nil || editAfter == nil {
t.Fatalf("edit chunk_status after run 2: %v / %v", editAfter, err)
}
// «overpay ONCE»: the DRAFT wave is byte-stable across the mined sign — same snapshot, same rendered
// content, same authoritative checkpoint → it resumed at $0 (a mined term never entered the draft wire).
if draftAfter.SnapshotID != draftBefore.SnapshotID {
t.Fatalf("draft-wave snapshot moved on a mined sign (%.12s → %.12s) — base bank not excluding mined", draftBefore.SnapshotID, draftAfter.SnapshotID)
}
if draftAfter.ContentHash != draftBefore.ContentHash {
t.Fatalf("draft injection CHANGED on a mined sign — the draft selects over the ENRICHED bank (regression): the pay-once invariant is broken, the whole draft wave re-bills")
}
if draftAfter.FinalHash != draftBefore.FinalHash {
t.Fatalf("draft checkpoint re-created on a mined sign (%.12s → %.12s) — the draft was re-billed", draftBefore.FinalHash, draftAfter.FinalHash)
}
// The single, gated overpay DID land on the edit wave: the enriched snapshot moved.
if editAfter.SnapshotID == editBefore.SnapshotID {
t.Fatalf("edit-wave snapshot did NOT move on a mined sign — the enriched bank is not folding the mined term")
}
}
// TestWaveMiningUnconfiguredAutoContinues: with no langpack / no contrast (every existing fixture), the
// bank-mining stop is a no-op — the driver runs straight through to the edit wave and writes no signature
// map. Guards that the mining wiring never perturbs a normal $0 run.
func TestWaveMiningUnconfiguredAutoContinues(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
if r.pack != nil {
t.Fatal("no langpack_root → pack must be nil")
}
stopped, err := r.runBankMiningStop(ctx, nil, "", true)
if err != nil || stopped {
t.Fatalf("unconfigured mining must auto-continue, got stopped=%v err=%v", stopped, err)
}
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(r.signatureMapPath()); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("no signature map may be written when mining is unconfigured (err=%v)", err)
}
}