brother: the scanner seat declared and claimed, its verbs scan, status and list; preferences untrusted (issue 1453, 1446)

This commit is contained in:
2026-10-20 21:07:00 +02:00
parent 4a892ee29d
commit 78f9731d4a
5 changed files with 87 additions and 16 deletions
+4 -4
View File
@@ -41,9 +41,9 @@ makes it (mode 0755) when it files the first scan.
| tool | does |
|---|---|
| `scan_now` | scans, crops, files and emits; answers the event's body |
| `scan_status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on |
| `scan_list` | the newest filed scans, newest first (`limit`, default 20). Read-only |
| `scanner.scan` | scans, crops, files and emits; answers the event's body |
| `scanner.status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on |
| `scanner.list` | the newest filed scans, newest first (`limit`, default 20). Read-only |
## The event
@@ -58,7 +58,7 @@ Each directory or archive this module declares says how it treats a file it did
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- The scan folder (`scan-folder`) is declared by no manifest. The module writes only the `scan-*.pdf` files it
files and their `.scan-*.tmp` while they are written; it never changes, moves or removes any other file there,
and `scan_list` only reads.
and `scanner.list` only reads.
## Tests
+4 -4
View File
@@ -21,7 +21,7 @@ func emit(body map[string]any, id string) error {
return stdio.Emit(EventAdded, body, stdio.EmitOptions{MessageID: id})
}
// tools are the three tools. The settings are read at each call, so a changed config value is the next
// tools are the scanner seat's three verbs, served as <seat>.<verb> (ADR 0467 clause 2). The settings are read at each call, so a changed config value is the next
// call's.
func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool {
service := func() (*Service, error) {
@@ -33,7 +33,7 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool
}
return []stdio.Tool{
{
Name: "scan_now",
Name: "scanner.scan",
Description: "Scan the glass of the configured network scanner, cut the document out of the platen's white, " +
"file it as a one-page PDF in the scan folder and say scan.added on the bus. Answers the event's body: " +
"the file's path, its size and sha256, its real size in millimetres, the resolution the scanner " +
@@ -47,7 +47,7 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool
},
},
{
Name: "scan_status",
Name: "scanner.status",
Description: "Whether the configured scanner answers, and what it is doing: its state (Idle or Processing) " +
"and its document feeder's state. A scanner that does not answer is said as answers false with why. Read-only.",
Run: func(map[string]any) (any, error) {
@@ -61,7 +61,7 @@ func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool
},
},
{
Name: "scan_list",
Name: "scanner.list",
Description: "The newest scans filed in the scan folder, newest first, with their size and time. Read-only.",
Input: map[string]any{
"type": "object",
@@ -11,6 +11,8 @@ import (
type manifest struct {
Emits []string `json:"emits"`
Seats []seat `json:"seats"`
Claims []seat `json:"claims"`
Tools []string `json:"tools"`
Config map[string]map[string]any `json:"config"`
Resources []map[string]any `json:"resources"`
@@ -19,6 +21,12 @@ type manifest struct {
} `json:"build"`
}
type seat struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
@@ -116,3 +124,41 @@ func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) {
}
}
}
// 1453/AC6: the manifest declares the scanner seat, held once for the mesh, and claims it, with the verbs
// ADR 0467 clause 2 names; the module serves each verb as the tool <seat>.<verb>.
func TestTheManifestDeclaresAndClaimsTheScannerSeat(t *testing.T) {
m := readManifest(t)
want := seat{Name: "scanner", Scope: "mesh", Serves: []string{"scan", "status", "list"}}
for what, got := range map[string][]seat{"seats": m.Seats, "claims": m.Claims} {
found := false
for _, s := range got {
if s.Name == "scanner" {
found = true
if !reflect.DeepEqual(s, want) {
t.Errorf("%s: scanner = %+v, want %+v", what, s, want)
}
}
}
if !found {
t.Errorf("%s does not hold the scanner seat", what)
}
}
}
// 1453/AC6 (issue 1446): the three preferences are not trusted, so a person's own setting is not refused.
func TestThePreferencesAreNotTrusted(t *testing.T) {
m := readManifest(t)
for _, k := range []string{"resolution", "color-mode", "crop"} {
if v, ok := m.Config[k]["trusted"]; !ok || v != false {
t.Errorf("%s: trusted = %v, want false", k, v)
}
}
}
// 1453/AC6: the scan module is gone from the catalogue.
func TestTheScanModuleIsGone(t *testing.T) {
if _, err := os.Stat(filepath.Join("..", "..", "..", "scan")); !os.IsNotExist(err) {
t.Errorf("modules/scan still exists (err %v)", err)
}
}
+5 -5
View File
@@ -209,7 +209,7 @@ func TestEveryToolSaysWhichValueIsUnsetAndWritesNothing(t *testing.T) {
js := strings.ReplaceAll(strings.Replace(c.json, "%s", f.base(), 1), "FOLDER", folder)
var out []said
tl := tools(settingsFile(t, js), func(b map[string]any, id string) error { out = append(out, said{b, id, true}); return nil }, time.Now)
for _, name := range []string{"scan_now", "scan_status", "scan_list"} {
for _, name := range []string{"scanner.scan", "scanner.status", "scanner.list"} {
_, err := tool(t, 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)
@@ -227,7 +227,7 @@ func TestEveryToolSaysWhichValueIsUnsetAndWritesNothing(t *testing.T) {
// 1440/AC7: the settings file is not there at all.
func TestAMissingSettingsFileIsSaid(t *testing.T) {
tl := tools(filepath.Join(t.TempDir(), "none.json"), nil, time.Now)
if _, err := tool(t, tl, "scan_status").Run(nil); err == nil || !strings.Contains(err.Error(), "settings") {
if _, err := tool(t, tl, "scanner.status").Run(nil); err == nil || !strings.Contains(err.Error(), "settings") {
t.Fatalf("error = %v", err)
}
}
@@ -260,7 +260,7 @@ func TestScanListToolListsTheNewestFirst(t *testing.T) {
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+`"}`), nil, time.Now)
got, err := tool(t, tl, "scan_list").Run(map[string]any{"limit": float64(5)})
got, err := tool(t, tl, "scanner.list").Run(map[string]any{"limit": float64(5)})
if err != nil {
t.Fatal(err)
}
@@ -268,7 +268,7 @@ func TestScanListToolListsTheNewestFirst(t *testing.T) {
if len(scans) != 2 || filepath.Base(scans[0].Path) != "scan-2.pdf" {
t.Fatalf("scans = %v", scans)
}
if _, err := tool(t, tl, "scan_list").Run(map[string]any{"limit": float64(0)}); err == nil {
if _, err := tool(t, tl, "scanner.list").Run(map[string]any{"limit": float64(0)}); err == nil {
t.Error("limit 0 accepted")
}
}
@@ -280,7 +280,7 @@ func TestScanNowToolScansWithTheFileSettings(t *testing.T) {
var out []said
tl := tools(settingsFile(t, `{"scanner":"`+f.base()+`","scan-folder":"`+folder+`","resolution":300,"color-mode":"color","crop":true}`),
func(b map[string]any, id string) error { out = append(out, said{b, id, true}); return nil }, time.Now)
got, err := tool(t, tl, "scan_now").Run(map[string]any{})
got, err := tool(t, tl, "scanner.scan").Run(map[string]any{})
if err != nil {
t.Fatal(err)
}
+28 -3
View File
@@ -28,6 +28,7 @@
300,
600
],
"trusted": false,
"default": 300
},
"color-mode": {
@@ -37,18 +38,42 @@
"color",
"gray"
],
"trusted": false,
"default": "color"
},
"crop": {
"type": "boolean",
"description": "Whether the document is cut out of the platen's white lid; false files the whole platen.",
"trusted": false,
"default": true
}
},
"seats": [
{
"name": "scanner",
"scope": "mesh",
"serves": [
"scan",
"status",
"list"
]
}
],
"claims": [
{
"name": "scanner",
"scope": "mesh",
"serves": [
"scan",
"status",
"list"
]
}
],
"tools": [
"scan_now",
"scan_status",
"scan_list"
"scanner.scan",
"scanner.status",
"scanner.list"
],
"resources": [
{