// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package ninjam_test import ( "slices" "strconv" "strings" "testing" "git.stormux.org/storm/fenrir/internal/applicationoverrides/ninjam" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/presentation" "git.stormux.org/storm/fenrir/internal/reader" "git.stormux.org/storm/fenrir/internal/screen" ) func snapshot(selected int, mute, volume string, messages ...string) screen.Snapshot { s := screen.Snapshot{Terminal: 1, Active: true, Rows: 24, Cols: 100, Y: 23, Cells: make([]rune, 2400), Attributes: make([]screen.Attribute, 2400)} for i := range s.Cells { s.Cells[i] = ' ' s.Attributes[i] = screen.Attribute{Foreground: screen.ColorIndexed | 7, Background: screen.ColorIndexed} } paint := func(row int, text string) { copy(s.Line(row), []rune(text)) } paint(0, "LOCAL") paint(1, " master: ["+mute+"]mute ["+volume+"] | metronome: [ ]mute [0.0dB center]") paint(2, " [Channel] [X]xmit [Input] [ ]mute [0.0dB center] [del] <-20.0dB>") paint(3, "REMOTE") paint(4, "Username") paint(5, " \"Remote channel\" [X]recv [ ]mute [0.0dB center] <-20.0dB>") paint(16, "CHAT") for i, text := range messages { if i < 4 { paint(21-len(messages)+i, text) } } paint(22, "####") paint(23, "[QUIT NINJAM] : fixture.invalid : 120.0BPM 16BPI : 48000Hz") field := 0 for row := range s.Rows { inside := false for col, char := range s.Line(row) { if char == ']' { inside = false } if inside && field-1 == selected { s.Attributes[row*s.Cols+col] = screen.Attribute{Foreground: screen.ColorIndexed, Background: screen.ColorIndexed | 7} } if char == '[' { inside = true field++ } } } return s } func texts(messages []presentation.Message) []string { var result []string for _, message := range messages { if message.Text != "" { result = append(result, message.Text) } } return result } func TestControlsHaveContextAndState(t *testing.T) { for _, trial := range []struct { selected int mute, want string }{ {0, " ", "Master unmuted"}, {0, "X", "Master muted"}, {1, " ", "Master Volume and pan 0.0dB center"}, {2, " ", "Metronome unmuted"}, {5, " ", "Local channel Channel transmitting"}, {10, " ", "Remote Username, channel Remote channel receiving"}, } { a := &ninjam.Adapter{} frame := a.Observe(snapshot(trial.selected, trial.mute, "0.0dB center")) if got := texts(frame.Focus); !frame.Recognized || !slices.Equal(got, []string{trial.want}) { t.Fatalf("%+v: %v", trial, got) } } } func TestFocusChatMetersAndValueReflow(t *testing.T) { a := &ninjam.Adapter{} r := reader.New() update := func(s screen.Snapshot) []string { return texts(r.Update_application(s, a.Observe(s))) } update(snapshot(0, " ", "0.0dB center", " first")) got := update(snapshot(1, " ", "0.0dB center", " first", " second")) if !slices.Equal(got, []string{"Master Volume and pan 0.0dB center", " second"}) { t.Fatal(got) } got = update(snapshot(1, " ", "-0.5dB center", " first", " second")) if !slices.Equal(got, []string{"minus 0.5dB"}) { t.Fatal("reflow", got) } s := snapshot(1, " ", "-0.5dB center", " first", " second") copy(s.Line(22), []rune("##################")) copy(s.Line(2)[65:], []rune("<-10.0dB>")) if got := update(s); len(got) != 0 { t.Fatal("meter chatter", got) } if result, _ := a.Command(command.ReadLiveRegion1); result.Text != " second" { t.Fatal(result) } if result, _ := a.Command(command.ReadLiveRegion3); !strings.Contains(result.Text, "120.0BPM") || !strings.Contains(result.Text, "Interval position 18 of 100") { t.Fatal(result) } } func TestReviewResumesOnlyOnApplicationNavigation(t *testing.T) { for _, direct := range []bool{false, true} { a, r := &ninjam.Adapter{}, reader.New() s := snapshot(0, " ", "0.0dB center", " old") r.Update_application(s, a.Observe(s)) r.Command(command.CurrentLine) if direct { r.Note_input(true, false, false) } s = snapshot(1, " ", "0.0dB center", " old", " new") got := texts(r.Update_application(s, a.Observe(s))) want := []string{" new"} if direct { want = append([]string{"Master Volume and pan 0.0dB center"}, want...) } if !slices.Equal(got, want) { t.Fatal(direct, got) } } } func TestChatScrollPagingRepetitionAndResize(t *testing.T) { a := &ninjam.Adapter{} a.Observe(snapshot(0, " ", "0.0dB center", " a", " b", " c", " d")) frame := a.Observe(snapshot(0, " ", "0.0dB center", " b", " c", " d", " d")) if got := texts(frame.Updates); !slices.Equal(got, []string{" d"}) { t.Fatal("repeated message", got) } a.Note_key(104) // An interval update can arrive before the application's history redraw. a.Observe(snapshot(0, " ", "0.0dB center", " b", " c", " d", " d")) frame = a.Observe(snapshot(0, " ", "0.0dB center", " old", " b", " c", " d")) if len(frame.Updates) != 0 { t.Fatal("history announced", frame.Updates) } s := snapshot(0, " ", "0.0dB center", " old history") s.Generation++ if frame = a.Observe(s); len(frame.Updates) != 0 { t.Fatal("buffer rebaseline", frame.Updates) } copy(s.Line(0), []rune("SHELL")) if a.Observe(s).Recognized { t.Fatal("unrelated screen recognized") } } func TestChatAlongsideTypingAndMutedAutomaticReading(t *testing.T) { a, r := &ninjam.Adapter{}, reader.New() s := snapshot(-1, " ", "0.0dB center", " old") s.Y = 21 r.Update_application(s, a.Observe(s)) r.Note_input(true, true, false) s = snapshot(-1, " ", "0.0dB center", " old", " incoming") s.Y, s.X = 21, 1 s.Line(21)[0] = 'a' got := texts(r.Update_application(s, a.Observe(s))) if !slices.Equal(got, []string{"a", " incoming"}) { t.Fatal(got) } r.Configure(false, true) s = snapshot(-1, " ", "0.0dB center", " incoming", " silent") s.Y = 21 if got := texts(r.Update_application(s, a.Observe(s))); len(got) != 0 { t.Fatal(got) } if result, _ := a.Command(command.ReadLiveRegion1); result.Text != " silent" { t.Fatal("latest lost while autoread off", result) } } func TestChatBurstAndTransientClearedArea(t *testing.T) { a := &ninjam.Adapter{} old := []string{" a", " b", " c", " d"} a.Observe(snapshot(0, " ", "0.0dB center", old...)) if frame := a.Observe(snapshot(0, " ", "0.0dB center")); len(frame.Updates) != 0 { t.Fatal("cleared area announced", frame.Updates) } if frame := a.Observe(snapshot(0, " ", "0.0dB center", old...)); len(frame.Updates) != 0 { t.Fatal("redraw replayed history", frame.Updates) } burst := []string{" e", " f", " g", " h"} if got := texts(a.Observe(snapshot(0, " ", "0.0dB center", burst...)).Updates); !slices.Equal(got, burst) { t.Fatal("visible burst lost", got) } } func TestWrappedChatRecallAndChangedGeometry(t *testing.T) { a := &ninjam.Adapter{} a.Observe(snapshot(0, " ", "0.0dB center", " old")) first := " " + strings.Repeat("x", 99-len(" ")) frame := a.Observe(snapshot(0, " ", "0.0dB center", " old", first, " continuation")) want := first + " continuation" if got := texts(frame.Updates); !slices.Equal(got, []string{want}) { t.Fatal("wrapped message", got) } if result, _ := a.Command(command.ReadLiveRegion1); result.Text != want { t.Fatal("recall lost continuation", result) } old := snapshot(0, " ", "0.0dB center") resized := screen.Snapshot{Terminal: 1, Active: true, Rows: 20, Cols: 80, Y: 19, Cells: make([]rune, 1600), Attributes: make([]screen.Attribute, 1600)} for i := range resized.Cells { resized.Cells[i] = ' ' } for row := 0; row < 3; row++ { copy(resized.Line(row), old.Line(row)[:80]) copy(resized.Attributes[row*80:(row+1)*80], old.Attributes[row*100:row*100+80]) } copy(resized.Line(13), []rune("CHAT")) copy(resized.Line(16), []rune(" old history")) copy(resized.Line(18), []rune("#####")) copy(resized.Line(19), old.Line(23)[:80]) if frame := a.Observe(resized); len(frame.Updates) != 0 { t.Fatal("resize replayed history", frame.Updates) } copy(resized.Line(15), resized.Line(16)) clear(resized.Line(16)) copy(resized.Line(16), []rune(" after resize")) if got := texts(a.Observe(resized).Updates); !slices.Equal(got, []string{" after resize"}) { t.Fatal("resized chat region", got) } if result, _ := a.Command(command.ReadLiveRegion3); !strings.Contains(result.Text, "of 80") { t.Fatal("resized interval region", result) } } func TestVolumeAndPanAnnounceOnlyChangedComponent(t *testing.T) { for _, selected := range []int{1, 3, 8, 12} { t.Run(strconv.Itoa(selected), func(t *testing.T) { a, r := &ninjam.Adapter{}, reader.New() makeSnapshot := func(value string, chat ...string) screen.Snapshot { s := snapshot(selected, " ", "0.0dB center", chat...) // Repaint the selected bracket with the same style, including // changes in width at center and across signed dB values. for row := range s.Rows { for col, attribute := range s.Attributes[row*s.Cols : (row+1)*s.Cols] { if attribute.Background != screen.ColorIndexed|7 { continue } line := string(s.Line(row)) start := col end := start + strings.Index(line[start:], "]") line = line[:start] + value + line[end:] for i := range s.Line(row) { s.Line(row)[i] = ' ' s.Attributes[row*s.Cols+i] = screen.Attribute{Foreground: screen.ColorIndexed | 7, Background: screen.ColorIndexed} } copy(s.Line(row), []rune(line)) for i := start; i < start+len(value); i++ { s.Attributes[row*s.Cols+i] = attribute } return s } } t.Fatal("missing selected field") return s } update := func(value string, chat ...string) []string { s := makeSnapshot(value, chat...) return texts(r.Update_application(s, a.Observe(s))) } update("0.0dB center", " old") for _, trial := range []struct{ value, want string }{ {"0.0dB 1%L", "1 percent left"}, {"0.0dB 2%L", "2 percent left"}, {"0.0dB center", "center"}, {"0.0dB 1%R", "1 percent right"}, {"-0.5dB 1%R", "minus 0.5dB"}, {"0.0dB 1%R", "0.0dB"}, {"+0.5dB 1%R", "plus 0.5dB"}, {"+1.0dB 2%R", "plus 1.0dB 2 percent right"}, } { if got := update(trial.value, " old"); !slices.Equal(got, []string{trial.want}) { t.Fatalf("%s: %v", trial.value, got) } } if got := update("+1.0dB 2%R", " old"); len(got) != 0 { t.Fatal("unchanged value repeated", got) } if got := update("+1.0dB 3%R", " old", " new"); !slices.Equal(got, []string{"3 percent right", " new"}) { t.Fatal("chat alongside pan", got) } if result, _ := a.Command(command.ReadLiveRegion2); !strings.Contains(result.Text, "Volume and pan plus 1.0dB 3 percent right") { t.Fatal("full control recall lost", result) } r.Command(command.CurrentLine) if got := update("+1.0dB 4%R", " new", " review"); !slices.Equal(got, []string{" review"}) { t.Fatal("pan disturbed detached review", got) } }) } }