package app import ( "context" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/config" "log/slog" "path/filepath" "testing" "time" ) func TestBrailleHelperStartsOnlyWhenEnabled(t *testing.T) { worker := &brailleWorker{logger: slog.New(slog.DiscardHandler), path: filepath.Join(t.TempDir(), "missing-helper"), wake: make(chan struct{}, 1)} worker.Enqueue("focus") if worker.frame.Text != "" { t.Fatal("disabled braille retained a frame") } if err := worker.Configure(context.Background(), false); err != nil { t.Fatal(err) } if err := worker.Configure(context.Background(), true); err == nil { t.Fatal("enabled braille accepted a missing helper") } } func TestBrailleWordWrapUpdatesCurrentAndMessageFrames(t *testing.T) { worker := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true} worker.EnqueueFrame(braille.Frame{Text: "Focused long line", Cursor: 0}) worker.SetWordWrap(true) if !worker.current().WordWrap || !worker.baseFrame.WordWrap { t.Fatal("word wrap did not update current and restoration frames") } if !worker.EnqueueMessage("Long external message") || !worker.current().WordWrap { t.Fatal("external message lost word wrapping") } options := config.DefaultSettings().Braille options.WordWrap, options.FlashPersistent = true, true if !worker.Flash("Long flash message", options) || !worker.current().WordWrap { t.Fatal("flash message lost word wrapping") } worker.SetWordWrap(false) if worker.current().WordWrap || worker.baseFrame.WordWrap { t.Fatal("disabled word wrap did not update current and restoration frames") } } func TestBrailleFlashRestoresFocusAndHonorsPreferences(t *testing.T) { worker := &brailleWorker{wake: make(chan struct{}, 1), cancel: func() {}, connected: true} worker.EnqueueFrame(braille.Frame{Text: "focus", Cursor: 0}) options := config.DefaultSettings().Braille options.FlashTime = 10 if !worker.Flash("notice", options) || worker.current().Text != "notice" { t.Fatalf("flash frame = %+v", worker.current()) } deadline := time.After(time.Second) for worker.current().Text != "focus" { select { case <-deadline: t.Fatal("flash did not restore focus") case <-time.After(time.Millisecond): } } options.FlashMessages = false if worker.Flash("ignored", options) || worker.current().Text != "focus" { t.Fatal("disabled flash changed the display") } options.FlashMessages = true options.FlashPersistent = true if !worker.Flash("persistent", options) { t.Fatal("persistent flash rejected") } time.Sleep(20 * time.Millisecond) if worker.current().Text != "persistent" { t.Fatal("persistent flash expired") } worker.EnqueueFrame(braille.Frame{Text: "new focus", Cursor: -1}) if worker.current().Text != "new focus" { t.Fatal("focus did not replace persistent flash") } } type recordingBrailleRouter struct { calls chan atspi.BrailleRoute entered chan struct{} } func (r *recordingBrailleRouter) RouteBraille(ctx context.Context, route atspi.BrailleRoute) error { r.calls <- route if r.entered != nil { close(r.entered) <-ctx.Done() return ctx.Err() } return nil } func TestBrailleWorkerRoutesOnlyCurrentTextCells(t *testing.T) { router := &recordingBrailleRouter{calls: make(chan atspi.BrailleRoute, 8)} worker := &brailleWorker{router: router, logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1), cancel: func() {}} snapshot := atspi.Snapshot{BusName: ":1.42", Path: "/entry", Name: "Message", Role: "entry", Editable: true, TextCaretKnown: true, BrailleLine: "é猫", BrailleLineStart: 20, BrailleCaret: 21, Required: true} worker.EnqueueFrame(brailleFocusFrame(snapshot)) id := worker.current().ID for _, event := range []braille.RoutingEvent{{ID: id, Offset: 0}, {ID: id, Offset: 13}, {ID: id, Offset: -1}, {ID: id + 1, Offset: 8}} { worker.route(context.Background(), event) } if len(router.calls) != 0 { t.Fatal("routed label, suffix, invalid offset or stale frame") } for _, offset := range []int{8, 9, 10, 12} { worker.route(context.Background(), braille.RoutingEvent{ID: id, Offset: offset}) } for _, want := range []int{20, 21, 22, 22} { select { case got := <-router.calls: if got.Offset != want || got.Focus != snapshot.ID() || got.Line != "é猫" { t.Fatalf("route %+v want offset %d", got, want) } default: t.Fatal("routing missing") } } worker.EnqueueFrame(braille.Frame{Cursor: -1}) worker.route(context.Background(), braille.RoutingEvent{ID: id, Offset: 8}) if len(router.calls) != 0 { t.Fatal("routed cleared focus") } } func TestBrailleWorkerCancelsRoutingWhenFrameChanges(t *testing.T) { router := &recordingBrailleRouter{calls: make(chan atspi.BrailleRoute, 1), entered: make(chan struct{})} worker := &brailleWorker{router: router, logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1), cancel: func() {}} worker.EnqueueFrame(braille.Frame{Text: "abc", Cursor: 0, Routes: []braille.TextRoute{{Start: 0, End: 3, TextLength: 3, BusName: ":1.42", Path: "/entry", SourceText: "abc"}}}) done := make(chan struct{}) go func() { defer close(done) worker.route(context.Background(), braille.RoutingEvent{ID: worker.current().ID, Offset: 1}) }() select { case <-router.entered: case <-time.After(time.Second): t.Fatal("route did not start") } worker.EnqueueFrame(braille.Frame{Cursor: -1}) select { case <-done: case <-time.After(time.Second): t.Fatal("route was not canceled") } } func TestBrailleStopTimeoutRetainsJoinState(t *testing.T) { done := make(chan struct{}) worker := &brailleWorker{cancel: func() {}, done: done} ctx, cancel := context.WithCancel(context.Background()) cancel() if err := worker.Stop(ctx); err != context.Canceled { t.Fatalf("stop error %v", err) } if worker.cancel == nil || worker.done == nil || !worker.stopping { t.Fatal("lost timed-out worker") } close(done) if err := worker.Stop(context.Background()); err != nil { t.Fatal(err) } if worker.cancel != nil || worker.done != nil { t.Fatal("completed stop retained session") } }