Files
fenrir/internal/app/clipboard_test.go

326 lines
11 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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)
}
}