673 lines
22 KiB
Go
673 lines
22 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"textmachine/backend/internal/chunk/chunktest"
|
|
"textmachine/backend/internal/obs"
|
|
"textmachine/backend/internal/runevents"
|
|
"textmachine/backend/internal/store"
|
|
)
|
|
|
|
// runevents_test.go: the driver's half of the run-event seam (row 103) — the stream a real run leaves
|
|
// behind, read back exactly as the platform's tailer reads it.
|
|
|
|
// readJournal returns the journal beside a book config, as decoded envelopes and as raw lines.
|
|
func readJournal(t *testing.T, bookPath string) ([]runevents.Envelope, [][]byte) {
|
|
t.Helper()
|
|
body, err := os.ReadFile(filepath.Join(filepath.Dir(bookPath), runevents.JournalFile))
|
|
if err != nil {
|
|
t.Fatalf("read journal: %v", err)
|
|
}
|
|
if len(body) > 0 && body[len(body)-1] != '\n' {
|
|
t.Fatalf("the journal ends mid-line — a line and its newline leave in one write: %q", tail(body))
|
|
}
|
|
var envs []runevents.Envelope
|
|
var raw [][]byte
|
|
for _, line := range strings.Split(strings.TrimSuffix(string(body), "\n"), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var ev runevents.Envelope
|
|
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
|
t.Fatalf("malformed journal line %q: %v", line, err)
|
|
}
|
|
envs = append(envs, ev)
|
|
raw = append(raw, []byte(line))
|
|
}
|
|
return envs, raw
|
|
}
|
|
|
|
func tail(b []byte) string {
|
|
if len(b) > 120 {
|
|
return string(b[len(b)-120:])
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// payload decodes one event's data.
|
|
func payload[T any](t *testing.T, ev runevents.Envelope) T {
|
|
t.Helper()
|
|
var v T
|
|
if err := json.Unmarshal(ev.Data, &v); err != nil {
|
|
t.Fatalf("decode %s payload: %v", ev.Type, err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
// assertOneStream checks the invariants the reader enforces over ONE process's region: it opens with a
|
|
// hello at seq 1 and every following number is exactly one more than the last.
|
|
func assertOneStream(t *testing.T, envs []runevents.Envelope) {
|
|
t.Helper()
|
|
if len(envs) == 0 {
|
|
t.Fatal("empty stream")
|
|
}
|
|
if envs[0].Type != runevents.TypeHello || envs[0].Seq != 1 {
|
|
t.Fatalf("a stream must open with hello at seq 1, got %s at seq %d", envs[0].Type, envs[0].Seq)
|
|
}
|
|
for i, ev := range envs {
|
|
if ev.Seq != int64(i+1) {
|
|
t.Fatalf("seq %d at position %d — the numbering must be dense, a gap is fatal to the reader", ev.Seq, i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// assertProgressSane checks what a progress bar is allowed to do: never pass its own denominator, and
|
|
// never go backwards. A reader ASSIGNS these counters, so both would be visible to a user.
|
|
func assertProgressSane(t *testing.T, envs []runevents.Envelope) {
|
|
t.Helper()
|
|
var prev runevents.Progress
|
|
for _, ev := range envs {
|
|
if ev.Type != runevents.TypeProgress {
|
|
continue
|
|
}
|
|
p := payload[runevents.Progress](t, ev)
|
|
if p.Draft.Done > p.Draft.Total || p.Edit.Done > p.Edit.Total {
|
|
t.Fatalf("progress %+v passes its own denominator at seq %d", p, ev.Seq)
|
|
}
|
|
if p.Draft.Done < prev.Draft.Done || p.Edit.Done < prev.Edit.Done {
|
|
t.Fatalf("progress went backwards at seq %d: %+v after %+v", ev.Seq, p, prev)
|
|
}
|
|
prev = p
|
|
}
|
|
}
|
|
|
|
// regions splits a journal into its per-PROCESS streams: a resumed run appends a second hello to the
|
|
// same file, which is exactly what the tailer keys on.
|
|
func regions(envs []runevents.Envelope) [][]runevents.Envelope {
|
|
var out [][]runevents.Envelope
|
|
for _, ev := range envs {
|
|
if ev.Type == runevents.TypeHello {
|
|
out = append(out, nil)
|
|
}
|
|
if len(out) == 0 {
|
|
continue
|
|
}
|
|
out[len(out)-1] = append(out[len(out)-1], ev)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func tracedCtx() context.Context {
|
|
return obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
|
}
|
|
|
|
func TestARunLeavesAStreamThatOpensWithItsHandshake(t *testing.T) {
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, draftEdit)
|
|
defer srv.Close()
|
|
bookPath := setupProject(t, srv.URL)
|
|
|
|
r := newRunner(t, bookPath)
|
|
if _, err := r.TranslateBook(tracedCtx()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Close()
|
|
|
|
envs, _ := readJournal(t, bookPath)
|
|
assertOneStream(t, envs)
|
|
assertProgressSane(t, envs)
|
|
|
|
h := payload[runevents.Hello](t, envs[0])
|
|
if h.StreamVersion != runevents.StreamVersion || h.BookID != "test-book" || h.EngineRunID == "" {
|
|
t.Fatalf("handshake does not identify the stream: %+v", h)
|
|
}
|
|
if h.ChunkerVersion != chunkerVersion {
|
|
t.Fatalf("hello must carry the chunker version the manifest was cut by: %q", h.ChunkerVersion)
|
|
}
|
|
|
|
seen := map[runevents.Type]int{}
|
|
for _, ev := range envs {
|
|
seen[ev.Type]++
|
|
}
|
|
for _, want := range []runevents.Type{runevents.TypeProgress, runevents.TypeUnitDone, runevents.TypeSpend, runevents.TypeFinished} {
|
|
if seen[want] == 0 {
|
|
t.Errorf("a clean run must announce %s", want)
|
|
}
|
|
}
|
|
last := envs[len(envs)-1]
|
|
if last.Type != runevents.TypeFinished {
|
|
t.Fatalf("the last line of a finished run must be `finished`, got %s", last.Type)
|
|
}
|
|
if got := payload[runevents.Finished](t, last).Outcome; got != runevents.OutcomeClean {
|
|
t.Fatalf("outcome %q, want %q", got, runevents.OutcomeClean)
|
|
}
|
|
|
|
// The counters are in OUTPUT UNITS and both waves finish the one unit this fixture has.
|
|
var lastProgress runevents.Progress
|
|
for _, ev := range envs {
|
|
if ev.Type == runevents.TypeProgress {
|
|
lastProgress = payload[runevents.Progress](t, ev)
|
|
}
|
|
}
|
|
if lastProgress.Draft != (runevents.Counter{Done: 1, Total: 1}) || lastProgress.Edit != (runevents.Counter{Done: 1, Total: 1}) {
|
|
t.Fatalf("final counters %+v, want 1/1 in both waves", lastProgress)
|
|
}
|
|
}
|
|
|
|
func TestEveryLineOnTheFileIsTheOutboxRowVerbatim(t *testing.T) {
|
|
// The projection is a COPY, never a re-render: the reader compares a re-read line against the sha256
|
|
// it stored, so a line rebuilt with a fresher timestamp reads as corruption and quarantines the run's
|
|
// projection. This is the property that makes retrying a failed journal write safe.
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, draftEdit)
|
|
defer srv.Close()
|
|
bookPath := setupProject(t, srv.URL)
|
|
|
|
r := newRunner(t, bookPath)
|
|
if _, err := r.TranslateBook(tracedCtx()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
envs, raw := readJournal(t, bookPath)
|
|
runID := payload[runevents.Hello](t, envs[0]).EngineRunID
|
|
stored, err := r.Store.PendingEvents(runID, 0, 1_000_000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Close()
|
|
|
|
if len(stored) != len(raw) {
|
|
t.Fatalf("the outbox holds %d lines, the file %d", len(stored), len(raw))
|
|
}
|
|
for i := range raw {
|
|
if stored[i].Seq != int64(i+1) || string(stored[i].Line) != string(raw[i]) {
|
|
t.Fatalf("line %d diverged:\n file %s\noutbox %s", i+1, raw[i], stored[i].Line)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAResumedRunOpensItsOwnStreamAndDoesNotRecountFinishedUnits(t *testing.T) {
|
|
// A resumed run walks every finished unit again at $0. `unit_done` is the vocabulary's only COUNTING
|
|
// event, so re-announcing those units would add a whole book to a reader's chapter counters on every
|
|
// resume — and `progress`, which a reader ASSIGNS, must not walk its bar backwards either.
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, draftEdit)
|
|
defer srv.Close()
|
|
bookPath := setupProject(t, srv.URL)
|
|
|
|
r1 := newRunner(t, bookPath)
|
|
if _, err := r1.TranslateBook(tracedCtx()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r1.Close()
|
|
|
|
r2 := newRunner(t, bookPath)
|
|
if _, err := r2.TranslateBook(tracedCtx()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r2.Close()
|
|
|
|
envs, _ := readJournal(t, bookPath)
|
|
rs := regions(envs)
|
|
if len(rs) != 2 {
|
|
t.Fatalf("want two process regions in one book journal, got %d", len(rs))
|
|
}
|
|
first, second := rs[0], rs[1]
|
|
for _, region := range rs {
|
|
assertOneStream(t, region)
|
|
assertProgressSane(t, region)
|
|
}
|
|
idA := payload[runevents.Hello](t, first[0]).EngineRunID
|
|
idB := payload[runevents.Hello](t, second[0]).EngineRunID
|
|
if idA == idB {
|
|
t.Fatal("each process must name itself: the idempotency key is (engine_run_id, seq)")
|
|
}
|
|
for _, ev := range second {
|
|
if ev.Type == runevents.TypeUnitDone {
|
|
t.Fatalf("the resumed run re-announced a unit it only replayed at $0: %s", ev.Data)
|
|
}
|
|
}
|
|
// Its first progress line carries the BOOK's state, not this process's work.
|
|
for _, ev := range second {
|
|
if ev.Type == runevents.TypeProgress {
|
|
p := payload[runevents.Progress](t, ev)
|
|
if p.Draft.Done != 1 || p.Edit.Done != 1 {
|
|
t.Fatalf("a resumed run's first counters %+v — a bar that starts at zero walks backwards", p)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestACeilingHaltAnnouncesItselfAndNamesWhichCeiling(t *testing.T) {
|
|
// PD-113: the stop is resumable, so it is not a failure — and the platform's contract forbids calling
|
|
// it `failed`. Before this event, a ceiling stop reached the platform only as exit 1.
|
|
for _, c := range []struct {
|
|
name string
|
|
bookUSD float64
|
|
dayUSD float64
|
|
wantScope string
|
|
}{
|
|
{"the book ceiling", 0.0000001, 2.0, runevents.ScopeBook},
|
|
{"the day ceiling", 1.0, 0.0000001, runevents.ScopeDay},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, draftEdit)
|
|
defer srv.Close()
|
|
bookPath := setupCeilingProject(t, srv.URL, c.bookUSD, c.dayUSD)
|
|
|
|
r := newRunner(t, bookPath)
|
|
_, err := r.TranslateBook(tracedCtx())
|
|
r.Close()
|
|
|
|
var halt *CeilingHalt
|
|
if !errors.As(err, &halt) {
|
|
t.Fatalf("want a typed ceiling halt, got %v", err)
|
|
}
|
|
if halt.Scope != c.wantScope {
|
|
t.Fatalf("scope %q, want %q", halt.Scope, c.wantScope)
|
|
}
|
|
if !errors.Is(err, errReserveCeiling) {
|
|
t.Fatal("the typed halt must keep matching the sentinel every degrade path uses")
|
|
}
|
|
|
|
envs, _ := readJournal(t, bookPath)
|
|
assertOneStream(t, envs)
|
|
if len(envs) < 2 {
|
|
t.Fatalf("want the ceiling and the terminal line, got %d events", len(envs))
|
|
}
|
|
ceiling, finished := envs[len(envs)-2], envs[len(envs)-1]
|
|
if ceiling.Type != runevents.TypeCeiling {
|
|
t.Fatalf("want a ceiling event before the terminal line, got %s", ceiling.Type)
|
|
}
|
|
c2 := payload[runevents.Ceiling](t, ceiling)
|
|
if !c2.Halted || c2.Scope != c.wantScope {
|
|
t.Fatalf("ceiling event %+v, want halted with scope %q", c2, c.wantScope)
|
|
}
|
|
if got := payload[runevents.Finished](t, finished).Outcome; got != runevents.OutcomeCeiling {
|
|
t.Fatalf("terminal outcome %q, want %q — `failed` is what the contract forbids here", got, runevents.OutcomeCeiling)
|
|
}
|
|
// Money never leaves the engine except inside `spend`: the halt says THAT, never how much.
|
|
if body := string(ceiling.Data); strings.Contains(body, "$") || strings.Contains(body, "usd") {
|
|
t.Fatalf("the ceiling event must carry the fact and no figures: %s", body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAReadOnlyProjectionWritesNoStream(t *testing.T) {
|
|
// `status`/`report` describe a run; they do not run one. Appending to a stream that says "a process
|
|
// is working on this book" from a read-only command would be a lie a reader acts on.
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, draftEdit)
|
|
defer srv.Close()
|
|
bookPath := setupProject(t, srv.URL)
|
|
|
|
r := newRunner(t, bookPath)
|
|
if _, err := r.TranslateBook(tracedCtx()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Close()
|
|
before, err := os.ReadFile(filepath.Join(filepath.Dir(bookPath), runevents.JournalFile))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ro, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ro.Status(tracedCtx()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ro.Close()
|
|
|
|
after, err := os.ReadFile(filepath.Join(filepath.Dir(bookPath), runevents.JournalFile))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(before) != string(after) {
|
|
t.Fatal("a read-only projection appended to the run stream")
|
|
}
|
|
}
|
|
|
|
func TestTheStreamCountsUnitsAcrossAMultiChapterBook(t *testing.T) {
|
|
// The draft wave fans out over CHUNKS, the seam counts OUTPUT UNITS: one announcement per unit per
|
|
// wave, no more and no fewer, so a reader's per-chapter counters land on the manifest's own totals.
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, draftEdit)
|
|
defer srv.Close()
|
|
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
|
waveWorkers: 3,
|
|
epub: []chunktest.Chapter{
|
|
{ID: "c1", Href: "ch1.xhtml", Body: `<p>静かな図書館の朝。</p>`},
|
|
{ID: "c2", Href: "ch2.xhtml", Body: `<p>静かな図書館の昼。</p>`},
|
|
{ID: "c3", Href: "ch3.xhtml", Body: `<p>静かな図書館の夜。</p>`},
|
|
},
|
|
spine: []string{"c1", "c2", "c3"},
|
|
})
|
|
|
|
r := newRunner(t, bookPath)
|
|
res, err := r.TranslateBook(tracedCtx())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Close()
|
|
|
|
envs, _ := readJournal(t, bookPath)
|
|
assertOneStream(t, envs)
|
|
assertProgressSane(t, envs)
|
|
perWave := map[string]map[int]int{runevents.WaveDraft: {}, runevents.WaveEdit: {}}
|
|
for _, ev := range envs {
|
|
if ev.Type != runevents.TypeUnitDone {
|
|
continue
|
|
}
|
|
u := payload[runevents.UnitDone](t, ev)
|
|
perWave[u.Wave][u.Chapter*1000+u.Unit]++
|
|
}
|
|
for wave, units := range perWave {
|
|
if len(units) != len(res.Chunks) {
|
|
t.Fatalf("%s wave announced %d units, the run produced %d", wave, len(units), len(res.Chunks))
|
|
}
|
|
for key, n := range units {
|
|
if n != 1 {
|
|
t.Fatalf("%s wave announced unit %d %d times — the reader folds this by increment", wave, key, n)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// setupCeilingProject is setupProject with an explicit day ceiling, so the two ceiling scopes can be
|
|
// exercised apart. (setupProjectOpts pins day_usd at 2.0.)
|
|
func setupCeilingProject(t *testing.T, providerURL string, bookUSD, dayUSD float64) string {
|
|
t.Helper()
|
|
base := setupProjectOpts(t, providerURL, projectOpts{bookUSD: 1.0})
|
|
body, err := os.ReadFile(base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
patched := strings.Replace(string(body), "ceilings: { book_usd: 1, day_usd: 2.0 }",
|
|
fmt.Sprintf("ceilings: { book_usd: %g, day_usd: %g }", bookUSD, dayUSD), 1)
|
|
if patched == string(body) {
|
|
t.Fatalf("the ceilings line moved; fixture needs updating:\n%s", body)
|
|
}
|
|
writeFile(t, base, patched)
|
|
return base
|
|
}
|
|
|
|
// storedRows is the book's dispositions, for the crash test's cross-check.
|
|
func storedRows(t *testing.T, r *Runner) []store.ChunkStatus {
|
|
t.Helper()
|
|
rows, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func TestAReusedRunIdDoesNotReopenTheEarlierStream(t *testing.T) {
|
|
// Since row 102 the CALLER supplies the run id (TM_TRACE_ID), and a caller that exports it in a
|
|
// wrapper reuses it. The seam's identity is per PROCESS, so a reused id used to make the second run
|
|
// continue the first one's numbering: its projection cursor starts at zero, so it re-appended the
|
|
// whole earlier stream and then wrote its handshake at a seq far past 1. A reader re-applies every
|
|
// counting event it already applied and finally refuses the handshake (hello must carry seq 1).
|
|
// Measured on the real binary before the guard: 33 lines, 15 byte-identical duplicates, hello at 16.
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, draftEdit)
|
|
defer srv.Close()
|
|
bookPath := setupProject(t, srv.URL)
|
|
reused := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: "a-caller-that-reuses-its-id"})
|
|
|
|
for i := 0; i < 2; i++ {
|
|
r := newRunner(t, bookPath)
|
|
if _, err := r.TranslateBook(reused); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Close()
|
|
}
|
|
|
|
envs, raw := readJournal(t, bookPath)
|
|
seen := map[string]int{}
|
|
for _, line := range raw {
|
|
seen[string(line)]++
|
|
}
|
|
for line, n := range seen {
|
|
if n > 1 {
|
|
t.Fatalf("line appears %d times — the second run re-projected the first one's stream: %s", n, line)
|
|
}
|
|
}
|
|
rs := regions(envs)
|
|
if len(rs) != 2 {
|
|
t.Fatalf("want one region per process, got %d", len(rs))
|
|
}
|
|
for _, region := range rs {
|
|
assertOneStream(t, region) // each opens with hello at seq 1 and is dense
|
|
}
|
|
ids := map[string]bool{}
|
|
for _, region := range rs {
|
|
ids[payload[runevents.Hello](t, region[0]).EngineRunID] = true
|
|
}
|
|
if len(ids) != 2 {
|
|
t.Fatalf("the two streams must not share an identity: %v", ids)
|
|
}
|
|
}
|
|
|
|
func TestAJournalThatCannotBeOpenedDoesNotStopThePaidRun(t *testing.T) {
|
|
// The PD-60 policy this pack argues for at length — degrade loudly, never let the journal stop the
|
|
// run — was exercised by nothing. A paid book must not die because a freshness side-channel is
|
|
// unavailable: the money protection is the platform's hold and the engine's own ceiling, neither of
|
|
// which depends on this file.
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, draftEdit)
|
|
defer srv.Close()
|
|
bookPath := setupProject(t, srv.URL)
|
|
// A directory where the journal should be: every open of it fails, for the whole run.
|
|
if err := os.Mkdir(filepath.Join(filepath.Dir(bookPath), runevents.JournalFile), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
r := newRunner(t, bookPath)
|
|
res, err := r.TranslateBook(tracedCtx())
|
|
r.Close()
|
|
if err != nil {
|
|
t.Fatalf("an unusable journal must not fail the run: %v", err)
|
|
}
|
|
if len(res.Chunks) != 1 || res.Flagged != 0 {
|
|
t.Fatalf("the run must have completed normally, got %+v", res)
|
|
}
|
|
if rec.count() == 0 {
|
|
t.Fatal("the run made no provider calls — it did not actually run")
|
|
}
|
|
}
|
|
|
|
func TestWhatTheUnitDidTravelsWithIt(t *testing.T) {
|
|
// shipped/flagged/reason are the payload a reader derives unit state from — shipped→translated,
|
|
// flagged without text→withheld. Nothing asserted them, so dropping `reason` or collapsing shipped
|
|
// into !flagged passed the whole battery.
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, func(body string) (string, string) {
|
|
if isEditBody(body) {
|
|
return "", "stop" // the edit produces nothing usable → the unit is flagged and ships nothing
|
|
}
|
|
return "ЧЕРНОВИК ПЕРЕВОДА", "stop"
|
|
})
|
|
defer srv.Close()
|
|
bookPath := setupProject(t, srv.URL)
|
|
|
|
r := newRunner(t, bookPath)
|
|
if _, err := r.TranslateBook(tracedCtx()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Close()
|
|
|
|
envs, _ := readJournal(t, bookPath)
|
|
var draft, edit *runevents.UnitDone
|
|
for _, ev := range envs {
|
|
if ev.Type != runevents.TypeUnitDone {
|
|
continue
|
|
}
|
|
u := payload[runevents.UnitDone](t, ev)
|
|
switch u.Wave {
|
|
case runevents.WaveDraft:
|
|
draft = &u
|
|
case runevents.WaveEdit:
|
|
edit = &u
|
|
}
|
|
}
|
|
if draft == nil || edit == nil {
|
|
t.Fatalf("both waves must announce their unit, got draft=%v edit=%v", draft, edit)
|
|
}
|
|
if !draft.Shipped || draft.Flagged || draft.Reason != "" {
|
|
t.Fatalf("the draft resolved cleanly and must say so: %+v", *draft)
|
|
}
|
|
if edit.Shipped || !edit.Flagged || edit.Reason == "" {
|
|
t.Fatalf("a flagged unit that ships nothing must carry flagged + a reason and shipped=false: %+v", *edit)
|
|
}
|
|
}
|
|
|
|
func TestTheJournalFollowsTheBookNotTheProjectDatabase(t *testing.T) {
|
|
// The whole reason config.Book.Dir exists. The platform tails `events.jsonl` in the directory it
|
|
// spawned the run in — the one holding book.yaml — while `project_db` may point anywhere; pointing
|
|
// the journal at the database's directory instead passed every other test.
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, draftEdit)
|
|
defer srv.Close()
|
|
bookPath := setupProject(t, srv.URL)
|
|
dir := filepath.Dir(bookPath)
|
|
if err := os.Mkdir(filepath.Join(dir, "state"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := os.ReadFile(bookPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, bookPath, string(body)+"\nproject_db: state/test-book.db\n")
|
|
|
|
r := newRunner(t, bookPath)
|
|
if _, err := r.TranslateBook(tracedCtx()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Close()
|
|
|
|
if _, err := os.Stat(filepath.Join(dir, runevents.JournalFile)); err != nil {
|
|
t.Fatalf("the journal must sit beside book.yaml: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "state", runevents.JournalFile)); err == nil {
|
|
t.Fatal("the journal followed the project database instead of the book")
|
|
}
|
|
}
|
|
|
|
func TestTheStreamCarriesAnEstimateOnceItHasMeasuredOne(t *testing.T) {
|
|
// Omitting the field is not leaving it alone: the consumer's progress handler ASSIGNS the column
|
|
// (`eta_seconds = $6` with etaOrNil), so an absent field decodes to 0 and NULLs on every line the
|
|
// estimate the `status --json` resync had just written. The first line carries none — nothing has
|
|
// been measured yet — and every line after a resolved unit must.
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, draftEdit)
|
|
defer srv.Close()
|
|
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
|
epub: []chunktest.Chapter{
|
|
{ID: "c1", Href: "ch1.xhtml", Body: `<p>静かな図書館の朝。</p>`},
|
|
{ID: "c2", Href: "ch2.xhtml", Body: `<p>静かな図書館の昼。</p>`},
|
|
{ID: "c3", Href: "ch3.xhtml", Body: `<p>静かな図書館の夜。</p>`},
|
|
},
|
|
spine: []string{"c1", "c2", "c3"},
|
|
})
|
|
|
|
r := newRunner(t, bookPath)
|
|
if _, err := r.TranslateBook(tracedCtx()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Close()
|
|
|
|
envs, _ := readJournal(t, bookPath)
|
|
withETA, withWorkLeft := 0, 0
|
|
for _, ev := range envs {
|
|
if ev.Type != runevents.TypeProgress {
|
|
continue
|
|
}
|
|
p := payload[runevents.Progress](t, ev)
|
|
left := (p.Draft.Total - p.Draft.Done) + (p.Edit.Total - p.Edit.Done)
|
|
if left == 0 {
|
|
continue // a finished book has nothing to estimate
|
|
}
|
|
withWorkLeft++
|
|
if p.ETASeconds > 0 {
|
|
withETA++
|
|
}
|
|
}
|
|
if withWorkLeft == 0 {
|
|
t.Fatal("no progress line reported work remaining — the fixture cannot exercise this")
|
|
}
|
|
if withETA == 0 {
|
|
t.Fatalf("%d progress lines reported work remaining and none carried an estimate — each of them NULLs the reader's column", withWorkLeft)
|
|
}
|
|
}
|
|
|
|
func TestTwoBooksSharingOneProjectDatabaseBothGetAnnounced(t *testing.T) {
|
|
// The announce-once ledger lives in the project database, and `project_db` is a path a config may
|
|
// share. Without the book in the key, the second book's units collide with the first book's and are
|
|
// announced never — its chapters would sit at zero forever with no repair channel.
|
|
rec := &reqRec{}
|
|
srv := newJSONProvider(rec, draftEdit)
|
|
defer srv.Close()
|
|
first := setupProject(t, srv.URL)
|
|
dir := filepath.Dir(first)
|
|
|
|
body, err := os.ReadFile(first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, filepath.Join(dir, "source2.txt"), "静かな図書館の夜。")
|
|
second := filepath.Join(dir, "book2.yaml")
|
|
writeFile(t, second, strings.NewReplacer(
|
|
"book_id: test-book", "book_id: other-book",
|
|
"source_file: source.txt", "source_file: source2.txt",
|
|
).Replace(string(body))+"\nproject_db: test-book.db\n")
|
|
|
|
for _, path := range []string{first, second} {
|
|
r := newRunner(t, path)
|
|
if _, err := r.TranslateBook(tracedCtx()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Close()
|
|
}
|
|
|
|
envs, _ := readJournal(t, bookPathOf(dir))
|
|
perBook := map[string]int{}
|
|
book := ""
|
|
for _, ev := range envs {
|
|
if ev.Type == runevents.TypeHello {
|
|
book = payload[runevents.Hello](t, ev).BookID
|
|
}
|
|
if ev.Type == runevents.TypeUnitDone {
|
|
perBook[book]++
|
|
}
|
|
}
|
|
for _, id := range []string{"test-book", "other-book"} {
|
|
if perBook[id] == 0 {
|
|
t.Fatalf("book %q announced no unit; the ledger key collided across books: %v", id, perBook)
|
|
}
|
|
}
|
|
}
|
|
|
|
// bookPathOf names the journal's directory for readJournal, which takes a config path.
|
|
func bookPathOf(dir string) string { return filepath.Join(dir, "book.yaml") }
|