Dialogue fixes and tests.
This commit is contained in:
1 parent
702d9737bd
commit
4c9a267897
5 files changed
+274
-9
No files matched your search
@@ -26,12 +26,7 @@ import (
|
|||||||
"git.stormux.org/storm/fenrir/internal/screen"
|
"git.stormux.org/storm/fenrir/internal/screen"
|
||||||
)
|
)
|
||||||
|
|
||||||
func changes(previous, next screen.Snapshot, cursorEcho bool) []presentation.Message {
|
func changes(previous, next screen.Snapshot, cursorEcho bool) (output []presentation.Message) {
|
||||||
// TUI navigation can move over unchanged text while a status line also
|
|
||||||
// changes. That status delta must not hide the newly focused row (mutt).
|
|
||||||
if cursorEcho && next.Y != previous.Y && strings.TrimSpace(next.Text(next.Y)) != "" && slices.Equal(previous.Line(next.Y), next.Line(next.Y)) {
|
|
||||||
return []presentation.Message{presentation.Line_message(next, next.Y)}
|
|
||||||
}
|
|
||||||
if slices.Equal(previous.Cells, next.Cells) {
|
if slices.Equal(previous.Cells, next.Cells) {
|
||||||
if cursorEcho && next.Y != previous.Y {
|
if cursorEcho && next.Y != previous.Y {
|
||||||
return []presentation.Message{presentation.Line_message(next, next.Y)}
|
return []presentation.Message{presentation.Line_message(next, next.Y)}
|
||||||
@@ -41,6 +36,14 @@ func changes(previous, next screen.Snapshot, cursorEcho bool) []presentation.Mes
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// Keep navigation over unchanged text audible even when other rows change
|
||||||
|
// (mutt), but first read those changes. A dialog can finish drawing its
|
||||||
|
// body while moving the cursor onto an already drawn button row.
|
||||||
|
if cursorEcho && next.Y != previous.Y && strings.TrimSpace(next.Text(next.Y)) != "" && slices.Equal(previous.Line(next.Y), next.Line(next.Y)) {
|
||||||
|
defer func() {
|
||||||
|
output = append(output, presentation.Line_message(next, next.Y))
|
||||||
|
}()
|
||||||
|
}
|
||||||
// Match a scroll as one shift so old lines aren't read a second time.
|
// Match a scroll as one shift so old lines aren't read a second time.
|
||||||
// Find the first non-space cell once. Successively shorter overlaps
|
// Find the first non-space cell once. Successively shorter overlaps
|
||||||
// cannot be meaningful scroll matches once they contain only whitespace.
|
// cannot be meaningful scroll matches once they contain only whitespace.
|
||||||
|
|||||||
@@ -70,12 +70,13 @@ func TestReaderDiagnosticsCursorAndStatusWithoutText(t *testing.T) {
|
|||||||
next := fixture("PRIVATE first", "PRIVATE second", "2/2 PRIVATE")
|
next := fixture("PRIVATE first", "PRIVATE second", "2/2 PRIVATE")
|
||||||
next.Y = 1
|
next.Y = 1
|
||||||
r.Note_input(true, false, false)
|
r.Note_input(true, false, false)
|
||||||
if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{{Text: "PRIVATE second"}}) {
|
want := []presentation.Message{presentation.Character_message('2'), {Text: "PRIVATE second"}}
|
||||||
|
if got := r.Update(next); !reflect.DeepEqual(got, want) {
|
||||||
t.Fatal("diagnostics changed cursor feedback", got)
|
t.Fatal("diagnostics changed cursor feedback", got)
|
||||||
}
|
}
|
||||||
check_update_record(t, log, map[string]string{
|
check_update_record(t, log, map[string]string{
|
||||||
"cursor": "[0,1]", "previous_cursor": "[0,0]", "mode": `"cursor"`,
|
"cursor": "[0,1]", "previous_cursor": "[0,0]", "mode": `"cursor"`,
|
||||||
"text_rows": "[2]", "text_cells": "1", "messages": "1", "message_lengths": "[14]",
|
"text_rows": "[2]", "text_cells": "1", "messages": "2", "message_lengths": "[1,14]",
|
||||||
"path": `"screen-changes"`, "review": "false", "cursor_echo": "true",
|
"path": `"screen-changes"`, "review": "false", "cursor_echo": "true",
|
||||||
})
|
})
|
||||||
log.Reset()
|
log.Reset()
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Fenrir console screen reader.
|
||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/fenrir/internal/command"
|
||||||
|
"git.stormux.org/storm/fenrir/internal/presentation"
|
||||||
|
"git.stormux.org/storm/fenrir/internal/screen"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dialog_snapshot(lines map[int]string, x, y int) screen.Snapshot {
|
||||||
|
snapshot := screen.Snapshot{Terminal: 1, Active: true, Rows: 25, Cols: 80, X: x, Y: y}
|
||||||
|
snapshot.Cells = make([]rune, snapshot.Rows*snapshot.Cols)
|
||||||
|
for index := range snapshot.Cells {
|
||||||
|
snapshot.Cells[index] = ' '
|
||||||
|
}
|
||||||
|
for row, text := range lines {
|
||||||
|
copy(snapshot.Line(row), []rune(text))
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDialogBodyWithUnchangedCursorRow(t *testing.T) {
|
||||||
|
// These are the two split redraws captured during the I38 audit: the
|
||||||
|
// button row is already present before the dialog body finishes drawing.
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
before, after screen.Snapshot
|
||||||
|
want []presentation.Message
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "saved preferences body",
|
||||||
|
before: dialog_snapshot(map[int]string{
|
||||||
|
8: "I38", 14: "< OK >",
|
||||||
|
}, 0, 19),
|
||||||
|
after: dialog_snapshot(map[int]string{
|
||||||
|
8: "I38", 9: "Loaded saved preferences from", 10: "/tmp/I38_preferences.conf",
|
||||||
|
12: "Missing or invalid values will be prompted.", 14: "< OK >",
|
||||||
|
}, 2, 14),
|
||||||
|
want: []presentation.Message{
|
||||||
|
{Text: "Loaded saved preferences from"}, {Text: "/tmp/I38_preferences.conf"},
|
||||||
|
{Text: "Missing or invalid values will be prompted."}, {Text: "< OK >"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "last warning line",
|
||||||
|
before: dialog_snapshot(map[int]string{
|
||||||
|
0: "I38", 1: "I38Lock is not available with the current screen reader setup.",
|
||||||
|
2: "Requires i38lock and either Cthulhu or Orca 50 or newer.", 22: "< OK >",
|
||||||
|
}, 2, 3),
|
||||||
|
after: dialog_snapshot(map[int]string{
|
||||||
|
0: "I38", 1: "I38Lock is not available with the current screen reader setup.",
|
||||||
|
2: "Requires i38lock and either Cthulhu or Orca 50 or newer.",
|
||||||
|
3: "Please choose another screen lock method.", 22: "< OK >",
|
||||||
|
}, 2, 22),
|
||||||
|
want: []presentation.Message{
|
||||||
|
{Text: "Please choose another screen lock method."}, {Text: "< OK >"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
r := New()
|
||||||
|
r.Update(testCase.before)
|
||||||
|
if got := r.Update(testCase.after); !reflect.DeepEqual(got, testCase.want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, testCase.want)
|
||||||
|
}
|
||||||
|
if got := r.Update(testCase.after); len(got) != 0 {
|
||||||
|
t.Fatal("unchanged dialog repeated speech", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCursorFeedbackPreservesConcurrentOutput(t *testing.T) {
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
name string
|
||||||
|
before, after screen.Snapshot
|
||||||
|
want []presentation.Message
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"output below focus", fixture("first", "focus", ""), fixture("first", "focus", "new output"),
|
||||||
|
[]presentation.Message{{Text: "new output"}, {Text: "focus"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scroll with fixed focus", fixture("old", "keep one", "keep two", "last", "focus"),
|
||||||
|
fixture("keep one", "keep two", "last", "new output", "focus"),
|
||||||
|
[]presentation.Message{{Text: "new output"}, {Text: "focus"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"changed focused row", fixture("first", "old focus"), fixture("first", "new focus"),
|
||||||
|
[]presentation.Message{{Text: "new"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cleared body", fixture("old body", "focus"), fixture("", "focus"),
|
||||||
|
[]presentation.Message{{Text: "focus"}},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
testCase.after.Y = 1
|
||||||
|
if testCase.name == "scroll with fixed focus" {
|
||||||
|
testCase.after.Y = 4
|
||||||
|
}
|
||||||
|
if got := changes(testCase.before, testCase.after, true); !reflect.DeepEqual(got, testCase.want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, testCase.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDialogRedrawRespectsReadingModes(t *testing.T) {
|
||||||
|
for _, mode := range []string{"cursor", "cursor off", "table", "highlight", "review", "automatic off"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
r := New()
|
||||||
|
before := fixture("I38", "", "< OK >")
|
||||||
|
r.Update(before)
|
||||||
|
switch mode {
|
||||||
|
case "cursor off":
|
||||||
|
r.Configure(true, false)
|
||||||
|
case "table", "highlight":
|
||||||
|
r.Configure_tracking(mode)
|
||||||
|
case "review":
|
||||||
|
r.Command(command.CurrentLine)
|
||||||
|
case "automatic off":
|
||||||
|
r.Configure(false, true)
|
||||||
|
}
|
||||||
|
after := fixture("I38", "New dialog body", "< OK >")
|
||||||
|
after.Y = 2
|
||||||
|
var want []presentation.Message
|
||||||
|
if mode != "automatic off" {
|
||||||
|
want = []presentation.Message{{Text: "New dialog body"}}
|
||||||
|
if mode == "cursor" {
|
||||||
|
want = append(want, presentation.Message{Text: "< OK >"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := r.Update(after); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if mode == "review" && (!r.review.Active() || r.Command(command.CurrentLine).Text != "I38") {
|
||||||
|
t.Fatal("passive dialog redraw displaced review")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,8 @@ func TestCursorNavigationWithStatusChanges(t *testing.T) {
|
|||||||
after := fixture("Message one", "Message two", "2/2 messages")
|
after := fixture("Message one", "Message two", "2/2 messages")
|
||||||
after.Y = 1
|
after.Y = 1
|
||||||
r.Note_input(true, false, false)
|
r.Note_input(true, false, false)
|
||||||
if got := r.Update(after); !reflect.DeepEqual(got, []presentation.Message{{Text: "Message two"}}) {
|
want := []presentation.Message{presentation.Character_message('2'), {Text: "Message two"}}
|
||||||
|
if got := r.Update(after); !reflect.DeepEqual(got, want) {
|
||||||
t.Fatal("status line hid focused message", got)
|
t.Fatal("status line hid focused message", got)
|
||||||
}
|
}
|
||||||
if r.review.Active() {
|
if r.review.Active() {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright (c) 2026 Storm Dragon
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Replay split dialog redraws through a real PTY and capture automatic SSIP.
|
||||||
|
|
||||||
|
Run: xvfb-run -a env FENRIR_PRIVATE_X11=1 python3 tests/native_dialogs.py
|
||||||
|
No installed I38, dialog, GNU Screen, live audio, or live keyboard is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from native_terminal import KeyboardFixture, SpeechServer, wait_for
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.environ.get("DISPLAY") or os.environ.get("FENRIR_PRIVATE_X11") != "1":
|
||||||
|
raise SystemExit("Use xvfb-run -a env FENRIR_PRIVATE_X11=1 python3 tests/native_dialogs.py")
|
||||||
|
repository = Path(__file__).resolve().parents[1]
|
||||||
|
fixture = str(repository / "tests/native_terminal.py")
|
||||||
|
binary = os.environ.get("FENRIR_TEST_BINARY", str(repository / "fenrir"))
|
||||||
|
with tempfile.TemporaryDirectory(prefix="fenrir-dialogs-", dir="/tmp") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "home").mkdir()
|
||||||
|
(root / "keyboard").mkdir()
|
||||||
|
(root / "keyboard/custom.toml").write_bytes((repository / "config/keyboard/desktop.toml").read_bytes())
|
||||||
|
(root / "settings.toml").write_text(
|
||||||
|
'version=3\n[presentation.sound]\nenabled=false\n'
|
||||||
|
'[presentation.progress]\nenabled=false\n'
|
||||||
|
'[reading]\npunctuation_level="none"\nspellcheck=false\n'
|
||||||
|
f'[remote]\nsocket_file="{root / "remote.sock"}"\n')
|
||||||
|
shell = root / "shell"
|
||||||
|
shell.write_text(f'#!{sys.executable}\nimport runpy, sys\nsys.argv=[{fixture!r}, "--fixture-shell"]\nrunpy.run_path({fixture!r}, run_name="__main__")\n')
|
||||||
|
shell.chmod(0o700)
|
||||||
|
server = SpeechServer(root / "speech.sock")
|
||||||
|
keyboard = KeyboardFixture()
|
||||||
|
environment = dict(os.environ, FENRIR_TEST_BINARY=binary, FENRIR_FIXTURE_DIR=str(root),
|
||||||
|
HOME=str(root / "home"), TMPDIR=str(root), LOGNAME="Username", USER="Username",
|
||||||
|
SPEECHD_ADDRESS="unix_socket:" + str(root / "speech.sock"), LC_ALL="C", LANGUAGE="C")
|
||||||
|
terminal = subprocess.Popen(["xterm", "-geometry", "80x25", "-e", sys.executable,
|
||||||
|
fixture, "--outer", str(root)], env=environment, cwd=root)
|
||||||
|
|
||||||
|
def spoken(text, start):
|
||||||
|
wait_for(lambda: any(text in item for item in server.events[start:]),
|
||||||
|
f"automatic speech {text!r}; recent={server.events[-12:]}")
|
||||||
|
|
||||||
|
def emit_output(data):
|
||||||
|
start = len(server.events)
|
||||||
|
(root / "prompt-output").write_bytes(data)
|
||||||
|
os.kill(int((root / "shell-pid").read_text()), signal.SIGUSR1)
|
||||||
|
return start
|
||||||
|
|
||||||
|
try:
|
||||||
|
wait_for(lambda: (root / "started").exists(), "reader startup")
|
||||||
|
state = json.loads((root / "started").read_text())
|
||||||
|
subprocess.run(["xdotool", "windowfocus", "--sync", state["window"]], check=True)
|
||||||
|
spoken("prompt", 0)
|
||||||
|
for name, initial, redraw, expected in (
|
||||||
|
("saved preferences body",
|
||||||
|
b"\x1b[2J\x1b[9;1HI38\x1b[15;1H< OK >\x1b[20;1H",
|
||||||
|
b"\x1b[10;1HLoaded saved preferences\x1b[11;1HPreferences file loaded"
|
||||||
|
b"\x1b[13;1HMissing or invalid values will be prompted\x1b[15;3H",
|
||||||
|
["Loaded saved preferences", "Preferences file loaded", "Missing or invalid values will be prompted"]),
|
||||||
|
("last warning line",
|
||||||
|
b"\x1b[2J\x1b[1;1HI38\x1b[2;1HI38Lock is not available"
|
||||||
|
b"\x1b[3;1HRequires i38lock and a supported reader\x1b[23;1H< OK >\x1b[4;3H",
|
||||||
|
b"\x1b[4;1HPlease choose another screen lock method\x1b[23;3H",
|
||||||
|
["Please choose another screen lock method"]),
|
||||||
|
):
|
||||||
|
start = emit_output(initial)
|
||||||
|
spoken("OK", start) # The button is captured before the body.
|
||||||
|
start = emit_output(redraw)
|
||||||
|
for text in expected:
|
||||||
|
spoken(text, start)
|
||||||
|
spoken("OK", start)
|
||||||
|
utterances = [item for item in server.events[start:] if item != "CANCEL"]
|
||||||
|
joined = " ".join(utterances)
|
||||||
|
for text in expected:
|
||||||
|
assert joined.count(text) == 1, (name, text, utterances)
|
||||||
|
assert "OK" in utterances[-1], (name, utterances)
|
||||||
|
# No review key was used to obtain the missing text.
|
||||||
|
assert not (root / "input").exists() or not (root / "input").read_bytes()
|
||||||
|
print(f"PASS: {name}: automatic body speech followed by focused button", flush=True)
|
||||||
|
keyboard.key("Insert+q")
|
||||||
|
wait_for(lambda: (root / "finished").exists(), "reader shutdown")
|
||||||
|
result = json.loads((root / "finished").read_text())
|
||||||
|
assert result == {"status": 0, "restored": True, "flagsRestored": True}, result
|
||||||
|
assert not server.errors, server.errors
|
||||||
|
except BaseException:
|
||||||
|
print("SSIP:", server.events[-25:], file=sys.stderr)
|
||||||
|
for name in ("reader-errors", "timing.log"):
|
||||||
|
if (root / name).exists():
|
||||||
|
print(name + ":", (root / name).read_text()[-5000:], file=sys.stderr)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
terminal.terminate()
|
||||||
|
try:
|
||||||
|
terminal.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
terminal.kill()
|
||||||
|
terminal.wait()
|
||||||
|
keyboard.close()
|
||||||
|
server.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user