// 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 reader import ( "bytes" "encoding/json" "log/slog" "reflect" "strings" "testing" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/presentation" "git.stormux.org/storm/fenrir/internal/screen" ) func capture_diagnostics(t *testing.T, level slog.Leveler) *bytes.Buffer { t.Helper() var log bytes.Buffer previousLogger := slog.Default() slog.SetDefault(slog.New(slog.NewJSONHandler(&log, &slog.HandlerOptions{Level: level}))) t.Cleanup(func() { slog.SetDefault(previousLogger) }) return &log } func check_update_record(t *testing.T, log *bytes.Buffer, fields map[string]string) map[string]json.RawMessage { t.Helper() lines := bytes.Split(bytes.TrimSpace(log.Bytes()), []byte("\n")) var record map[string]json.RawMessage if err := json.Unmarshal(lines[len(lines)-1], &record); err != nil { t.Fatal(err, log.String()) } fields["msg"] = `"reader update"` for field, want := range fields { if got := string(record[field]); got != want { t.Errorf("%s=%s, want %s", field, got, want) } } if strings.Contains(log.String(), "PRIVATE") { t.Fatal("diagnostics exposed screen or output text") } return record } func TestReaderDiagnosticsCursorAndStatusWithoutText(t *testing.T) { var level slog.LevelVar level.Set(slog.LevelDebug) log := capture_diagnostics(t, &level) r := New() r.Update(fixture("PRIVATE first", "PRIVATE second", "1/2 PRIVATE")) r.Command(command.CurrentLine) r.Command(command.NextLine) log.Reset() next := fixture("PRIVATE first", "PRIVATE second", "2/2 PRIVATE") next.Y = 1 r.Note_input(true, false, false) 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": "2", "message_lengths": "[1,14]", "path": `"screen-changes"`, "review": "false", "cursor_echo": "true", }) log.Reset() level.Set(slog.LevelInfo) r.Update(next) if log.Len() != 0 { t.Fatal("reader diagnostics were not opt-in", log.String()) } } func TestReaderDiagnosticsStationaryCursorAndSuppression(t *testing.T) { for _, testCase := range []struct { name, mode, path string review, automatic bool messages string }{ {"cursor", "cursor", "screen-changes", false, true, "0"}, {"highlight", "highlight", "highlight", false, true, "1"}, {"review", "cursor", "screen-changes", true, true, "0"}, {"automatic off", "cursor", "automatic-disabled", false, false, "0"}, } { t.Run(testCase.name, func(t *testing.T) { log := capture_diagnostics(t, slog.LevelDebug) before := fixture("PRIVATE first", "PRIVATE second", "third", "status") before.Attributes = make([]screen.Attribute, len(before.Cells)) next := before next.Attributes = make([]screen.Attribute, len(before.Cells)) selected := screen.Attribute{Foreground: 7, Background: 4} for col := range before.Cols { before.Attributes[col] = selected next.Attributes[next.Cols+col] = selected } r := New() r.Configure(testCase.automatic, true) r.Configure_tracking(testCase.mode) r.Update(before) if testCase.review { r.Command(command.CurrentLine) } log.Reset() r.Update(next) check_update_record(t, log, map[string]string{ "cursor": "[0,0]", "previous_cursor": "[0,0]", "text_rows": "[]", "attribute_rows": "[0,1]", "attribute_cells": "40", "attributes": "true", "path": `"` + testCase.path + `"`, "messages": testCase.messages, "review": map[bool]string{true: "true", false: "false"}[testCase.review], }) }) } } func TestReaderDiagnosticsBoundAttributeDetailsAndHandleSessionChanges(t *testing.T) { log := capture_diagnostics(t, slog.LevelDebug) r := New() r.Update(screen.Snapshot{}) // Inactive captures may have no geometry or cells. check_update_record(t, log, map[string]string{"path": `"inactive"`, "comparable": "false"}) before := fixture("PRIVATE", "PRIVATE", "PRIVATE", "PRIVATE") before.Attributes = make([]screen.Attribute, len(before.Cells)) r.Update(before) check_update_record(t, log, map[string]string{"path": `"session-change"`, "comparable": "false"}) next := before next.Attributes = make([]screen.Attribute, len(before.Cells)) for index := range next.Attributes { next.Attributes[index].Background = uint32(index%2 + 1) } log.Reset() r.Update(next) record := check_update_record(t, log, map[string]string{ "attribute_changes_truncated": "true", "attribute_cells": "80", "attribute_rows": "[0,1,2,3]", }) var changes []AttributeChange if err := json.Unmarshal(record["attribute_changes"], &changes); err != nil { t.Fatal(err) } if len(changes) != 32 || changes[0].Row != 0 || changes[0].Start != 0 || changes[0].End != 1 || changes[0].After.Background != 1 { t.Fatal("attribute details lost geometry/appearance or exceeded their bound", changes) } }