Dialogue fixes and tests.

This commit is contained in:
Storm Dragon committed 2026-11-06 02:13:00 -05:00
1 parent 702d9737bd
commit 4c9a267897
5 files changed
+274 -9

No files matched your search

+9 -6
View File
@@ -26,12 +26,7 @@ import (
"git.stormux.org/storm/fenrir/internal/screen"
)
func changes(previous, next screen.Snapshot, cursorEcho bool) []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)}
}
func changes(previous, next screen.Snapshot, cursorEcho bool) (output []presentation.Message) {
if slices.Equal(previous.Cells, next.Cells) {
if cursorEcho && next.Y != previous.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
}
// 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.
// Find the first non-space cell once. Successively shorter overlaps
// cannot be meaningful scroll matches once they contain only whitespace.
+3 -2
View File
@@ -70,12 +70,13 @@ func TestReaderDiagnosticsCursorAndStatusWithoutText(t *testing.T) {
next := fixture("PRIVATE first", "PRIVATE second", "2/2 PRIVATE")
next.Y = 1
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)
}
check_update_record(t, log, map[string]string{
"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",
})
log.Reset()
+148
View File
@@ -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")
}
})
}
}
+2 -1
View File
@@ -37,7 +37,8 @@ func TestCursorNavigationWithStatusChanges(t *testing.T) {
after := fixture("Message one", "Message two", "2/2 messages")
after.Y = 1
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)
}
if r.review.Active() {
+112
View File
@@ -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()