diff --git a/internal/app/lazytable.go b/internal/app/lazytable.go index e5e7d53..b44b499 100644 --- a/internal/app/lazytable.go +++ b/internal/app/lazytable.go @@ -77,9 +77,10 @@ func (m lazyTableModel) handleKey(msg tea.KeyMsg, render bool) (lazyTableModel, // this function increases the viewport offset by 1 if possible. (scrolls down) increaseOffset := func() { maxOffset := max(m.entries.Len()-m.table.Height(), 0) - o := min(m.offset+1, maxOffset) - if o != m.offset { - m.offset = o + + offset := min(m.offset+1, maxOffset) + if offset != m.offset { + m.offset = offset render = true } else { // we were at the last item, so we should follow the log @@ -111,12 +112,14 @@ func (m lazyTableModel) handleKey(msg tea.KeyMsg, render bool) (lazyTableModel, increaseOffset() // move the viewport } } + if key.Matches(msg, m.Application.keys.Up) { m.follow = false if m.table.Cursor() == 0 { decreaseOffset() // move the viewport } } + if key.Matches(msg, m.Application.keys.GotoTop) { if m.reverse { // when follow is enabled, rendering will handle setting the offset to the correct value @@ -127,6 +130,7 @@ func (m lazyTableModel) handleKey(msg tea.KeyMsg, render bool) (lazyTableModel, } render = true } + if key.Matches(msg, m.Application.keys.GotoBottom) { if m.reverse { m.follow = false diff --git a/internal/app/stateerror_test.go b/internal/app/stateerror_test.go index 5cdfdd0..a6ab662 100644 --- a/internal/app/stateerror_test.go +++ b/internal/app/stateerror_test.go @@ -23,11 +23,13 @@ func TestStateError(t *testing.T) { _, ok := model.(app.StateErrorModel) assert.Truef(t, ok, "%s", model) + return model } t.Run("rendered", func(t *testing.T) { t.Parallel() + model := setup() rendered := model.View() assert.Contains(t, rendered, errTest.Error()) @@ -35,14 +37,27 @@ func TestStateError(t *testing.T) { t.Run("any_key_msg", func(t *testing.T) { t.Parallel() + model := setup() _, cmd := model.Update(tea.KeyMsg{}) assert.Equal(t, tea.Quit(), cmd()) }) + t.Run("unknown_message", func(t *testing.T) { + t.Parallel() + + model := setup() + + model, _ = model.Update(nil) + + _, ok := model.(app.StateErrorModel) + assert.Truef(t, ok, "%s", model) + }) + t.Run("stringer", func(t *testing.T) { t.Parallel() + model := setup() stringer, ok := model.(fmt.Stringer) diff --git a/internal/app/statefiltered.go b/internal/app/statefiltered.go index dce3c04..52c6611 100644 --- a/internal/app/statefiltered.go +++ b/internal/app/statefiltered.go @@ -56,47 +56,68 @@ func (s StateFilteredModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.Application.Update(msg) - switch typedMsg := msg.(type) { - case *StateFilteredModel: - entries, err := s.Application.Entries.Filter(s.filterText) - if err != nil { - return s, events.ShowError(err) - } - s.logEntries = entries - s.table = newLogsTableModel(s.Application, entries) - msg = events.LogEntriesUpdateMsg(entries) - case events.LogEntriesUpdateMsg: - entries, err := s.Application.Entries.Filter(s.filterText) - if err != nil { - return s, events.ShowError(err) - } - s.logEntries = entries - msg = events.LogEntriesUpdateMsg(entries) + if _, ok := msg.(*StateFilteredModel); ok { + s, msg = s.handleStateFilteredModel() + } + + if _, ok := msg.(*events.LogEntriesUpdateMsg); ok { + s, msg = s.handleLogEntriesUpdateMsg() + } + switch typedMsg := msg.(type) { case events.ErrorOccuredMsg: return s.handleErrorOccuredMsg(typedMsg) case events.OpenJSONRowRequestedMsg: return s.handleOpenJSONRowRequestedMsg(typedMsg, s) case tea.KeyMsg: - switch { - case key.Matches(typedMsg, s.keys.Back): - return s.previousState.refresh() - case key.Matches(typedMsg, s.keys.Filter): - return s.handleFilterKeyClickedMsg() - case key.Matches(typedMsg, s.keys.ToggleViewArrow), key.Matches(typedMsg, s.keys.Open): - return s.handleRequestOpenJSON() - } - if cmd := s.handleKeyMsg(typedMsg); cmd != nil { - return s, cmd + if mdl, cmd := s.handleKeyMsg(typedMsg); mdl != nil { + return mdl, cmd } default: s.table, cmdBatch = batched(s.table.Update(typedMsg))(cmdBatch) } s.table, cmdBatch = batched(s.table.Update(msg))(cmdBatch) + return s, tea.Batch(cmdBatch...) } +func (s StateFilteredModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, s.keys.Back): + return s.previousState.refresh() + case key.Matches(msg, s.keys.Filter): + return s.handleFilterKeyClickedMsg() + case key.Matches(msg, s.keys.ToggleViewArrow), key.Matches(msg, s.keys.Open): + return s.handleRequestOpenJSON() + default: + return nil, nil + } +} + +func (s StateFilteredModel) handleLogEntriesUpdateMsg() (StateFilteredModel, tea.Msg) { + entries, err := s.Application.Entries.Filter(s.filterText) + if err != nil { + return s, events.ShowError(err)() + } + + s.logEntries = entries + + return s, events.LogEntriesUpdateMsg(entries) +} + +func (s StateFilteredModel) handleStateFilteredModel() (StateFilteredModel, tea.Msg) { + entries, err := s.Application.Entries.Filter(s.filterText) + if err != nil { + return s, events.ShowError(err)() + } + + s.logEntries = entries + s.table = newLogsTableModel(s.Application, entries) + + return s, events.LogEntriesUpdateMsg(entries) +} + func (s StateFilteredModel) handleFilterKeyClickedMsg() (tea.Model, tea.Cmd) { state := newStateFiltering(s.previousState) return initializeModel(state) @@ -104,7 +125,7 @@ func (s StateFilteredModel) handleFilterKeyClickedMsg() (tea.Model, tea.Cmd) { func (s StateFilteredModel) handleRequestOpenJSON() (tea.Model, tea.Cmd) { if s.logEntries.Len() == 0 { - return s, events.BackKeyClicked + return s, events.EscKeyClicked } return s, events.OpenJSONRowRequested(s.logEntries, s.table.Cursor()) @@ -114,9 +135,9 @@ func (s StateFilteredModel) getApplication() *Application { return s.Application } -func (s StateFilteredModel) refresh() (stateModel, tea.Cmd) { - var cmd tea.Cmd +func (s StateFilteredModel) refresh() (_ stateModel, cmd tea.Cmd) { s.table, cmd = s.table.Update(s.Application.LastWindowSize) + return s, cmd } diff --git a/internal/app/statefiltered_test.go b/internal/app/statefiltered_test.go index e03d312..654ae03 100644 --- a/internal/app/statefiltered_test.go +++ b/internal/app/statefiltered_test.go @@ -47,7 +47,7 @@ func TestStateFiltered(t *testing.T) { // Write term to search by. model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyRunes, - Runes: []rune(termIncluded), + Runes: []rune(termIncluded[1:]), }) // Filter. @@ -59,14 +59,16 @@ func TestStateFiltered(t *testing.T) { if assert.Truef(t, ok, "%s", model) { rendered = model.View() assert.Contains(t, rendered, termIncluded) - assert.Contains(t, rendered, "filtered 1 by: "+termIncluded) + assert.Contains(t, rendered, "filtered 1 by: "+termIncluded[1:]) assert.NotContains(t, rendered, termExcluded) } + return model } t.Run("reopen_filter", func(t *testing.T) { t.Parallel() + model := setup() model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyRunes, @@ -79,6 +81,7 @@ func TestStateFiltered(t *testing.T) { t.Run("open_hide_json_view", func(t *testing.T) { t.Parallel() + model := setup() model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyEnter, @@ -97,6 +100,7 @@ func TestStateFiltered(t *testing.T) { t.Run("error", func(t *testing.T) { t.Parallel() + model := setup() model = handleUpdate(model, events.ErrorOccuredMsg{Err: getTestError()}) @@ -106,6 +110,7 @@ func TestStateFiltered(t *testing.T) { t.Run("navigation", func(t *testing.T) { t.Parallel() + model := setup() model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyUp, @@ -117,6 +122,7 @@ func TestStateFiltered(t *testing.T) { t.Run("returned", func(t *testing.T) { t.Parallel() + model := setup() model = handleUpdate(model, tea.KeyMsg{ Type: tea.KeyEsc, @@ -128,10 +134,21 @@ func TestStateFiltered(t *testing.T) { t.Run("stringer", func(t *testing.T) { t.Parallel() + model := setup() stringer, ok := model.(fmt.Stringer) if assert.True(t, ok) { assert.Contains(t, stringer.String(), "StateFiltered") } }) + + t.Run("updated", func(t *testing.T) { + t.Parallel() + + model := setup() + model = handleUpdate(model, &events.LogEntriesUpdateMsg{}) + + rendered := model.View() + assert.Contains(t, rendered, termIncluded) + }) } diff --git a/internal/app/statefiltering.go b/internal/app/statefiltering.go index 19d2fa8..31aaf66 100644 --- a/internal/app/statefiltering.go +++ b/internal/app/statefiltering.go @@ -56,15 +56,8 @@ func (s StateFilteringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case events.ErrorOccuredMsg: return s.handleErrorOccuredMsg(msg) case tea.KeyMsg: - switch { - case key.Matches(msg, s.keys.Back): - return s.previousState.refresh() - case key.Matches(msg, s.keys.Open): - return s.handleEnterKeyClickedMsg() - } - if cmd := s.handleKeyMsg(msg); cmd != nil { - // Intercept table update. - return s, cmd + if mdl, cmd := s.handleKeyMsg(msg); mdl != nil { + return mdl, cmd } default: s.table, cmdBatch = batched(s.table.Update(msg))(cmdBatch) @@ -75,17 +68,20 @@ func (s StateFilteringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, tea.Batch(cmdBatch...) } -func (s StateFilteringModel) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { - if len(msg.Runes) == 1 { - return nil +func (s StateFilteringModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, s.keys.Back): + return s.previousState.refresh() + case key.Matches(msg, s.keys.Open): + return s.handleEnterKeyClickedMsg() + default: + return nil, nil } - - return s.Application.handleKeyMsg(msg) } func (s StateFilteringModel) handleEnterKeyClickedMsg() (tea.Model, tea.Cmd) { if s.textInput.Value() == "" { - return s, events.BackKeyClicked + return s, events.EscKeyClicked } return initializeModel(newStateFiltered( diff --git a/internal/app/statefiltering_test.go b/internal/app/statefiltering_test.go index 1094c7c..8bf75fe 100644 --- a/internal/app/statefiltering_test.go +++ b/internal/app/statefiltering_test.go @@ -99,6 +99,61 @@ func TestStateFiltering(t *testing.T) { _, ok := model.(app.StateFilteringModel) assert.Truef(t, ok, "%s", model) }) + + t.Run("runes", func(t *testing.T) { + model := setup() + + const content = "hello world" + + for _, r := range content { + model = handleUpdate(model, tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{r}, + }) + } + + assert.Contains(t, model.View(), content) + }) + + t.Run("arrow_right", func(t *testing.T) { + model := setup() + + model = handleUpdate(model, events.ArrowRightKeyClicked()) + + const content = "hello word" + + // 1. Input "hello word". + // 2. Press "Left" 2 times: "hello wo|rd" + // 3. Press "Right" 1 time: "hello wor|d". + // 4. Input "r". + // 5. Expect to see "hello world". + for _, r := range content { + model = handleUpdate(model, tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{r}, + }) + } + + model = handleUpdate(model, tea.KeyMsg{Type: tea.KeyLeft}) + model = handleUpdate(model, tea.KeyMsg{Type: tea.KeyLeft}) + model = handleUpdate(model, tea.KeyMsg{Type: tea.KeyRight}) + + model = handleUpdate(model, tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{'l'}, + }) + + assert.Contains(t, model.View(), "hello world") + }) + + t.Run("unknown_message", func(t *testing.T) { + model := setup() + + model = handleUpdate(model, nil) + + _, ok := model.(app.StateFilteringModel) + assert.Truef(t, ok, "%s", model) + }) } func TestStateFilteringReset(t *testing.T) { diff --git a/internal/app/stateinitial_test.go b/internal/app/stateinitial_test.go index b96815c..213a382 100644 --- a/internal/app/stateinitial_test.go +++ b/internal/app/stateinitial_test.go @@ -18,9 +18,9 @@ import ( func TestStateInitial(t *testing.T) { t.Parallel() - is, err := source.Reader(bytes.NewReader([]byte{}), config.GetDefaultConfig()) + inputSource, err := source.Reader(bytes.NewReader([]byte{}), config.GetDefaultConfig()) require.NoError(t, err) - t.Cleanup(func() { _ = is.Close() }) + t.Cleanup(func() { _ = inputSource.Close() }) model := app.NewModel( "-", @@ -28,13 +28,31 @@ func TestStateInitial(t *testing.T) { testVersion, ) - entries, err := is.ParseLogEntries() + entries, err := inputSource.ParseLogEntries() require.NoError(t, err) handleUpdate(model, events.LogEntriesUpdateMsg(entries)) _, ok := model.(app.StateInitialModel) require.Truef(t, ok, "%s", model) + t.Run("Init", func(t *testing.T) { + t.Parallel() + + model, ok := model.(app.StateInitialModel) + require.Truef(t, ok, "%s", model) + + assert.Nil(t, model.Init()) + }) + + t.Run("Unknown_Event", func(t *testing.T) { + t.Parallel() + + model := handleUpdate(model, nil) + + model, ok := model.(app.StateInitialModel) + require.Truef(t, ok, "%s", model) + }) + t.Run("stringer", func(t *testing.T) { t.Parallel() diff --git a/internal/app/stateloaded.go b/internal/app/stateloaded.go index 9a9b7ee..9ebf4b9 100644 --- a/internal/app/stateloaded.go +++ b/internal/app/stateloaded.go @@ -46,26 +46,30 @@ func (s StateLoadedModel) viewTable() string { return s.BaseStyle.Render(s.table.View()) } -func (s StateLoadedModel) viewHelp() string { - toggles := func() string { - toggles := []string{} - if s.table.lazyTable.reverse { - toggles = append(toggles, "reverse") - } - if s.table.lazyTable.follow { - toggles = append(toggles, "following") - } - if len(toggles) > 0 { - return fmt.Sprintf("( %s )", strings.Join(toggles, ", ")) - } - return "" +func (s StateLoadedModel) toggles() string { + toggles := make([]string, 0, 2) + + if s.table.lazyTable.reverse { + toggles = append(toggles, "reverse") + } + + if s.table.lazyTable.follow { + toggles = append(toggles, "following") } + if len(toggles) > 0 { + return fmt.Sprintf("( %s )", strings.Join(toggles, ", ")) + } + + return "" +} + +func (s StateLoadedModel) viewHelp() string { if s.help.ShowAll { toggleText := lipgloss.NewStyle(). Background(lipgloss.Color("#353533")). Padding(0, 1). - Render(toggles()) + Render(s.toggles()) versionText := lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFFDF5")). @@ -87,7 +91,7 @@ func (s StateLoadedModel) viewHelp() string { return "\n" + s.help.View(s.keys) + "\n" + lipgloss.NewStyle().Width(width).Render(bar) } - return "\n" + s.help.View(s.keys) + " " + toggles() + return "\n" + s.help.View(s.keys) + " " + s.toggles() } // Update handles events. It implements tea.Model. @@ -148,9 +152,9 @@ func (s StateLoadedModel) getApplication() *Application { return s.Application } -func (s StateLoadedModel) refresh() (stateModel, tea.Cmd) { - var cmd tea.Cmd +func (s StateLoadedModel) refresh() (_ stateModel, cmd tea.Cmd) { s.table, cmd = s.table.Update(s.Application.LastWindowSize) + return s, cmd } diff --git a/internal/app/stateloaded_test.go b/internal/app/stateloaded_test.go index 5b75c93..f84fad3 100644 --- a/internal/app/stateloaded_test.go +++ b/internal/app/stateloaded_test.go @@ -71,6 +71,67 @@ func TestStateLoaded(t *testing.T) { view := model.View() assert.Contains(t, view, testVersion) }) + + t.Run("hide_help", func(t *testing.T) { + t.Parallel() + model := setup() + + model = handleUpdate(model, events.HelpKeyClicked()) + model = handleUpdate(model, events.HelpKeyClicked()) + + view := model.View() + assert.NotContains(t, view, testVersion) + }) + + t.Run("label_following_default", func(t *testing.T) { + t.Parallel() + + model := setup() + + view := model.View() + assert.Contains(t, view, "following") + }) + + t.Run("label_not_following", func(t *testing.T) { + t.Parallel() + + model := setup() + model = handleUpdate(model, tea.KeyMsg{Type: tea.KeyDown}) + + view := model.View() + assert.NotContains(t, view, "following") + }) + + t.Run("label_reverse_default", func(t *testing.T) { + t.Parallel() + + model := setup() + + view := model.View() + assert.Contains(t, view, "reverse") + }) + + t.Run("label_not_reverse", func(t *testing.T) { + t.Parallel() + + model := setup() + model = handleUpdate(model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + + view := model.View() + assert.NotContains(t, view, "reverse") + }) + + t.Run("label_not_reverse_not_following", func(t *testing.T) { + t.Parallel() + + model := setup() + model = handleUpdate(model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + model = handleUpdate(model, tea.KeyMsg{Type: tea.KeyDown}) + + view := model.View() + assert.NotContains(t, view, "reverse") + assert.NotContains(t, view, "following") + }) } func TestStateLoadedQuit(t *testing.T) { @@ -138,11 +199,11 @@ func BenchmarkStateLoadedBig(b *testing.B) { b.ResetTimer() - is, err := source.Reader(contentReader, cfg) + inputSource, err := source.Reader(contentReader, cfg) require.NoError(b, err) - b.Cleanup(func() { _ = is.Close() }) + b.Cleanup(func() { _ = inputSource.Close() }) - logEntries, err := is.ParseLogEntries() + logEntries, err := inputSource.ParseLogEntries() if err != nil { b.Fatal(model.View()) } diff --git a/internal/pkg/events/events.go b/internal/pkg/events/events.go index dcc8b27..f2020dd 100644 --- a/internal/pkg/events/events.go +++ b/internal/pkg/events/events.go @@ -35,12 +35,14 @@ func OpenJSONRowRequested(logEntries source.LazyLogEntries, index int) func() te } } +// ShowError is an event about occurred error. func ShowError(err error) func() tea.Msg { return func() tea.Msg { return ErrorOccuredMsg{Err: err} } } +// HelpKeyClicked is a trigger to display detailed help. func HelpKeyClicked() tea.Msg { return tea.KeyMsg{ Type: tea.KeyRunes, @@ -48,6 +50,7 @@ func HelpKeyClicked() tea.Msg { } } +// EscKeyClicked is an "Esc" key event. func EscKeyClicked() tea.Msg { return tea.KeyMsg{Type: tea.KeyEsc} } @@ -69,8 +72,3 @@ func FilterKeyClicked() tea.Msg { Runes: []rune{'f'}, } } - -// BackKeyClicked implements tea.Cmd. It creates a message indicating 'Esc' has been clicked. -func BackKeyClicked() tea.Msg { - return tea.KeyMsg{Type: tea.KeyEscape} -} diff --git a/internal/pkg/events/events_test.go b/internal/pkg/events/events_test.go new file mode 100644 index 0000000..d186a20 --- /dev/null +++ b/internal/pkg/events/events_test.go @@ -0,0 +1,61 @@ +package events_test + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + + "github.com/hedhyw/json-log-viewer/internal/pkg/events" + "github.com/hedhyw/json-log-viewer/internal/pkg/source" + "github.com/hedhyw/json-log-viewer/internal/pkg/tests" +) + +func TestEvents(t *testing.T) { + t.Parallel() + + testCases := [...]struct { + Name string + Actual tea.Msg + Expected tea.Msg + }{{ + Name: "OpenJSONRowRequested", + Actual: events.OpenJSONRowRequested(source.LazyLogEntries{}, 0)(), + Expected: events.OpenJSONRowRequestedMsg{ + LogEntries: source.LazyLogEntries{}, + Index: 0, + }, + }, { + Name: "ShowError", + Actual: events.ShowError(tests.ErrTest)(), + Expected: events.ErrorOccuredMsg{Err: tests.ErrTest}, + }, { + Name: "HelpKeyClicked", + Actual: events.HelpKeyClicked(), + Expected: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}, + }, { + Name: "EscKeyClicked", + Actual: events.EscKeyClicked(), + Expected: tea.KeyMsg{Type: tea.KeyEsc}, + }, { + Name: "EnterKeyClicked", + Actual: events.EnterKeyClicked(), + Expected: tea.KeyMsg{Type: tea.KeyEnter}, + }, { + Name: "ArrowRightKeyClicked", + Actual: events.ArrowRightKeyClicked(), + Expected: tea.KeyMsg{Type: tea.KeyRight}, + }, { + Name: "FilterKeyClicked", + Actual: events.FilterKeyClicked(), + Expected: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}, + }} + + for _, testCase := range testCases { + assert.Equal(t, + testCase.Expected, + testCase.Actual, + testCase.Name, + ) + } +} diff --git a/internal/pkg/source/source_test.go b/internal/pkg/source/source_test.go index c21f859..e239de6 100644 --- a/internal/pkg/source/source_test.go +++ b/internal/pkg/source/source_test.go @@ -71,16 +71,18 @@ func TestParseLogEntries(t *testing.T) { func TestParseLogEntriesFromReaderLimited(t *testing.T) { t.Parallel() - content := `{}` + const content = `{}` cfg := config.GetDefaultConfig() cfg.MaxFileSizeBytes = 1 reader := strings.NewReader(content) - is, err := source.Reader(reader, cfg) + + inputSource, err := source.Reader(reader, cfg) require.NoError(t, err) - defer is.Close() - logEntries, err := is.ParseLogEntries() + defer func() { assert.NoError(t, inputSource.Close()) }() + + logEntries, err := inputSource.ParseLogEntries() require.NoError(t, err) require.Empty(t, logEntries.Entries) diff --git a/internal/pkg/tests/tests.go b/internal/pkg/tests/tests.go index 84529f5..0008340 100644 --- a/internal/pkg/tests/tests.go +++ b/internal/pkg/tests/tests.go @@ -7,10 +7,14 @@ import ( "testing" "time" + "github.com/hedhyw/semerr/pkg/v1/semerr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// ErrTest is a fake constant error to use in tests. +const ErrTest semerr.Error = "test error" + // RequireCreateFile is a helper that create a temporary file and deletes // it at the end of the test. func RequireCreateFile(tb testing.TB, content []byte) string {