Files
ratatoskr-go/internal/ui/log_panel_test.go
ki.sagidullin 70287140ec
Some checks failed
CI / test (pull_request) Failing after 1m14s
CI / build-and-package (amd64, linux) (pull_request) Failing after 1m3s
CI / build-and-package (amd64, windows) (pull_request) Successful in 28s
perf(ui): инкрементальный рендер ленты логов (дельта вместо полного пересбора)
2026-08-21 23:59:59 +05:00

321 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import "testing"
// NilLogPanel — no-op контракт (спец 12.8): Append ничего не делает, безопасно
// вызывается из горутин Hub и из тестов без Fyne.
func TestNilLogPanelAppendNoop(t *testing.T) {
p := NewNilLogPanel()
p.Append(LogLevelError, "line 1")
p.Append("", "")
p.Append(LogLevelDebug, "line 3")
}
// NormalizeLogLevel приводит любые уровни к каноническим четырём:
// синонимы маппятся, неизвестные/пустые значения считаются info.
func TestNormalizeLogLevel(t *testing.T) {
cases := []struct {
in string
want string
}{
{LogLevelError, LogLevelError},
{"ERROR", LogLevelError},
{" err ", LogLevelError},
{"fatal", LogLevelError},
{LogLevelWarning, LogLevelWarning},
{"WARN", LogLevelWarning},
{"Warning", LogLevelWarning},
{LogLevelInfo, LogLevelInfo},
{"INFO", LogLevelInfo},
{LogLevelDebug, LogLevelDebug},
{"DEBUG", LogLevelDebug},
{"trace", LogLevelDebug},
// события LogWriter без явного уровня и прочий мусор → info
{"log", LogLevelInfo},
{"", LogLevelInfo},
{" ", LogLevelInfo},
{"unknown", LogLevelInfo},
}
for _, c := range cases {
if got := NormalizeLogLevel(c.in); got != c.want {
t.Errorf("NormalizeLogLevel(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// LogLevels — ровно четыре уровня фильтра в фиксированном порядке.
func TestLogLevelsContract(t *testing.T) {
want := []string{LogLevelError, LogLevelWarning, LogLevelInfo, LogLevelDebug}
if len(LogLevels) != len(want) {
t.Fatalf("len(LogLevels) = %d, want %d", len(LogLevels), len(want))
}
for i := range want {
if LogLevels[i] != want[i] {
t.Fatalf("LogLevels = %v, want %v", LogLevels, want)
}
}
}
// allLevels — набор «отмечены все уровни» (состояние фильтра по умолчанию).
func allLevels() map[string]bool {
m := make(map[string]bool, len(LogLevels))
for _, lvl := range LogLevels {
m[lvl] = true
}
return m
}
// По умолчанию (все уровни отмечены) рендерится полный лог; уровни
// нормализуются при добавлении.
func TestLogBufferRenderAllByDefault(t *testing.T) {
b := NewLogBuffer(1000)
b.Append("error", "e1")
b.Append("warn", "w1") // синоним → warning
b.Append("log", "i1") // неизвестный уровень → info
b.Append("trace", "d1") // синоним → debug
got := b.Render(allLevels())
want := "e1\nw1\ni1\nd1\n"
if got != want {
t.Fatalf("Render(all) = %q, want %q", got, want)
}
entries := b.Entries()
wantLvls := []string{LogLevelError, LogLevelWarning, LogLevelInfo, LogLevelDebug}
for i, e := range entries {
if e.Level != wantLvls[i] {
t.Fatalf("entry[%d].Level = %q, want %q", i, e.Level, wantLvls[i])
}
}
}
// Показываются только записи выбранных уровней.
func TestLogBufferRenderSelectedOnly(t *testing.T) {
b := NewLogBuffer(1000)
b.Append(LogLevelError, "err line")
b.Append(LogLevelWarning, "warn line")
b.Append(LogLevelInfo, "info line")
b.Append(LogLevelDebug, "debug line")
onlyErrors := map[string]bool{LogLevelError: true}
if got, want := b.Render(onlyErrors), "err line\n"; got != want {
t.Fatalf("Render(error only) = %q, want %q", got, want)
}
errorsAndDebug := map[string]bool{LogLevelError: true, LogLevelDebug: true}
if got, want := b.Render(errorsAndDebug), "err line\ndebug line\n"; got != want {
t.Fatalf("Render(error+debug) = %q, want %q", got, want)
}
}
// Ни один уровень не выбран — лента пуста (и это не ошибка).
func TestLogBufferRenderEmptySelection(t *testing.T) {
b := NewLogBuffer(1000)
b.Append(LogLevelError, "err line")
b.Append(LogLevelInfo, "info line")
if got := b.Render(map[string]bool{}); got != "" {
t.Fatalf("Render(none) = %q, want empty", got)
}
if got := b.Render(nil); got != "" {
t.Fatalf("Render(nil) = %q, want empty", got)
}
}
// Фильтрация применяется и к уже загруженным записям: смена набора уровней
// меняет результат рендера того же буфера без новых Append.
func TestLogBufferRefilterLoadedEntries(t *testing.T) {
b := NewLogBuffer(1000)
b.Append(LogLevelError, "err line")
b.Append(LogLevelInfo, "info line")
all := allLevels()
if got, want := b.Render(all), "err line\ninfo line\n"; got != want {
t.Fatalf("Render(all) = %q, want %q", got, want)
}
delete(all, LogLevelInfo)
if got, want := b.Render(all), "err line\n"; got != want {
t.Fatalf("Render(error) after refilter = %q, want %q", got, want)
}
all[LogLevelInfo] = true // вернули галочку — запись снова видима
if got, want := b.Render(all), "err line\ninfo line\n"; got != want {
t.Fatalf("Render(all) after re-check = %q, want %q", got, want)
}
}
// Новые записи сразу попадают в ленту своего уровня.
func TestLogBufferNewEntriesVisibleImmediately(t *testing.T) {
b := NewLogBuffer(1000)
sel := map[string]bool{LogLevelError: true}
b.Append(LogLevelError, "first")
if got, want := b.Render(sel), "first\n"; got != want {
t.Fatalf("Render = %q, want %q", got, want)
}
b.Append(LogLevelInfo, "noise") // невидимый уровень — ленту не меняет
if got, want := b.Render(sel), "first\n"; got != want {
t.Fatalf("Render after invisible append = %q, want %q", got, want)
}
b.Append(LogLevelError, "second")
if got, want := b.Render(sel), "first\nsecond\n"; got != want {
t.Fatalf("Render after visible append = %q, want %q", got, want)
}
}
// Буфер ограничен: старые записи вытесняются, суммарный объём ≤ лимита.
func TestLogBufferBounded(t *testing.T) {
b := NewLogBuffer(10)
for i := 0; i < 5; i++ {
b.Append(LogLevelInfo, "0123456789") // каждая запись — ровно лимит
}
entries := b.Entries()
if len(entries) != 1 {
t.Fatalf("len(entries) = %d, want 1 (старые вытеснены)", len(entries))
}
if entries[0].Text != "0123456789" {
t.Fatalf("text = %q", entries[0].Text)
}
// одиночная запись длиннее лимита обрезается до последних maxLen символов
b2 := NewLogBuffer(4)
b2.Append(LogLevelInfo, "abcdefgh")
if got := b2.Entries()[0].Text; got != "efgh" {
t.Fatalf("oversized entry text = %q, want %q", got, "efgh")
}
}
// Буфер держит только живые записи: ленивое вытеснение (start) не даёт
// «мёртвым» записям попасть в Entries/Render.
func TestLogBufferLiveEntriesOnly(t *testing.T) {
b := NewLogBuffer(30)
for i := 0; i < 3; i++ {
b.Append(LogLevelInfo, "0123456789") // 10 байт + \n каждая
}
// сумма 33 > 30 → вытесняется первая запись, остаются две живые
entries := b.Entries()
if len(entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 (мёртвая запись скрыта)", len(entries))
}
if entries[0].Text != "0123456789" || entries[1].Text != "0123456789" {
t.Fatalf("unexpected live entries: %+v", entries)
}
if got, want := b.Render(allLevels()), "0123456789\n0123456789\n"; got != want {
t.Fatalf("Render = %q, want %q", got, want)
}
}
// Кап считается по байтам текста, а не по числу записей: короткие записи
// не вытесняются, пока их суммарный объём не превысит лимит.
func TestLogBufferBytesCap(t *testing.T) {
b := NewLogBuffer(100)
for i := 0; i < 3; i++ {
b.Append(LogLevelInfo, "hello") // 5 байт + \n
}
if got := len(b.Entries()); got != 3 {
t.Fatalf("len(Entries) = %d, want 3 (все помещаются)", got)
}
// одна большая запись вытесняет всё старое (live=1) и обрезается
b2 := NewLogBuffer(8)
b2.Append(LogLevelInfo, "short")
b2.Append(LogLevelInfo, "verylongtext")
entries := b2.Entries()
if len(entries) != 1 {
t.Fatalf("len(Entries) = %d, want 1", len(entries))
}
if entries[0].Text != "longtext" {
t.Fatalf("oversized entry text = %q, want %q", entries[0].Text, "longtext")
}
}
// Первый RenderNew отдаёт весь видимый текст, последующие — только новые
// записи (инкрементальная отрисовка: повторный вызов без Append пуст).
func TestLogBufferRenderNewDelta(t *testing.T) {
b := NewLogBuffer(1000)
sel := allLevels()
b.Append(LogLevelError, "e1")
b.Append(LogLevelInfo, "i1")
text, full := b.RenderNew(sel)
if full {
t.Fatalf("первый RenderNew не должен требовать полного пересбора, got full")
}
if text != "e1\ni1\n" {
t.Fatalf("первый RenderNew = %q, want %q", text, "e1\ni1\n")
}
// Без новых записей — пустой дельта.
text, full = b.RenderNew(sel)
if full || text != "" {
t.Fatalf("повторный RenderNew без новых записей = (%q, %v), want (\"\", false)", text, full)
}
// Новые записи — только их текст, без повторной отдачи старых.
b.Append(LogLevelInfo, "i2")
text, _ = b.RenderNew(sel)
if text != "i2\n" {
t.Fatalf("дельта RenderNew = %q, want %q", text, "i2\n")
}
}
// RenderNew применяет фильтр: невидимые уровни не попадают в дельту.
func TestLogBufferRenderNewFiltered(t *testing.T) {
b := NewLogBuffer(1000)
sel := map[string]bool{LogLevelError: true}
b.Append(LogLevelError, "e1")
b.Append(LogLevelInfo, "i1")
b.Append(LogLevelError, "e2")
text, _ := b.RenderNew(sel)
if text != "e1\ne2\n" {
t.Fatalf("RenderNew(error only) = %q, want %q", text, "e1\ne2\n")
}
}
// RenderNew уважает ResetRendered: после полного рендера новые записи
// добавляются дельтом, а не вытесняют уже выведенный текст.
func TestLogBufferRenderNewAfterReset(t *testing.T) {
b := NewLogBuffer(1000)
sel := allLevels()
b.Append(LogLevelInfo, "old")
b.Append(LogLevelInfo, "base")
text, full := b.RenderNew(sel)
if full || text != "old\nbase\n" {
t.Fatalf("RenderNew = (%q, %v), want (\"old\\nbase\\n\", false)", text, full)
}
b.ResetRendered() // имитация полной пересборки ленты
b.Append(LogLevelInfo, "new")
text, full = b.RenderNew(sel)
if full || text != "new\n" {
t.Fatalf("RenderNew после Reset = (%q, %v), want (\"new\\n\", false)", text, full)
}
}
// Вытеснение уже выведенных записей помечает рендер как требующий полного
// пересбора (stale): дельта не может убрать верхние строки.
func TestLogBufferRenderNewEvictionStale(t *testing.T) {
b := NewLogBuffer(25) // записи по 10 байт + \n: помещаются 2, 3-я вытесняет 1-ю
sel := allLevels()
b.Append(LogLevelInfo, "0123456789")
b.Append(LogLevelInfo, "0123456789")
text, full := b.RenderNew(sel)
if full || text != "0123456789\n0123456789\n" {
t.Fatalf("RenderNew = (%q, %v), want two lines", text, full)
}
// Третья запись вытесняет первую (уже отрисованную) — stale.
b.Append(LogLevelInfo, "0123456789")
text, full = b.RenderNew(sel)
if !full {
t.Fatalf("RenderNew после вытеснения = (full=%v), want full=true", full)
}
if text != "0123456789\n0123456789\n" {
t.Fatalf("полный текст после вытеснения = %q, want последние две записи", text)
}
}