Files
mesh-catalog/modules/brother/cmd/brother/service_test.go
T
jschoubben d5f9cd465c
mesh/merge-gate head d5f9cd465c96 merged into trunk d62c2edc35f3 — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head d5f9cd465c96 merged into trunk d62c2edc35f3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Send brother's scan settings on one line and scan from the feeder, both sides, into one PDF (issue 1452)
The Brother ignores the whole body when it holds a line break, so every setting was ignored and the glass was scanned at its default.
2026-10-21 01:05:55 +02:00

337 lines
12 KiB
Go

package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
type said struct {
body map[string]any
id string
// seen is whether the scan was kept in the bucket when it was said.
seen bool
}
// scanRig is the tools, the device worker and a bucket in memory over a settings file for the fake scanner and a
// scan folder of the test's own, with the real defaults laid under what s sets.
func scanRig(t *testing.T, f *fakeScanner, s Settings) (*printerRig, string) {
t.Helper()
if s.Scanner == "" {
s.Scanner = f.base()
}
if s.Folder == "" {
s.Folder = filepath.Join(t.TempDir(), "scans")
}
if s.Resolution == 0 {
s.Resolution = 300
}
if s.ColorMode == "" {
s.ColorMode = "color"
}
js, _ := json.Marshal(map[string]any{"scanner": s.Scanner, "scan-folder": s.Folder, "resolution": s.Resolution,
"color-mode": s.ColorMode, "crop": s.Crop})
return newRig(t, string(js), "pw"), s.Folder
}
func (r *printerRig) scan(t *testing.T) (map[string]any, error) {
t.Helper()
got, err := r.call(t, "scanner.scan", map[string]any{})
if err != nil {
return nil, err
}
return got.(map[string]any), nil
}
// 1440/AC2: asked 300 dpi, the scanner gave 200: the event's resolution and millimetres, and the PDF's page,
// are the image's own.
func TestTheFiledScanIsAtTheImagesOwnResolution(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, _ := scanRig(t, f, Settings{Crop: false, Resolution: 300})
body, err := r.scan(t)
if err != nil {
t.Fatal(err)
}
if body["resolution"] != float64(200) {
t.Errorf("resolution = %v, want the image's 200 (300 was asked)", body["resolution"])
}
if body["width-mm"] != 215.4 || body["height-mm"] != 279.8 {
t.Errorf("size = %v x %v mm, want 215.4 x 279.8 (1696 x 2203 px at 200 dpi)", body["width-mm"], body["height-mm"])
}
pdf, _ := os.ReadFile(body["path"].(string))
if !strings.Contains(string(pdf), "/MediaBox [0 0 610.56 793.08]") {
t.Errorf("the page is not 200 dpi's size")
}
}
// 1440/AC6 and 1453/AC9: one scan.added, said by the device process after the PDF is kept in the bucket under its
// sha256, with every key, the object's key and never the image; the tool then files the PDF.
func TestScanNowEmitsOneEventAfterTheScanIsKeptUnderTheSha256(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, folder := scanRig(t, f, Settings{Crop: true})
body, err := r.scan(t)
if err != nil {
t.Fatal(err)
}
out := *r.said
if len(out) != 1 {
t.Fatalf("%d events, want 1", len(out))
}
ev := out[0]
if !ev.seen {
t.Error("the event was said before the PDF was kept in the bucket")
}
for _, k := range []string{"object", "node", "bytes", "sha256", "width-mm", "height-mm", "resolution", "color-mode", "cropped", "pages", "scanned-at"} {
if _, ok := ev.body[k]; !ok {
t.Errorf("the event lacks %s", k)
}
}
if len(ev.body) != 11 {
t.Errorf("the event has %d keys, want the eleven named (no path: filing is the tool's)", len(ev.body))
}
pdf, err := os.ReadFile(body["path"].(string))
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(pdf)
want := hex.EncodeToString(sum[:])
if ev.body["sha256"] != want || ev.id != want || ev.body["object"] != want+".pdf" {
t.Errorf("sha256 %v / message id %v / object %v, want the file's %s", ev.body["sha256"], ev.id, ev.body["object"], want)
}
if kept, err := r.objects.Get(t.Context(), want+".pdf"); err != nil || !bytes.Equal(kept, pdf) {
t.Errorf("the bucket does not hold the filed PDF under its sha256 (%v)", err)
}
if ev.body["bytes"] != len(pdf) || ev.body["node"] != "shanks" || ev.body["cropped"] != true || ev.body["color-mode"] != "color" {
t.Errorf("event = %v", ev.body)
}
if want := fixedNow.UTC().Format(time.RFC3339); ev.body["scanned-at"] != want {
t.Errorf("scanned-at = %v, want %v", ev.body["scanned-at"], want)
}
if bytes.Contains(pdf, []byte("scanned-at")) || len(body) != 12 {
t.Errorf("the tool's answer has %d keys, want the event's eleven and the path", len(body))
}
if _, has := body["key"]; has {
t.Errorf("the device process's own answer keys leak into the tool's: %v", body)
}
// The cropped scan is the receipt, about 80-84 mm wide (1440/AC3).
if w := ev.body["width-mm"].(float64); w < 80 || w > 84 {
t.Errorf("width = %v mm", w)
}
files, _ := os.ReadDir(folder)
if len(files) != 1 || filepath.Join(folder, files[0].Name()) != body["path"] {
t.Errorf("folder holds %v, want the one scan", files)
}
}
// 1453/AC9: the same scan twice is one object in the bucket: a key is kept once.
func TestTheSameScanTwiceIsOneObject(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, _ := scanRig(t, f, Settings{Crop: true})
if _, err := r.scan(t); err != nil {
t.Fatal(err)
}
f.mu.Lock()
_ = f.documents
f.mu.Unlock()
if _, err := r.scan(t); err != nil {
t.Fatal(err)
}
if r.objects.puts != 1 || len(r.objects.keys()) != 1 {
t.Errorf("%d puts, objects %v: want the one", r.objects.puts, r.objects.keys())
}
}
// 1440/AC6: a scanner that refuses keeps nothing, writes nothing, says nothing, and the error says why.
func TestAScannerRefusalKeepsWritesAndSaysNothing(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
f.refuse = 503
r, folder := scanRig(t, f, Settings{})
_, err := r.scan(t)
if err == nil || !strings.Contains(err.Error(), "503") {
t.Fatalf("error = %v, want the refusal said", err)
}
if len(*r.said) != 0 || len(r.objects.keys()) != 0 {
t.Errorf("%d events, objects %v after a refusal", len(*r.said), r.objects.keys())
}
if _, err := os.Stat(folder); !os.IsNotExist(err) {
t.Errorf("the folder exists after a refusal: %v", err)
}
}
// 1440/AC6: a job that gives no page (the second NextDocument's 404) is the same.
func TestAJobThatGivesNoPageKeepsWritesAndSaysNothing(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
f.noPage = true
r, folder := scanRig(t, f, Settings{})
if _, err := r.scan(t); err == nil || !strings.Contains(err.Error(), "404") {
t.Fatalf("error = %v", err)
}
if len(*r.said) != 0 || len(r.objects.keys()) != 0 {
t.Errorf("%d events, objects %v", len(*r.said), r.objects.keys())
}
if _, err := os.Stat(folder); !os.IsNotExist(err) {
t.Errorf("the folder exists: %v", err)
}
}
// 1440/AC6: an image that is no JPEG (a scanner that sent something else) keeps and files nothing.
func TestAnImageThatIsNotAJPEGKeepsAndFilesNothing(t *testing.T) {
f := newFakeScanner(t, []byte("not an image"))
r, folder := scanRig(t, f, Settings{})
if _, err := r.scan(t); err == nil {
t.Fatal("a non-image was filed")
}
if len(*r.said) != 0 || len(r.objects.keys()) != 0 {
t.Errorf("%d events, objects %v", len(*r.said), r.objects.keys())
}
if _, err := os.Stat(folder); !os.IsNotExist(err) {
t.Errorf("the folder exists: %v", err)
}
}
// 1440/AC11: the first scan into a folder that is not there makes it.
func TestTheFirstScanMakesTheFolder(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, folder := scanRig(t, f, Settings{})
if _, err := r.scan(t); err != nil {
t.Fatal(err)
}
info, err := os.Stat(folder)
if err != nil || info.Mode().Perm() != 0o755 {
t.Fatalf("folder %v %v", info, err)
}
}
func settingsFile(t *testing.T, json string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(p, []byte(json), 0o600); err != nil {
t.Fatal(err)
}
return p
}
func tool(t *testing.T, tl []stdio.Tool, name string) stdio.Tool {
t.Helper()
for _, x := range tl {
if x.Name == name {
return x
}
}
t.Fatalf("no tool %s", name)
return stdio.Tool{}
}
// 1440/AC7: scanner or folder unset: every tool says which, and nothing is written or asked.
func TestEveryToolSaysWhichValueIsUnsetAndWritesNothing(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
for _, c := range []struct{ json, want string }{
{`{"scanner":"` + "%s" + `","scan-folder":""}`, "scan-folder"},
{`{"scanner":"","scan-folder":"FOLDER"}`, "scanner"},
{`{}`, "scanner and scan-folder"},
} {
folder := filepath.Join(t.TempDir(), "scans")
js := strings.ReplaceAll(strings.Replace(c.json, "%s", f.base(), 1), "FOLDER", folder)
r := newRig(t, js, "pw")
out := *r.said
for _, name := range []string{"scanner.scan", "scanner.status", "scanner.list"} {
_, err := tool(t, r.tl, name).Run(map[string]any{})
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s with %s: error = %v, want it to name %q", name, c.json, err, c.want)
}
}
if f.posts != 0 || f.documents != 0 || len(out) != 0 || r.bus.asks() != 0 || len(r.objects.keys()) != 0 {
t.Errorf("the scanner was asked, an ask queued or an event said: %d posts, %d documents, %d events, %d asks", f.posts, f.documents, len(out), r.bus.asks())
}
if _, err := os.Stat(folder); !os.IsNotExist(err) {
t.Errorf("the folder was made: %v", err)
}
}
}
// 1440/AC7: the settings file is not there at all.
func TestAMissingSettingsFileIsSaid(t *testing.T) {
tl := tools(filepath.Join(t.TempDir(), "none.json"), time.Now)
if _, err := tool(t, tl, "scanner.status").Run(nil); err == nil || !strings.Contains(err.Error(), "settings") {
t.Fatalf("error = %v", err)
}
}
// 1440/AC8: scan_status from the captured idle status; a scanner that does not answer is answers false.
func TestScanStatusSaysStateAndAdfState(t *testing.T) {
f := newFakeScanner(t, nil)
svc := &Service{Settings: Settings{Scanner: f.base(), Folder: "/x"}}
got, err := svc.ScanStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if got["answers"] != true || got["state"] != "Idle" || got["adf-state"] != "ScannerAdfEmpty" {
t.Fatalf("status = %v", got)
}
f.Close()
got, err = svc.ScanStatus(context.Background())
if err != nil || got["answers"] != false || got["why"] == "" {
t.Fatalf("a scanner that is gone: %v, %v", got, err)
}
}
// 1440/AC8: scan_list through the tool, newest first.
func TestScanListToolListsTheNewestFirst(t *testing.T) {
folder := t.TempDir()
base := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
for i, n := range []string{"scan-1.pdf", "scan-2.pdf"} {
p := filepath.Join(folder, n)
os.WriteFile(p, []byte("x"), 0o644)
os.Chtimes(p, base.Add(time.Duration(i)*time.Hour), base.Add(time.Duration(i)*time.Hour))
}
tl := tools(settingsFile(t, `{"scanner":"http://192.0.2.10/eSCL","scan-folder":"`+folder+`"}`), time.Now)
got, err := tool(t, tl, "scanner.list").Run(map[string]any{"limit": float64(5)})
if err != nil {
t.Fatal(err)
}
scans := got.(map[string]any)["scans"].([]Entry)
if len(scans) != 2 || filepath.Base(scans[0].Path) != "scan-2.pdf" {
t.Fatalf("scans = %v", scans)
}
if _, err := tool(t, tl, "scanner.list").Run(map[string]any{"limit": float64(0)}); err == nil {
t.Error("limit 0 accepted")
}
}
// 1440/AC1: the whole path through the tool, settings read from the file, with the real defaults.
func TestScanNowToolScansWithTheFileSettings(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
folder := filepath.Join(t.TempDir(), "scans")
r := newRig(t, `{"scanner":"`+f.base()+`","scan-folder":"`+folder+`","resolution":300,"color-mode":"color","crop":true}`, "pw")
got, err := r.call(t, "scanner.scan", map[string]any{})
if err != nil {
t.Fatal(err)
}
if len(*r.said) != 1 || got.(map[string]any)["cropped"] != true {
t.Fatalf("%d events, body %v", len(*r.said), got)
}
}
func TestLoadSettingsLaysTheDefaultsUnder(t *testing.T) {
s, err := LoadSettings(settingsFile(t, `{"scanner":" http://192.0.2.10/eSCL ","scan-folder":"/srv/scans"}`))
if err != nil {
t.Fatal(err)
}
if s.Scanner != "http://192.0.2.10/eSCL" || s.Folder != "/srv/scans" || s.Resolution != 300 || s.ColorMode != "color" || !s.Crop {
t.Fatalf("settings = %+v", s)
}
s, _ = LoadSettings(settingsFile(t, `{"resolution":600,"color-mode":"gray","crop":false}`))
if s.Resolution != 600 || s.ColorMode != "gray" || s.Crop {
t.Fatalf("settings = %+v", s)
}
}