Files
mesh-catalog/modules/brother/cmd/brother/feeder_test.go
T
jschoubben 3757d75d6a
mesh/merge-gate head 3757d75d6acf merged into trunk 9aa97926141f — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head 3757d75d6acf merged into trunk 9aa97926141f — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Wait for the background scan's outcome in the test instead of reading it once, and say where brother_health sits (issue 1552)
2026-10-21 02:08:50 +02:00

308 lines
12 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"os"
"strings"
"testing"
"time"
)
// 1452: the Brother ignores the whole body when it holds a line break before the closing root tag, and runs its
// default job (Platen, 200 dpi). The body is one line.
func TestScanSettingsAreOneLineAndTheCapturedRequest(t *testing.T) {
caps, err := parseCapabilities(fixture(t, "capabilities.xml"))
if err != nil {
t.Fatal(err)
}
body, err := ScanSettings(caps, ScanOptions{Resolution: 200, ColorMode: "color", Feeder: true, Duplex: true})
if err != nil {
t.Fatal(err)
}
end := strings.Index(body, "</scan:ScanSettings>")
if end < 0 || strings.Contains(body[:end], "\n") {
t.Fatalf("the body has a line break before the closing root tag: %q", body)
}
// It is the request the real scanner took to feed three sheets, both sides (testdata/escl/settings.xml).
if want := strings.TrimSpace(string(fixture(t, "settings.xml"))); strings.TrimSpace(body) != want {
t.Fatalf("not the captured request\n got: %s\nwant: %s", body, want)
}
glass, _ := ScanSettings(caps, ScanOptions{Resolution: 300, ColorMode: "gray"})
if strings.Contains(glass[:strings.Index(glass, "</scan:ScanSettings>")], "\n") || strings.Contains(glass, "Duplex") ||
!strings.Contains(glass, "<pwg:InputSource>Platen</pwg:InputSource>") ||
!strings.Contains(glass, "<pwg:Width>2480</pwg:Width><pwg:Height>3507</pwg:Height>") {
t.Fatalf("the glass body is not one line of Platen, A4 cut to the glass's 3507: %q", glass)
}
}
// 1452: the fake is the real scanner in that: a body with a line break gets the defaults.
func TestTheFakeScannerIgnoresABodyWithALineBreak(t *testing.T) {
multi := strings.Replace(string(fixture(t, "settings.xml")), "<pwg:Version>", "\n<pwg:Version>", 1)
if j := readSettings(multi); j.Source != "Platen" || j.Resolution != 200 || j.Duplex {
t.Fatalf("a body with a line break was read as %+v", j)
}
if j := readSettings(strings.TrimSpace(string(fixture(t, "settings.xml"))) + "\n"); j.Source != "Feeder" || !j.Duplex || j.Resolution != 200 {
t.Fatalf("the captured body with a trailing newline was read as %+v", j)
}
}
// 1452: three sheets, both sides, are six images from one job, fetched until NextDocument answers 404.
func TestAFeederDuplexScanOfThreeSheetsGivesSixPagesAndClosesTheJob(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
f.sheets = 3
pages, err := NewClient(f.base()).ScanPages(context.Background(), ScanOptions{Resolution: 200, ColorMode: "color", Feeder: true, Duplex: true})
if err != nil {
t.Fatal(err)
}
if f.job.Source != "Feeder" || !f.job.Duplex || f.job.Resolution != 200 || f.job.Region != "2480x3508" {
t.Fatalf("the scanner took %+v", f.job)
}
if len(pages) != 6 {
t.Fatalf("%d pages, want 6", len(pages))
}
if f.open || f.deletes != 0 || f.documents != 7 {
t.Fatalf("job open %v, %d DELETEs, %d NextDocument requests, want closed, 0, 7 (6 pages and the 404)", f.open, f.deletes, f.documents)
}
}
// 1452: an empty feeder is an error that says so, and no job stays open.
func TestAnEmptyFeederIsAnErrorAndLeavesNoJob(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
f.sheets = 0
_, err := NewClient(f.base()).ScanPages(context.Background(), ScanOptions{Resolution: 200, ColorMode: "color", Feeder: true})
if err == nil || !strings.Contains(err.Error(), "feeder") {
t.Fatalf("error = %v, want it to name the feeder", err)
}
if f.open {
t.Fatal("the job stays open")
}
}
// 1452: a feeder that never says 404 is cut at 100 pages and the job is deleted.
func TestAFeederThatNeverEndsIsCutAndTheJobDeleted(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
f.sheets = 500
_, err := NewClient(f.base()).ScanPages(context.Background(), ScanOptions{Resolution: 100, ColorMode: "gray", Feeder: true})
if err == nil || !strings.Contains(err.Error(), "100") {
t.Fatalf("error = %v, want the 100-page bound", err)
}
if f.open || f.deletes != 1 {
t.Fatalf("job open %v, %d DELETEs, want closed by 1 DELETE", f.open, f.deletes)
}
}
// 1452: a page that cannot be had in the middle of a feeder job deletes the job.
func TestAFeederJobThatDropsMidwayIsDeleted(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
f.sheets = 3
f.dropConn = true
if _, err := NewClient(f.base()).ScanPages(context.Background(), ScanOptions{Resolution: 200, ColorMode: "color", Feeder: true}); err == nil {
t.Fatal("a lost answer is a scan")
}
if f.open || f.deletes != 1 {
t.Fatalf("job open %v, %d DELETEs, want closed by 1 DELETE", f.open, f.deletes)
}
}
func scanArgs(source string, duplex bool) map[string]any {
a := map[string]any{}
if source != "" {
a["source"] = source
}
if duplex {
a["duplex"] = true
}
return a
}
// 1452: through the tool, a duplex feeder scan is ONE PDF of six pages, filed once, and scan.added says pages 6.
func TestTheToolFilesOneSixPagePDFForAFeederDuplexScan(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
f.sheets = 3
r, folder := scanRig(t, f, Settings{Crop: true, Resolution: 200})
got, err := r.call(t, "scanner.scan", scanArgs("feeder", true))
if err != nil {
t.Fatal(err)
}
body := got.(map[string]any)
if body["pages"] != float64(6) || body["cropped"] != false {
t.Errorf("pages = %v, cropped = %v, want 6, false (a feeder page is a whole sheet)", body["pages"], body["cropped"])
}
pdf, err := os.ReadFile(body["path"].(string))
if err != nil {
t.Fatal(err)
}
if n := bytes.Count(pdf, []byte("/Type /Page /Parent")); n != 6 || !bytes.Contains(pdf, []byte("/Count 6")) {
t.Errorf("the PDF has %d page objects, want 6 and /Count 6", n)
}
if f.open || f.deletes != 0 || f.posts != 1 {
t.Errorf("job open %v, %d DELETEs, %d posts", f.open, f.deletes, f.posts)
}
if entries, _ := os.ReadDir(folder); len(entries) != 1 {
t.Errorf("%d files in the scan folder, want 1", len(entries))
}
if out := *r.said; len(out) != 1 || out[0].body["pages"] != 6 || !out[0].seen {
t.Errorf("events = %+v, want one scan.added with pages 6, said after the scan was kept", out)
}
}
// 1452: the glass is one page, and duplex is for the feeder.
func TestDuplexOnTheGlassIsRefusedAndNothingIsAsked(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, _ := scanRig(t, f, Settings{})
for _, args := range []map[string]any{scanArgs("", true), scanArgs("glass", true)} {
_, err := r.call(t, "scanner.scan", args)
if err == nil || !strings.Contains(err.Error(), "duplex") || !strings.Contains(err.Error(), "feeder") {
t.Errorf("%v: error = %v, want duplex is for the feeder", args, err)
}
}
if _, err := r.call(t, "scanner.scan", scanArgs("tray", false)); err == nil || !strings.Contains(err.Error(), "glass or feeder") {
t.Errorf("an unknown source: error = %v", err)
}
if r.bus.asks() != 0 || f.posts != 0 {
t.Errorf("%d asks queued, %d jobs posted", r.bus.asks(), f.posts)
}
}
// pdfCapture is a stand-in capture of a small PDF, ready to be released.
func standInCapture(release <-chan struct{}, fail error) func(ctx context.Context, req ScanRequest) ([]Capture, error) {
return func(ctx context.Context, req ScanRequest) ([]Capture, error) {
<-release
if fail != nil {
return nil, fail
}
pdf := pdfOf(5000)
return []Capture{{PDF: pdf, SHA256: sha256Hex(pdf), Resolution: 200, Pages: 3, At: fixedNow}}, nil
}
}
// waitFor polls until the condition holds.
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
for i := 0; i < 400; i++ {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
// 1552 and 1539: a feeder scan not done within the quick wait is answered as started with its id; the scan goes on
// and is filed although the caller has gone; the id reads the outcome, running and then done with the path.
func TestALongFeederScanAnswersStartedAndIsFiledWithoutTheCaller(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, folder := scanRig(t, f, Settings{})
old := quickWait
quickWait = 50 * time.Millisecond
t.Cleanup(func() { quickWait = old })
release := make(chan struct{})
r.worker.Capture = standInCapture(release, nil)
got, err := r.call(t, "scanner.scan", scanArgs("feeder", false))
if err != nil {
t.Fatal(err)
}
start := got.(map[string]any)
id, _ := start["id"].(string)
if start["started"] != true || id == "" || start["path"] != nil {
t.Fatalf("answer = %v, want started with an id", start)
}
running, err := r.call(t, "scanner.scan", map[string]any{"id": id})
if err != nil || running.(map[string]any)["state"] != "running" {
t.Fatalf("while it runs: %v, %v", running, err)
}
close(release)
// Nobody asks again: the scan is filed all the same.
waitFor(t, "the scan to be filed", func() bool { e, _ := os.ReadDir(folder); return len(e) == 1 })
// The file is renamed into place before the job is marked done: read the outcome until it is.
var d map[string]any
waitFor(t, "the outcome to be done", func() bool {
got, err := r.call(t, "scanner.scan", map[string]any{"id": id})
if err != nil {
t.Fatal(err)
}
d = got.(map[string]any)
return d["state"] != "running"
})
if d["state"] != "done" || d["pages"] != float64(3) || d["path"] == nil {
t.Fatalf("outcome = %v, want done with pages and the path", d)
}
if _, err := os.Stat(d["path"].(string)); err != nil {
t.Error(err)
}
}
// 1552: a scan that fails after the caller was told it started is read as failed, with why; an unknown id is an
// error that says so.
func TestAFailedLongScanIsReadAsFailedWithWhy(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, _ := scanRig(t, f, Settings{})
old := quickWait
quickWait = 50 * time.Millisecond
t.Cleanup(func() { quickWait = old })
release := make(chan struct{})
r.worker.Capture = standInCapture(release, errors.New("the scanner dropped the job: 410 Gone"))
start, err := r.call(t, "scanner.scan", scanArgs("feeder", true))
if err != nil {
t.Fatal(err)
}
id := start.(map[string]any)["id"].(string)
close(release)
var out map[string]any
waitFor(t, "the failure", func() bool {
got, _ := r.call(t, "scanner.scan", map[string]any{"id": id})
out = got.(map[string]any)
return out["state"] == "failed"
})
if !strings.Contains(out["why"].(string), "410 Gone") {
t.Errorf("why = %v", out["why"])
}
if _, err := r.call(t, "scanner.scan", map[string]any{"id": "nosuchid"}); err == nil || !strings.Contains(err.Error(), "nosuchid") {
t.Errorf("an unknown id: %v", err)
}
}
// 1539: the feeder job's bound scales with its pages, from 3 to 30 minutes.
func TestTheFeederBoundScalesWithThePages(t *testing.T) {
for pages, want := range map[int]time.Duration{1: 3 * time.Minute, 9: 3 * time.Minute, 30: 10 * time.Minute, 100: 30 * time.Minute, 500: 30 * time.Minute} {
if got := FeederBound(pages); got != want {
t.Errorf("FeederBound(%d) = %s, want %s", pages, got, want)
}
}
f := newFakeScanner(t, fixture(t, "scan.jpg"))
f.sheets = 3
_, err := NewClient(f.base()).ScanPages(context.Background(), ScanOptions{Resolution: 200, ColorMode: "color", Feeder: true,
Bound: func(int) time.Duration { return 0 }})
if err == nil || !strings.Contains(err.Error(), "bound") {
t.Fatalf("error = %v, want the bound", err)
}
if f.open || f.deletes != 1 {
t.Errorf("job open %v, %d DELETEs, want the job deleted", f.open, f.deletes)
}
}
// 1539: a scan too large for the tool to fetch is not kept nor announced: it is refused with its size.
func TestAScanTooLargeToFetchIsNeitherKeptNorAnnounced(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, folder := scanRig(t, f, Settings{})
release := make(chan struct{})
close(release)
huge := make([]byte, MostAnswerParts*AnswerPartSize+1)
r.worker.Capture = func(ctx context.Context, req ScanRequest) ([]Capture, error) {
return []Capture{{PDF: huge, SHA256: "x", Resolution: 200, Pages: 99, At: fixedNow}}, nil
}
_, err := r.call(t, "scanner.scan", scanArgs("feeder", false))
if err == nil || !strings.Contains(err.Error(), "not kept") {
t.Fatalf("error = %v", err)
}
if len(*r.said) != 0 {
t.Errorf("%d events said for a scan not kept", len(*r.said))
}
if e, _ := os.ReadDir(folder); len(e) != 0 {
t.Errorf("filed: %v", e)
}
}