// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package reader import ( "reflect" "strconv" "strings" "testing" "time" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/presentation" "git.stormux.org/storm/fenrir/internal/screen" ) func region_mark(t *testing.T, r *Reader, x, y int) { t.Helper() snapshot := r.screen snapshot.X, snapshot.Y = x, y r.Update(snapshot) r.Command(command.FollowCursor) r.Command(command.CurrentChar) if result := r.Set_mark(); result.Text != "set mark" { t.Fatal(result) } } func region_command(t *testing.T, r *Reader, id command.ID, want string) { t.Helper() if result := r.Command(id); result.Text != want { t.Fatalf("%s: %+v; want %q", id, result, want) } } func TestLiveRegionVimRulerTypingLatestReadAndClear(t *testing.T) { for _, echo := range []bool{false, true} { t.Run(map[bool]string{false: "echo disabled", true: "echo enabled"}[echo], func(t *testing.T) { r := New() r.Configure_echo(echo, false, false) before := fixture("compose", "1,8 All") before.X = 7 r.Update(before) region_mark(t, r, 0, 1) region_command(t, r, command.SetLiveRegion, "Live region 1 set") if _, ok := r.Marked_text(true); ok { t.Fatal("successful creation retained marks") } r.Command(command.FollowCursor) r.Update(before) after := fixture("composeX", "1,9 All") after.X = 8 r.Note_input(true, true, false) got := r.Update(after) var want []presentation.Message if echo { want = []presentation.Message{presentation.Character_message('X')} } if !reflect.DeepEqual(got, want) { t.Fatal("ruler broke typing echo", got, want) } region_command(t, r, command.ReadLiveRegion1, "1,9 All") if !strings.Contains(r.Command(command.ReadScreen).Text, "1,9 All") { t.Fatal("manual read lost excluded text") } after = fixture("composeX", "2,9 All") after.X = 8 if got := r.Update(after); len(got) != 0 { t.Fatal("passive ruler spoke", got) } region_command(t, r, command.ClearLiveRegion1, "Live region 1 cleared") if got := r.Update(after); len(got) != 0 { t.Fatal("clear replayed hidden history", got) } after = fixture("composeX", "3,9 All") after.X = 8 if got := r.Update(after); len(got) == 0 { t.Fatal("clearing did not restore automatic reading") } }) } } func TestLiveRegionRectangleUnicodeEdgesAndReversedMarks(t *testing.T) { r := New() s := fixture("LEFTabcRIGHT", "LEFT界éxRIGHT", "untouched") r.Update(s) region_mark(t, r, 6, 1) region_mark(t, r, 4, 0) region_command(t, r, command.SetLiveRegion, "Live region 1 set") region_command(t, r, command.ReadLiveRegion1, "abc\n界éx") r.Command(command.FollowCursor) next := fixture("LEFTxyzRIGHT", "LEFTé界qRIGHT", "untouched") next.X, next.Y = r.screen.X, r.screen.Y if got := r.Update(next); len(got) != 0 { t.Fatal("rectangle boundary leaked", got) } next = fixture("LEFTxyzRIGH!", "LEFTé界qRIGHT", "untouched") next.X, next.Y = r.screen.X, r.screen.Y if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('!')}) { t.Fatal("adjacent character lost", got) } if s.Text(0) != "LEFTabcRIGHT" { t.Fatal("filter mutated the original snapshot") } } func TestLiveRegionSlotsReuseLimitListingAndIndependentTerminals(t *testing.T) { r := New() r.Update(fixture("first", "second")) region_command(t, r, command.ReadLiveRegion2, "Live region 2 not set") region_command(t, r, command.SetLiveRegion, "One or two marks are needed") for slot := range 10 { region_mark(t, r, 0, slot%2) region_command(t, r, command.SetLiveRegion, "Live region "+strconv.Itoa(slot+1)+" set") } region_mark(t, r, 0, 0) region_command(t, r, command.SetLiveRegion, "All 10 live regions are set") if _, ok := r.Marked_text(true); !ok { t.Fatal("failed creation discarded marks") } listing := r.Command(command.ListReadingRegions).Text if !strings.Contains(listing, "Live region 10: row 2, column 1 to end of line") { t.Fatal(listing) } region_command(t, r, command.ClearLiveRegion4, "Live region 4 cleared") region_command(t, r, command.SetLiveRegion, "Live region 4 set") region_command(t, r, command.ReadLiveRegion4, "first") other := fixture("another terminal", "status") other.Terminal = 3 r.Update(other) region_command(t, r, command.ReadLiveRegion1, "Live region 1 not set") region_command(t, r, command.ListReadingRegions, "No reading regions set") region_mark(t, r, 0, 1) region_command(t, r, command.SetLiveRegion, "Live region 1 set") region_command(t, r, command.ClearReadingRegions, "Reading regions cleared") r.Update(fixture("new first", "new second")) region_command(t, r, command.ReadLiveRegion1, "new first") region_command(t, r, command.ClearLiveRegions, "Live regions cleared") region_command(t, r, command.ReadLiveRegion10, "Live region 10 not set") fresh := New() fresh.Update(r.screen) region_command(t, fresh, command.ReadLiveRegion1, "Live region 1 not set") } func TestRegionsSurviveFocusBufferAndResizeWithClippedReads(t *testing.T) { r := New() r.Update(fixture("editor", "status at bottom")) region_mark(t, r, 7, 1) region_command(t, r, command.SetLiveRegion, "Live region 1 set") s := r.screen s.Active = false r.Update(s) s.Active = true r.Update(s) region_command(t, r, command.ReadLiveRegion1, "at bottom") s.Generation++ r.Update(s) region_command(t, r, command.ReadLiveRegion1, "at bottom") s = screen.Snapshot{Terminal: s.Terminal, Active: true, Rows: 1, Cols: 5, Cells: []rune("small")} r.Update(s) region_command(t, r, command.ReadLiveRegion1, "Live region 1 outside screen") r.Update(fixture("editor", "status new contents")) region_command(t, r, command.ReadLiveRegion1, "new contents") } func TestReadingWindowAndLiveRegionComposeWithoutRestrictingReview(t *testing.T) { r := New() r.Update(fixture("outer text", "IN abc OUT", "IN def OUT")) region_command(t, r, command.SetWindow, "Set window begin and end marks") region_mark(t, r, 5, 2) region_mark(t, r, 3, 1) region_command(t, r, command.SetWindow, "Window Mode on for terminal 2") if got := r.Automatic_snapshot().Text(1); got != " abc" { t.Fatal(got) } region_mark(t, r, 3, 2) region_command(t, r, command.SetLiveRegion, "Live region 1 set") next := fixture("changed outer", "IN abc NEW", "IN XYZ NEW") next.X, next.Y = r.screen.X, r.screen.Y if got := r.Update(next); len(got) != 0 { t.Fatal("outside window/live region spoke", got) } region_command(t, r, command.ReadLiveRegion1, "XYZ NEW") if got := r.Command(command.ReadScreen).Text; !strings.Contains(got, "changed outer") || !strings.Contains(got, "XYZ NEW") { t.Fatal("window clipped manual review", got) } next = fixture("changed outer", "IN qbc NEW", "IN XYZ NEW") next.X, next.Y = r.screen.X, r.screen.Y if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('q')}) { t.Fatal("inside window did not speak", got) } region_command(t, r, command.ClearWindow, "Window Mode off for terminal 2") region_command(t, r, command.ReadLiveRegion1, "XYZ NEW") region_command(t, r, command.ClearReadingRegions, "Reading regions cleared") region_command(t, r, command.ListReadingRegions, "No reading regions set") } func TestLiveRegionProgressAloneAndConcurrentTyping(t *testing.T) { for _, typing := range []bool{false, true} { r := New() r.Configure_progress(true) r.Configure_echo(true, false, false) before := fixture("compose", "Download 20%") before.X = 7 r.Update(before) region_mark(t, r, 0, 1) region_command(t, r, command.SetLiveRegion, "Live region 1 set") r.Command(command.FollowCursor) r.Update(before) after := fixture("compose", "Download 30%") after.X = 7 if typing { after = fixture("composeX", "Download 30%") after.X = 8 r.Note_input(true, true, false) } output := r.Update(after) if len(output) > 0 && (!typing || len(output) != 1 || output[0].Character == nil || *output[0].Character != 'X') { t.Fatal("progress region spoke", typing, output) } if value, ok := r.Next_progress(time.Now().Add(time.Second)); !ok || value != 30 { t.Fatal("lost region progress", typing, value, ok) } r.Configure_progress(false) after = fixture("composeX", "Download 40%") after.X = 8 r.Update(after) if _, ok := r.Next_progress(time.Now().Add(2 * time.Second)); ok { t.Fatal("disabled monitor beeped") } } } func TestLiveRegionKeepsProgressOutsideWindowAndSuppressesSameRowTypedPercentages(t *testing.T) { r := New() r.Configure_progress(true) r.Update(fixture("editor", "Download 20%")) if err := r.Set_window(0, 0, 19, 0); err != nil { t.Fatal(err) } region_mark(t, r, 0, 1) region_command(t, r, command.SetLiveRegion, "Live region 1 set") next := fixture("editor", "Download 30%") next.Y = 1 if got := r.Update(next); len(got) != 0 { t.Fatal(got) } if value, ok := r.Next_progress(time.Now()); !ok || value != 30 { t.Fatal("window hid an explicit live progress region", value, ok) } // A progress field and typing can also share one physical row. r = New() r.Configure_progress(true) s := fixture("20 Download 20%") s.X = 2 r.Update(s) region_mark(t, r, 4, 0) region_command(t, r, command.SetLiveRegion, "Live region 1 set") r.Command(command.FollowCursor) r.Update(s) next = fixture("20% Download 30%") next.X = 3 r.Note_input(true, true, false) r.Update(next) if value, ok := r.Next_progress(time.Now()); !ok || value != 30 { t.Fatal("typed percentage won over live progress on the same row", value, ok) } } func TestLiveRegionFilteringPreservesScrollAndDeletion(t *testing.T) { r := New() s := fixture("old first", "old second", "old third", "status 1") r.Update(s) region_mark(t, r, 0, 3) region_command(t, r, command.SetLiveRegion, "Live region 1 set") r.Command(command.FollowCursor) next := fixture("old second", "old third", "new fourth", "status 2") next.Y = 3 if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{{Text: "new fourth"}}) { t.Fatal("retained scroll history repeated", got) } s = fixture("composeX", "old third", "new fourth", "status 2") s.X = 8 r.Update(s) r.Note_input(true, false, false) r.Note_deletion() next = fixture("compose", "old third", "new fourth", "status 3") next.X = 7 if got := r.Update(next); !reflect.DeepEqual(got, []presentation.Message{presentation.Character_message('X')}) { t.Fatal("ruler hid deletion feedback", got) } } func TestLiveRegionSuppressesCursorHighlightAttributesIndentationAndWordFeedback(t *testing.T) { for _, mode := range []string{"cursor", "table", "highlight"} { r := New() s := fixture("outside", " helo") s.Attributes = make([]screen.Attribute, len(s.Cells)) r.Update(s) region_mark(t, r, 0, 1) region_command(t, r, command.SetLiveRegion, "Live region 1 set") r.Command(command.FollowCursor) r.Configure_tracking(mode) r.Configure_indentation("both") r.Configure_attributes("both", "fenrirBold") r.Configure_echo(true, true, true) next := fixture("outside", " helo ") next.Y, next.X = 1, 9 next.Attributes = make([]screen.Attribute, len(next.Cells)) for col := range 9 { next.Attributes[next.Cols+col].Style = screen.Bold } r.Note_input(true, true, true) if got := r.Update(next); len(got) != 0 || len(r.Completed_words()) != 0 { t.Fatal("excluded automatic feedback", mode, got, r.Completed_words()) } if got := r.Command(command.CurrentLine).Text; !strings.Contains(got, "helo") { t.Fatal("manual review lost live content", mode, got) } } }