// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package app import ( "context" "errors" "os" "path/filepath" "reflect" "testing" "time" "git.stormux.org/storm/fenrir/internal/clipboard" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/input" "git.stormux.org/storm/fenrir/internal/presentation" "git.stormux.org/storm/fenrir/internal/reader" "git.stormux.org/storm/fenrir/internal/screen" ) type fakeTransfers struct{ requests []clipboard.Request } func (f *fakeTransfers) Submit(request clipboard.Request) bool { f.requests = append(f.requests, request) return true } func (f *fakeTransfers) Results() <-chan clipboard.Result { return nil } func (f *fakeTransfers) Cancel() {} func (f *fakeTransfers) Close() {} func clipboard_fixture() (*clipboardHost, *fakeTransfers, *[]presentation.Message, *string) { settings := config.Defaults().Clipboard settings.Sync = true worker := &fakeTransfers{} messages := []presentation.Message{} echo := "last text" host := new_clipboard(settings, reader.New(), worker, func(text string, cue presentation.Cue) { messages = append(messages, presentation.Message{Text: text, Cue: cue}) }, func() string { return echo }) return host, worker, &messages, &echo } func TestClipboardSyncStaleImportRetryAcknowledgementAndNoLoop(t *testing.T) { host, worker, messages, echo := clipboard_fixture() host.poll() stale := worker.requests[0] host.command(command.CopyLastEcho) host.result(clipboard.Result{Request: stale, Text: "old X text"}) if text, _ := host.history.Current(); text != *echo { t.Fatal("stale poll overwrote copy", text) } host.poll() write := worker.requests[len(worker.requests)-1] if write.Command != command.ExportXClipboard || write.Text != *echo { t.Fatal(write) } host.result(clipboard.Result{Request: write, Err: errors.New("fixture failed")}) if !host.dirty || host.observed != "" { t.Fatal("failed write was acknowledged") } host.poll() host.result(clipboard.Result{Request: worker.requests[len(worker.requests)-1]}) if host.dirty || host.observed != *echo { t.Fatal("successful write not acknowledged") } host.poll() poll := worker.requests[len(worker.requests)-1] host.result(clipboard.Result{Request: poll, Text: *echo}) if !host.history.At_first() || !host.history.At_last() || len(*messages) != 1 { t.Fatal("own write reimported or sync spoke", *messages) } host.poll() host.result(clipboard.Result{Request: worker.requests[len(worker.requests)-1], Text: "new X text"}) if text, _ := host.history.Current(); text != "new X text" { t.Fatal(text) } host.command(command.NextClipboard) if text, _ := host.history.Current(); text != *echo { t.Fatal("history entry lost", text) } } func TestClipboardManualImportsAndLateFeedback(t *testing.T) { host, worker, messages, _ := clipboard_fixture() host.settings.Sync = false host.poll() if len(worker.requests) != 0 { t.Fatal("disabled sync opened X") } host.command(command.ImportXClipboard) request := worker.requests[0] host.feedback++ // Later typing suppresses old completion speech. host.result(clipboard.Result{Request: request, Text: "imported"}) if text, _ := host.history.Current(); text != "imported" || len(*messages) != 0 { t.Fatal(text, *messages) } host.command(command.ImportClipboard) host.result(clipboard.Result{Request: worker.requests[1], Text: "file text"}) if len(*messages) != 2 || (*messages)[0].Text != "Import to Clipboard" || (*messages)[0].Cue != presentation.CopyToClipboard || (*messages)[1].Text != "file text" { t.Fatal(*messages) } host.command(command.ClearClipboard) if _, ok := host.history.Current(); ok { t.Fatal("history not cleared") } host.command(command.PasteClipboard) if (*messages)[len(*messages)-1].Text != "clipboard empty" { t.Fatal(*messages) } } func TestClipboardPendingManualTransferRetiresOnTargetChange(t *testing.T) { host, worker, messages, _ := clipboard_fixture() host.poll() stale := worker.requests[0] host.command(command.ImportXClipboard) host.session_changed(3) host.result(clipboard.Result{Request: stale, Text: "old session"}) if _, ok := host.history.Current(); ok || len(*messages) != 0 || len(worker.requests) != 1 { t.Fatal("stale session changed clipboard", *messages, worker.requests) } } func TestManualTransferQueuePreservesEveryExport(t *testing.T) { host, worker, _, _ := clipboard_fixture() host.settings.Sync = false host.add("text", true) for range 3 { host.command(command.ExportClipboard) } for i := range 3 { if len(worker.requests) != i+1 || worker.requests[i].Command != command.ExportClipboard { t.Fatal("manual transfer lost", worker.requests) } host.result(clipboard.Result{Request: worker.requests[i]}) } if host.busy || len(host.next) != 0 { t.Fatal("transfer queue did not drain") } } func TestPersistentClipboardLiveEnableResizeSelectionDisableAndClear(t *testing.T) { path := filepath.Join(t.TempDir(), "private", "clipboard.json") host, _, _, _ := clipboard_fixture() host.historyStore = clipboard.New_persistence(path, false) host.settings.Sync = false host.add("old", true) host.add("new", true) settings := host.settings settings.Persistent = true host.configure(settings) host.command(command.NextClipboard) if err := host.historyStore.Close(); err != nil { t.Fatal(err) } store := clipboard.New_persistence(path, true) if store.LoadErr != nil || store.Initial.Selected != 1 || !reflect.DeepEqual(store.Initial.Entries, []string{"new", "old"}) { t.Fatal(store.Initial, store.LoadErr) } host.historyStore = store settings.NumberOfClipboards = 1 host.configure(settings) if got, _ := host.history.Current(); got != "new" { t.Fatal(got) } settings.Persistent = false host.configure(settings) host.add("memory only", true) if err := store.Close(); err != nil { t.Fatal(err) } if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Fatal("disabling kept the saved file", err) } host.historyStore = clipboard.New_persistence(path, false) settings.Persistent = true host.configure(settings) host.command(command.ClearClipboard) if err := host.historyStore.Close(); err != nil { t.Fatal(err) } if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Fatal("clear kept the saved file", err) } } func TestPersistentClipboardStartupRestoresClipsAndRetainsSelection(t *testing.T) { path := filepath.Join(t.TempDir(), "private", "tty.json") store := clipboard.New_persistence(path, false) store.Enable(clipboard.Snapshot{Version: 1, Entries: []string{"new", "middle", "old"}, Selected: 1}, 200) if err := store.Close(); err != nil { t.Fatal(err) } store = clipboard.New_persistence(path, true) settings := config.Defaults() settings.Clipboard.Persistent = true settings.Clipboard.NumberOfClipboards = 2 source := &fakeSource{snapshot: screen.Snapshot{Terminal: 2, Active: true, Rows: 1, Cols: 1, Cells: []rune("x")}} voice := &fakeVoice{} keys := &fakeKeys{poll: func(_ bool, handle func(input.Decision)) error { handle(input.Decision{Command: command.CurrentClipboard}) if voice.messages[len(voice.messages)-1] != "middle" { t.Fatal("startup selection was lost", voice.messages) } handle(input.Decision{Command: command.Quit}) return nil }} if err := run(context.Background(), source, keys, voice, nil, RunOptions{Settings: settings, ClipboardHistory: store}); err != nil { t.Fatal(err) } if err := store.Close(); err != nil { t.Fatal(err) } store = clipboard.New_persistence(path, true) if store.LoadErr != nil || !reflect.DeepEqual(store.Initial.Entries, []string{"new", "middle"}) || store.Initial.Selected != 1 { t.Fatal("startup limit not applied on disk", store.Initial, store.LoadErr) } store.Close() } func apply_shared_history(t *testing.T, host *clipboardHost) { t.Helper() deadline := time.NewTimer(3 * time.Second) defer deadline.Stop() for { select { case update := <-host.historyStore.Updates(): host.history_update(update) if update.Sequence == host.historyStore.Sequence() { return } case err := <-host.historyStore.Errors(): t.Fatal(err) case <-deadline.C: t.Fatal("shared clipboard update did not arrive") } } } func TestSharedClipboardHostsKeepSelectionLocalAndRefreshSilently(t *testing.T) { path := filepath.Join(t.TempDir(), "private", "clipboard.json") first, _, firstMessages, _ := clipboard_fixture() second, _, secondMessages, _ := clipboard_fixture() for _, host := range []*clipboardHost{first, second} { host.settings.Persistent = true host.settings.Sync = false host.historyStore = clipboard.New_persistence(path, true) t.Cleanup(func() { host.historyStore.Close() }) } first.add("older", true) apply_shared_history(t, first) apply_shared_history(t, second) first.add("newer", true) apply_shared_history(t, first) apply_shared_history(t, second) first.command(command.NextClipboard) apply_shared_history(t, first) second.add("peer", true) apply_shared_history(t, second) apply_shared_history(t, first) if got, _ := first.history.Current(); got != "older" { t.Fatal("peer copy changed local selection", got) } if got, _ := second.history.Current(); got != "peer" { t.Fatal("local copy did not remain selected", got) } if !reflect.DeepEqual(first.history.Snapshot().Entries, []string{"peer", "newer", "older"}) { t.Fatal(first.history.Snapshot()) } beforeFirst, beforeSecond := len(*firstMessages), len(*secondMessages) second.command(command.ClearClipboard) apply_shared_history(t, second) apply_shared_history(t, first) if _, ok := first.history.Current(); ok { t.Fatal("peer Clear kept local history") } if len(*firstMessages) != beforeFirst || len(*secondMessages) != beforeSecond+1 { t.Fatal("refresh announced unsolicited feedback", *firstMessages, *secondMessages) } // Pending local copies cannot be overwritten by an earlier refresh. first.add("pending", true) first.history_update(clipboard.HistoryUpdate{State: clipboard.Snapshot{Version: 1}, Enabled: true}) if got, _ := first.history.Current(); got != "pending" { t.Fatal("stale refresh replaced pending copy", got) } apply_shared_history(t, first) } func TestDisabledClipboardClearDoesNotEnablePersistence(t *testing.T) { path := filepath.Join(t.TempDir(), "private", "clipboard.json") host, _, _, _ := clipboard_fixture() host.historyStore = clipboard.New_persistence(path, false) host.command(command.ClearClipboard) if err := host.historyStore.Close(); err != nil { t.Fatal(err) } select { case update := <-host.historyStore.Updates(): host.history_update(update) default: } if host.settings.Persistent { t.Fatal("memory-only Clear enabled persistence") } if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) { t.Fatal("memory-only Clear created storage", err) } }