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
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.
184 lines
5.2 KiB
Go
184 lines
5.2 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// fixture reads a capture of the real scanner's answers (testdata/escl, 2026-10-20). Tests never reach a real scanner.
|
|
func fixture(t *testing.T, name string) []byte {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join("testdata", "escl", name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// fakeScanner serves the captured eSCL answers and records what it was asked.
|
|
type fakeScanner struct {
|
|
*httptest.Server
|
|
mu sync.Mutex
|
|
image []byte
|
|
postBody string
|
|
posts int
|
|
documents int // NextDocument requests
|
|
refuse int // when set, the status ScanJobs answers instead of 201
|
|
noPage bool
|
|
// A real scanner keeps a job open (and answers a new POST with 503) until its NextDocument has answered 404
|
|
// or the job is DELETEd; the fake does the same.
|
|
open bool
|
|
delivered bool
|
|
pages int // pages a job holds beyond the first
|
|
served int
|
|
deletes int
|
|
gone bool // the first NextDocument answers 410 (the job was cancelled at the device)
|
|
dropConn bool // the first NextDocument loses its answer
|
|
stuck bool // the job cannot be closed: NextDocument and DELETE both fail
|
|
// sheets is what the feeder holds; a duplex job gives two pages a sheet.
|
|
sheets int
|
|
// job is what the scanner took of the last body. The real Brother ignores the WHOLE body when it holds a line
|
|
// break before the closing root tag (issue 1452) and runs its default job: Platen, 200 dpi, one page.
|
|
job scannerJob
|
|
}
|
|
|
|
type scannerJob struct {
|
|
Source string
|
|
Resolution int
|
|
Duplex bool
|
|
Region string // width x height
|
|
}
|
|
|
|
var (
|
|
reSource = regexp.MustCompile(`<pwg:InputSource>(\w+)</pwg:InputSource>`)
|
|
reXRes = regexp.MustCompile(`<scan:XResolution>(\d+)</scan:XResolution>`)
|
|
reRegion = regexp.MustCompile(`<pwg:Width>(\d+)</pwg:Width><pwg:Height>(\d+)</pwg:Height>`)
|
|
)
|
|
|
|
// readSettings is the real scanner's reading of a body: a line break before the closing root tag and every setting
|
|
// is ignored.
|
|
func readSettings(body string) scannerJob {
|
|
end := strings.Index(body, "</scan:ScanSettings>")
|
|
if end < 0 || strings.Contains(body[:end], "\n") {
|
|
return scannerJob{Source: "Platen", Resolution: 200, Region: "1696x2203"}
|
|
}
|
|
j := scannerJob{Source: "Platen", Resolution: 200, Duplex: strings.Contains(body, "<scan:Duplex>true</scan:Duplex>")}
|
|
if m := reSource.FindStringSubmatch(body); m != nil {
|
|
j.Source = m[1]
|
|
}
|
|
if m := reXRes.FindStringSubmatch(body); m != nil {
|
|
j.Resolution, _ = strconv.Atoi(m[1])
|
|
}
|
|
if m := reRegion.FindStringSubmatch(body); m != nil {
|
|
j.Region = m[1] + "x" + m[2]
|
|
}
|
|
return j
|
|
}
|
|
|
|
// total is how many pages the job holds.
|
|
func (f *fakeScanner) total() int {
|
|
if f.job.Source == "Feeder" {
|
|
if f.job.Duplex {
|
|
return 2 * f.sheets
|
|
}
|
|
return f.sheets
|
|
}
|
|
return 1 + f.pages
|
|
}
|
|
|
|
func newFakeScanner(t *testing.T, image []byte) *fakeScanner {
|
|
t.Helper()
|
|
f := &fakeScanner{image: image}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/eSCL/ScannerCapabilities", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/xml")
|
|
w.Write(fixture(t, "capabilities.xml"))
|
|
})
|
|
mux.HandleFunc("/eSCL/ScannerStatus", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/xml")
|
|
w.Write(fixture(t, "status-idle.xml"))
|
|
})
|
|
mux.HandleFunc("/eSCL/ScanJobs", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "post only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
b := make([]byte, 1<<16)
|
|
n, _ := r.Body.Read(b)
|
|
f.postBody = string(b[:n])
|
|
f.posts++
|
|
f.job = readSettings(f.postBody)
|
|
if f.refuse != 0 {
|
|
http.Error(w, "busy", f.refuse)
|
|
return
|
|
}
|
|
if f.open {
|
|
http.Error(w, "busy", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
f.open, f.delivered, f.served = true, false, 0
|
|
// The captured Location names the real scanner's host; the test's own server stands in for it.
|
|
w.Header().Set("Location", f.URL+"/eSCL/ScanJobs/7320ead5")
|
|
w.WriteHeader(http.StatusCreated)
|
|
})
|
|
mux.HandleFunc("/eSCL/ScanJobs/7320ead5/NextDocument", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.documents++
|
|
if f.stuck {
|
|
http.Error(w, "stuck", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if f.gone {
|
|
f.gone, f.open = false, false
|
|
http.Error(w, "gone", http.StatusGone)
|
|
return
|
|
}
|
|
if f.dropConn {
|
|
if hj, ok := w.(http.Hijacker); ok {
|
|
c, _, _ := hj.Hijack()
|
|
c.Close()
|
|
}
|
|
return
|
|
}
|
|
if f.noPage || f.served >= f.total() {
|
|
f.open = false
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
f.delivered = true
|
|
f.served++
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Write(f.image)
|
|
})
|
|
mux.HandleFunc("/eSCL/ScanJobs/7320ead5", func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, "delete only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
f.deletes++
|
|
if f.stuck {
|
|
http.Error(w, "stuck", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
f.open = false
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
f.Server = httptest.NewServer(mux)
|
|
t.Cleanup(f.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeScanner) base() string { return f.URL + "/eSCL" }
|