From 25a6a8ed7410a08b1af7f593d86cc4491e3db8d2 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 30 Oct 2024 10:37:44 -0400 Subject: [PATCH 01/55] fix: responsiveness with qr code, remove controls --- ui/controls/controller.go | 29 ------------- ui/controls/controls_test.go | 35 --------------- ui/controls/model.go | 19 -------- ui/controls/style.go | 12 ------ ui/controls/view.go | 17 -------- ui/error.go | 8 ++-- ui/pages/transaction/controller.go | 22 ++++------ ui/pages/transaction/model.go | 22 ++++++---- ui/pages/transaction/view.go | 69 ++++++++++++++++++------------ 9 files changed, 67 insertions(+), 166 deletions(-) delete mode 100644 ui/controls/controller.go delete mode 100644 ui/controls/controls_test.go delete mode 100644 ui/controls/model.go delete mode 100644 ui/controls/style.go delete mode 100644 ui/controls/view.go diff --git a/ui/controls/controller.go b/ui/controls/controller.go deleted file mode 100644 index 006f9eb7..00000000 --- a/ui/controls/controller.go +++ /dev/null @@ -1,29 +0,0 @@ -package controls - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -// Init has no I/O right now -func (m Model) Init() tea.Cmd { - return nil -} - -// Update processes incoming messages, modifies the model state, and returns the updated model and command to execute. -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m.HandleMessage(msg) -} - -// HandleMessage processes incoming messages, updates the model's state, and returns the updated model and a command to execute. -func (m Model) HandleMessage(msg tea.Msg) (Model, tea.Cmd) { - switch msg := msg.(type) { - - case tea.WindowSizeMsg: - if msg.Width != 0 && msg.Height != 0 { - m.Width = msg.Width - m.Height = lipgloss.Height(m.View()) - } - } - return m, nil -} diff --git a/ui/controls/controls_test.go b/ui/controls/controls_test.go deleted file mode 100644 index ed1811a1..00000000 --- a/ui/controls/controls_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package controls - -import ( - "bytes" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/x/exp/teatest" - "testing" - "time" -) - -func Test_Controls(t *testing.T) { - expected := "(q)uit | (d)elete | (g)enerate | (t)xn | (h)ide" - // Create the Model - m := New(expected) - - tm := teatest.NewTestModel( - t, m, - teatest.WithInitialTermSize(80, 40), - ) - - // Wait for prompt to exit - teatest.WaitFor( - t, tm.Output(), - func(bts []byte) bool { - return bytes.Contains(bts, []byte(expected)) - }, - teatest.WithCheckInterval(time.Millisecond*100), - teatest.WithDuration(time.Second*3), - ) - - // Send quit msg - tm.Send(tea.QuitMsg{}) - - tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) -} diff --git a/ui/controls/model.go b/ui/controls/model.go deleted file mode 100644 index 92baa819..00000000 --- a/ui/controls/model.go +++ /dev/null @@ -1,19 +0,0 @@ -package controls - -// Model represents the data structure used for defining visibility, dimensions, and content. -type Model struct { - Width int - Height int - IsVisible bool - Content string -} - -// New creates a instance of a Model -func New(body string) Model { - return Model{ - IsVisible: true, - Width: 80, - Height: 24, - Content: body, - } -} diff --git a/ui/controls/style.go b/ui/controls/style.go deleted file mode 100644 index 3ad10eb2..00000000 --- a/ui/controls/style.go +++ /dev/null @@ -1,12 +0,0 @@ -package controls - -import "github.com/charmbracelet/lipgloss" - -var controlStyle = func() lipgloss.Style { - b := lipgloss.RoundedBorder() - b.Left = "┤" - b.Right = "├" - return lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderStyle(b) -}() diff --git a/ui/controls/view.go b/ui/controls/view.go deleted file mode 100644 index 3754f42c..00000000 --- a/ui/controls/view.go +++ /dev/null @@ -1,17 +0,0 @@ -package controls - -import ( - "github.com/charmbracelet/lipgloss" - "strings" -) - -// View renders the model's content if it is visible, aligning it horizontally and ensuring it fits within the specified width. -func (m Model) View() string { - if !m.IsVisible { - return "" - } - render := controlStyle.Render(m.Content) - difference := m.Width - lipgloss.Width(render) - line := strings.Repeat("─", max(0, difference/2)) - return lipgloss.JoinHorizontal(lipgloss.Center, line, render, line) -} diff --git a/ui/error.go b/ui/error.go index c0678d7d..f0742510 100644 --- a/ui/error.go +++ b/ui/error.go @@ -1,7 +1,6 @@ package ui import ( - "github.com/algorandfoundation/hack-tui/ui/controls" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -9,10 +8,9 @@ import ( ) type ErrorViewModel struct { - Height int - Width int - controls controls.Model - Message string + Height int + Width int + Message string } func NewErrorViewModel(message string) ErrorViewModel { diff --git a/ui/pages/transaction/controller.go b/ui/pages/transaction/controller.go index 5b5eaa92..6f155707 100644 --- a/ui/pages/transaction/controller.go +++ b/ui/pages/transaction/controller.go @@ -3,12 +3,13 @@ package transaction import ( "encoding/base64" "fmt" + "github.com/algorandfoundation/hack-tui/ui/style" + "github.com/charmbracelet/lipgloss" "github.com/algorand/go-algorand-sdk/v2/types" "github.com/algorandfoundation/algourl/encoder" "github.com/algorandfoundation/hack-tui/api" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) func (m ViewModel) Init() tea.Cmd { @@ -34,10 +35,10 @@ func (m *ViewModel) UpdateTxnURLAndQRCode() error { case "Not Participating": // This status means the account can never participate in consensus m.urlTxn = "" m.asciiQR = "" - m.hint = fmt.Sprintf("%s is NotParticipating. Cannot register key.", m.Data.Address) + m.hint = fmt.Sprintf("%s is NotParticipating. Cannot register key.", m.FormatedAddress()) return nil } - + m.IsOnline = isOnline fee := uint64(1000) kr := &encoder.AUrlTxn{} @@ -69,7 +70,7 @@ func (m *ViewModel) UpdateTxnURLAndQRCode() error { }, } - m.hint = fmt.Sprintf("Scan this QR code to take %s Online.", m.Data.Address) + m.hint = fmt.Sprintf("Scan this QR code to take %s Online.", m.FormatedAddress()) } else { @@ -81,7 +82,7 @@ func (m *ViewModel) UpdateTxnURLAndQRCode() error { Fee: &fee, }} - m.hint = fmt.Sprintf("Scan this QR code to take %s Offline.", m.Data.Address) + m.hint = fmt.Sprintf("Scan this QR code to take %s Offline.", m.FormatedAddress()) } qrCode, err := kr.ProduceQRCode() @@ -100,7 +101,6 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { // When the participation key updates, set the models data - case *api.ParticipationKey: m.Data = *msg @@ -111,13 +111,9 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { // Handle View Size changes case tea.WindowSizeMsg: - if msg.Width != 0 && msg.Height != 0 { - m.Width = msg.Width - m.Height = max(0, msg.Height-lipgloss.Height(m.controls.View())-3) - } + borderRender := style.Border.Render("") + m.Width = max(0, msg.Width-lipgloss.Width(borderRender)) + m.Height = max(0, msg.Height-lipgloss.Height(borderRender)) } - - // Pass messages to controls - m.controls, cmd = m.controls.HandleMessage(msg) return m, cmd } diff --git a/ui/pages/transaction/model.go b/ui/pages/transaction/model.go index aed10168..be00c1f2 100644 --- a/ui/pages/transaction/model.go +++ b/ui/pages/transaction/model.go @@ -1,14 +1,12 @@ package transaction import ( + "fmt" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" - "github.com/algorandfoundation/hack-tui/ui/controls" - "github.com/charmbracelet/lipgloss" + "github.com/algorandfoundation/hack-tui/ui/style" ) -var green = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) - type ViewModel struct { // Width is the last known horizontal lines Width int @@ -19,10 +17,12 @@ type ViewModel struct { Data api.ParticipationKey // Pointer to the State - State *internal.StateModel + State *internal.StateModel + IsOnline bool // Components - controls controls.Model + controls string + navigation string // QR Code, URL and hint text asciiQR string @@ -30,10 +30,16 @@ type ViewModel struct { hint string } +func (m ViewModel) FormatedAddress() string { + return fmt.Sprintf("%s...%s", m.Data.Address[0:4], m.Data.Address[len(m.Data.Address)-4:]) +} + // New creates and instance of the ViewModel with a default controls.Model func New(state *internal.StateModel) ViewModel { return ViewModel{ - State: state, - controls: controls.New(" (a)ccounts | (k)eys | " + green.Render("(t)xn") + " | shift+tab: back "), + State: state, + IsOnline: false, + navigation: "| (a)ccounts | (k)eys | " + style.Green.Render("(t)xn") + " |", + controls: "( shift+tab: back )", } } diff --git a/ui/pages/transaction/view.go b/ui/pages/transaction/view.go index 795841c1..4d972c2c 100644 --- a/ui/pages/transaction/view.go +++ b/ui/pages/transaction/view.go @@ -7,43 +7,56 @@ import ( ) func (m ViewModel) View() string { - qrRender := lipgloss.JoinVertical( - lipgloss.Center, - style.Yellow.Render(m.hint), - "", - qrStyle.Render(m.asciiQR), - urlStyle.Render(m.urlTxn), - ) + qrCode := qrStyle.Render(m.asciiQR) + qrWidth := lipgloss.Width(qrCode) + 1 + qrHeight := lipgloss.Height(qrCode) + title := "" + if m.IsOnline { + title = "Offline Transaction" + } else { + title = "Online Transaction" + } - if m.asciiQR == "" || m.urlTxn == "" { - return lipgloss.JoinVertical( - lipgloss.Center, - "No QR Code or TxnURL available", - "\n", - m.controls.View()) + url := "" + if lipgloss.Width(m.urlTxn) > qrWidth { + url = m.urlTxn[:(qrWidth-3)] + "..." + } else { + url = m.urlTxn } - if lipgloss.Height(qrRender) > m.Height { - padHeight := max(0, m.Height-lipgloss.Height(m.controls.View())-1) - padHString := strings.Repeat("\n", padHeight/2) + var render string + if qrWidth > m.Width || qrHeight+2 > m.Height { text := style.Red.Render("QR Code too large to display... Please adjust terminal dimensions or font.") + padHeight := max(0, m.Height-lipgloss.Height(text)) + padHString := strings.Repeat("\n", padHeight/2) padWidth := max(0, m.Width-lipgloss.Width(text)) padWString := strings.Repeat(" ", padWidth/2) - return lipgloss.JoinVertical( + paddedStr := lipgloss.JoinVertical( lipgloss.Left, padHString, lipgloss.JoinHorizontal(lipgloss.Left, padWString, style.Red.Render("QR Code too large to display... Please adjust terminal dimensions or font.")), - padHString, - m.controls.View()) - } + ) + render = style.ApplyBorder(m.Width, m.Height, "8").Render(paddedStr) + } else { + qRemainingWidth := max(0, (m.Width-lipgloss.Width(qrCode))/2) + qrCode = lipgloss.JoinHorizontal(lipgloss.Left, strings.Repeat(" ", qRemainingWidth), qrCode, strings.Repeat(" ", qRemainingWidth)) + qRemainingHeight := max(0, (m.Height-2-lipgloss.Height(qrCode))/2) + if qrHeight+2 == m.Height { + qrCode = lipgloss.JoinVertical(lipgloss.Center, style.Yellow.Render(m.hint), qrCode, urlStyle.Render(url)) + } else { + qrCode = lipgloss.JoinVertical(lipgloss.Center, strings.Repeat("\n", qRemainingHeight), style.Yellow.Render(m.hint), qrCode, urlStyle.Render(url)) - qrRenderPadHeight := max(0, m.Height-(lipgloss.Height(qrRender)-lipgloss.Height(m.controls.View()))-1) - qrPad := strings.Repeat("\n", qrRenderPadHeight/2) - return lipgloss.JoinVertical( - lipgloss.Center, - qrPad, - qrRender, - qrPad, - m.controls.View(), + } + render = style.ApplyBorder(m.Width, m.Height, "8").Render(qrCode) + } + return style.WithNavigation( + m.navigation, + style.WithControls( + m.controls, + style.WithTitle( + title, + render, + ), + ), ) } From da64fee41cebd8ae356e67e7d76c01cd82bd0899 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 5 Nov 2024 12:53:10 -0500 Subject: [PATCH 02/55] refactor: use arrow keys and remove hotkeys --- ui/pages/accounts/model.go | 2 +- ui/pages/keys/model.go | 4 ++-- ui/pages/transaction/model.go | 4 ++-- ui/viewport.go | 36 ++--------------------------------- 4 files changed, 7 insertions(+), 39 deletions(-) diff --git a/ui/pages/accounts/model.go b/ui/pages/accounts/model.go index 425adf40..2784f9ac 100644 --- a/ui/pages/accounts/model.go +++ b/ui/pages/accounts/model.go @@ -26,7 +26,7 @@ func New(state *internal.StateModel) ViewModel { Height: 0, Data: state.Accounts, controls: "( (g)enerate )", - navigation: "| " + style.Green.Render("(a)ccounts") + " | (k)eys | (t)xn |", + navigation: "| " + style.Green.Render("accounts") + " | keys | txn |", } m.table = table.New( diff --git a/ui/pages/keys/model.go b/ui/pages/keys/model.go index e3879de5..d974c991 100644 --- a/ui/pages/keys/model.go +++ b/ui/pages/keys/model.go @@ -31,8 +31,8 @@ func New(address string, keys *[]api.ParticipationKey) ViewModel { Width: 80, Height: 24, - controls: "( (g)enerate | (d)elete )", - navigation: "| (a)ccounts | " + style.Green.Render("(k)eys") + " | (t)xn |", + controls: "( (g)enerate )", + navigation: "| accounts | " + style.Green.Render("keys") + " | txn |", table: table.New(), } diff --git a/ui/pages/transaction/model.go b/ui/pages/transaction/model.go index be00c1f2..ac4b2b57 100644 --- a/ui/pages/transaction/model.go +++ b/ui/pages/transaction/model.go @@ -39,7 +39,7 @@ func New(state *internal.StateModel) ViewModel { return ViewModel{ State: state, IsOnline: false, - navigation: "| (a)ccounts | (k)eys | " + style.Green.Render("(t)xn") + " |", - controls: "( shift+tab: back )", + navigation: "| accounts | keys | " + style.Green.Render("txn") + " |", + controls: "( <- back )", } } diff --git a/ui/viewport.go b/ui/viewport.go index 660c73cf..f602d67a 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -100,7 +100,7 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { // Tab Backwards - case "shift+tab": + case "left": if m.page == AccountsPage { return m, nil } @@ -112,7 +112,7 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } // Tab Forwards - case "tab": + case "right": if m.page == AccountsPage { selAcc := m.accountsPage.SelectedAccount() if selAcc != (internal.Account{}) { @@ -129,38 +129,6 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } return m, nil - case "a": - m.page = AccountsPage - case "g": - m.generatePage.Inputs[0].SetValue(m.accountsPage.SelectedAccount().Address) - m.page = GeneratePage - return m, nil - case "k": - selAcc := m.accountsPage.SelectedAccount() - if selAcc != (internal.Account{}) { - m.page = KeysPage - return m, accounts.EmitAccountSelected(selAcc) - } - return m, nil - case "t": - if m.page == AccountsPage { - acct := m.accountsPage.SelectedAccount() - data := *m.Data.ParticipationKeys - for i, key := range data { - if key.Address == acct.Address { - m.page = TransactionPage - return m, keys.EmitKeySelected(&data[i]) - } - } - } - if m.page == KeysPage { - selKey := m.keysPage.SelectedKey() - if selKey != nil { - m.page = TransactionPage - return m, keys.EmitKeySelected(selKey) - } - } - return m, nil case "ctrl+c": if m.page != GeneratePage { return m, tea.Quit From 34a6dde031199c2844e17e3c890ac3d9e56b3725 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 6 Nov 2024 15:19:55 -0500 Subject: [PATCH 03/55] refactor: move transaction and generate page --- ui/{pages => modals}/generate/cmds.go | 0 ui/{pages => modals}/generate/controller.go | 0 ui/{pages => modals}/generate/model.go | 0 ui/{pages => modals}/generate/style.go | 0 ui/{pages => modals}/generate/view.go | 0 ui/{pages => modals}/transaction/controller.go | 0 ui/{pages => modals}/transaction/model.go | 0 ui/{pages => modals}/transaction/style.go | 0 ui/{pages => modals}/transaction/view.go | 0 ui/viewport.go | 4 ++-- 10 files changed, 2 insertions(+), 2 deletions(-) rename ui/{pages => modals}/generate/cmds.go (100%) rename ui/{pages => modals}/generate/controller.go (100%) rename ui/{pages => modals}/generate/model.go (100%) rename ui/{pages => modals}/generate/style.go (100%) rename ui/{pages => modals}/generate/view.go (100%) rename ui/{pages => modals}/transaction/controller.go (100%) rename ui/{pages => modals}/transaction/model.go (100%) rename ui/{pages => modals}/transaction/style.go (100%) rename ui/{pages => modals}/transaction/view.go (100%) diff --git a/ui/pages/generate/cmds.go b/ui/modals/generate/cmds.go similarity index 100% rename from ui/pages/generate/cmds.go rename to ui/modals/generate/cmds.go diff --git a/ui/pages/generate/controller.go b/ui/modals/generate/controller.go similarity index 100% rename from ui/pages/generate/controller.go rename to ui/modals/generate/controller.go diff --git a/ui/pages/generate/model.go b/ui/modals/generate/model.go similarity index 100% rename from ui/pages/generate/model.go rename to ui/modals/generate/model.go diff --git a/ui/pages/generate/style.go b/ui/modals/generate/style.go similarity index 100% rename from ui/pages/generate/style.go rename to ui/modals/generate/style.go diff --git a/ui/pages/generate/view.go b/ui/modals/generate/view.go similarity index 100% rename from ui/pages/generate/view.go rename to ui/modals/generate/view.go diff --git a/ui/pages/transaction/controller.go b/ui/modals/transaction/controller.go similarity index 100% rename from ui/pages/transaction/controller.go rename to ui/modals/transaction/controller.go diff --git a/ui/pages/transaction/model.go b/ui/modals/transaction/model.go similarity index 100% rename from ui/pages/transaction/model.go rename to ui/modals/transaction/model.go diff --git a/ui/pages/transaction/style.go b/ui/modals/transaction/style.go similarity index 100% rename from ui/pages/transaction/style.go rename to ui/modals/transaction/style.go diff --git a/ui/pages/transaction/view.go b/ui/modals/transaction/view.go similarity index 100% rename from ui/pages/transaction/view.go rename to ui/modals/transaction/view.go diff --git a/ui/viewport.go b/ui/viewport.go index 97d1c0eb..7244cd51 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -3,13 +3,13 @@ package ui import ( "context" "fmt" + "github.com/algorandfoundation/hack-tui/ui/modals/generate" + "github.com/algorandfoundation/hack-tui/ui/modals/transaction" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" "github.com/algorandfoundation/hack-tui/ui/pages/accounts" - "github.com/algorandfoundation/hack-tui/ui/pages/generate" "github.com/algorandfoundation/hack-tui/ui/pages/keys" - "github.com/algorandfoundation/hack-tui/ui/pages/transaction" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) From d78a619533df12183a4ea7e349f435c415067668 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 6 Nov 2024 15:23:35 -0500 Subject: [PATCH 04/55] refactor: overlays with modal --- cmd/root.go | 13 +- go.mod | 2 +- internal/state.go | 15 ++- internal/state_test.go | 2 + ui/modals/confirm/confirm.go | 81 +++++++++++++ ui/modals/info/info.go | 86 ++++++++++++++ ui/modals/modal.go | 177 ++++++++++++++++++++++++++++ ui/modals/transaction/controller.go | 149 +++++++++-------------- ui/modals/transaction/model.go | 35 +++--- ui/modals/transaction/style.go | 2 - ui/modals/transaction/view.go | 66 +++-------- ui/overlay/overlay.go | 62 ++++++++++ ui/pages/accounts/model.go | 2 +- ui/pages/accounts/view.go | 2 +- ui/pages/keys/cmds.go | 23 ---- ui/pages/keys/controller.go | 34 +----- ui/pages/keys/model.go | 30 +---- ui/pages/keys/view.go | 24 +--- ui/style/style.go | 96 +++++++++++++-- ui/viewport.go | 129 ++++++++------------ 20 files changed, 667 insertions(+), 363 deletions(-) create mode 100644 ui/modals/confirm/confirm.go create mode 100644 ui/modals/info/info.go create mode 100644 ui/modals/modal.go create mode 100644 ui/overlay/overlay.go delete mode 100644 ui/pages/keys/cmds.go diff --git a/cmd/root.go b/cmd/root.go index f50bc7a0..50cdcc4a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -52,7 +52,8 @@ var ( client, err := getClient() cobra.CheckErr(err) - partkeys, err := internal.GetPartKeys(context.Background(), client) + ctx := context.Background() + partkeys, err := internal.GetPartKeys(ctx, client) if err != nil { return fmt.Errorf( style.Red.Render("failed to get participation keys: %s"), @@ -75,11 +76,14 @@ var ( TX: 0, }, ParticipationKeys: partkeys, + + Client: client, + Context: ctx, } state.Accounts = internal.AccountsFromState(&state, new(internal.Clock), client) // Fetch current state - err = state.Status.Fetch(context.Background(), client) + err = state.Status.Fetch(ctx, client) cobra.CheckErr(err) m, err := ui.MakeViewportViewModel(&state, client) @@ -99,12 +103,9 @@ var ( p.Send(state) p.Send(err) } - }, context.Background(), client) + }, ctx, client) }() _, err = p.Run() - //for { - // time.Sleep(10 * time.Second) - //} return err }, } diff --git a/go.mod b/go.mod index ebabe667..e09ebc3c 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.2.0 // indirect - github.com/charmbracelet/x/ansi v0.3.2 // indirect + github.com/charmbracelet/x/ansi v0.3.2 github.com/charmbracelet/x/exp/golden v0.0.0-20241022174419-46d9bb99a691 // indirect github.com/charmbracelet/x/term v0.2.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect diff --git a/internal/state.go b/internal/state.go index 7b3ccd8b..62a0dc94 100644 --- a/internal/state.go +++ b/internal/state.go @@ -16,6 +16,9 @@ type StateModel struct { // TODO: handle contexts instead of adding it to state Admin bool Watching bool + + Client *api.ClientWithResponses + Context context.Context } func (s *StateModel) waitAfterError(err error, cb func(model *StateModel, err error)) { @@ -60,7 +63,7 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co s.Status.Update(status.JSON200.LastRound, status.JSON200.CatchupTime, status.JSON200.UpgradeNodeVote) // Fetch Keys - s.UpdateKeys(ctx, client) + s.UpdateKeys() if s.Status.State == "SYNCING" { lastRound = s.Status.LastRound @@ -107,18 +110,18 @@ func (s *StateModel) UpdateMetricsFromRPC(ctx context.Context, client *api.Clien s.Metrics.LastRX = res["algod_network_received_bytes_total"] } } -func (s *StateModel) UpdateAccounts(client *api.ClientWithResponses) { - s.Accounts = AccountsFromState(s, new(Clock), client) +func (s *StateModel) UpdateAccounts() { + s.Accounts = AccountsFromState(s, new(Clock), s.Client) } -func (s *StateModel) UpdateKeys(ctx context.Context, client *api.ClientWithResponses) { +func (s *StateModel) UpdateKeys() { var err error - s.ParticipationKeys, err = GetPartKeys(ctx, client) + s.ParticipationKeys, err = GetPartKeys(s.Context, s.Client) if err != nil { s.Admin = false } if err == nil { s.Admin = true - s.UpdateAccounts(client) + s.UpdateAccounts() } } diff --git a/internal/state_test.go b/internal/state_test.go index 769c6639..c5a7e666 100644 --- a/internal/state_test.go +++ b/internal/state_test.go @@ -29,6 +29,8 @@ func Test_StateModel(t *testing.T) { RX: 0, TPS: 0, }, + Client: client, + Context: context.Background(), } count := 0 go state.Watch(func(model *StateModel, err error) { diff --git a/ui/modals/confirm/confirm.go b/ui/modals/confirm/confirm.go new file mode 100644 index 00000000..842671e9 --- /dev/null +++ b/ui/modals/confirm/confirm.go @@ -0,0 +1,81 @@ +package confirm + +import ( + "fmt" + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/style" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Msg *api.ParticipationKey + +func EmitMsg(key *api.ParticipationKey) tea.Cmd { + return func() tea.Msg { + return Msg(key) + } +} + +type ViewModel struct { + Width int + Height int + Title string + Controls string + BorderColor string + ActiveKey *api.ParticipationKey + Data *internal.StateModel +} + +func New(state *internal.StateModel) *ViewModel { + return &ViewModel{ + Width: 0, + Height: 0, + Title: "Delete Key", + BorderColor: "9", + Controls: "( " + style.Green.Render("(y)es") + " | " + style.Red.Render("(n)o") + " )", + Data: state, + } +} + +func (m ViewModel) Init() tea.Cmd { + return nil +} + +func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m.HandleMessage(msg) +} +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "y": + return &m, EmitMsg(m.ActiveKey) + case "n": + return &m, EmitMsg(nil) + } + case tea.WindowSizeMsg: + m.Width = msg.Width + m.Height = msg.Height + } + return &m, nil +} +func (m ViewModel) View() string { + if m.ActiveKey == nil { + return "No key selected" + } + return renderDeleteConfirmationModal(m.ActiveKey) + +} + +func renderDeleteConfirmationModal(partKey *api.ParticipationKey) string { + modalStyle := lipgloss.NewStyle(). + Width(60). + Height(7). + Align(lipgloss.Center). + Padding(1, 2) + + modalContent := fmt.Sprintf("Participation Key: %v\nAccount Address: %v", partKey.Id, partKey.Address) + + return modalStyle.Render("Are you sure you want to delete this key from your node?\n" + modalContent) +} diff --git a/ui/modals/info/info.go b/ui/modals/info/info.go new file mode 100644 index 00000000..e1a15e7b --- /dev/null +++ b/ui/modals/info/info.go @@ -0,0 +1,86 @@ +package info + +import ( + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/style" + "github.com/algorandfoundation/hack-tui/ui/utils" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +type ViewModel struct { + Width int + Height int + Title string + Controls string + BorderColor string + ActiveKey *api.ParticipationKey + Data *internal.StateModel +} + +func New(state *internal.StateModel) *ViewModel { + return &ViewModel{ + Width: 0, + Height: 0, + Title: "Key Information", + BorderColor: "3", + Controls: "( " + style.Red.Render("(d)elete") + " | " + style.Green.Render("(o)nline") + " )", + Data: state, + } +} + +func (m ViewModel) Init() tea.Cmd { + return nil +} + +func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m.HandleMessage(msg) +} +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.Width = msg.Width + m.Height = msg.Height + } + m.UpdateState() + return &m, nil +} +func (m *ViewModel) UpdateState() { + if m.ActiveKey == nil { + return + } + accountStatus := m.Data.Accounts[m.ActiveKey.Address].Status + + if accountStatus == "Online" { + m.Controls = "( " + style.Red.Render("(d)elete") + " | " + style.Yellow.Render("(o)ffline") + " )" + } else { + m.Controls = "( " + style.Red.Render("(d)elete") + " | " + style.Green.Render("(o)nline") + " )" + } +} +func (m ViewModel) View() string { + if m.ActiveKey == nil { + return "No key selected" + } + + id := style.Cyan.Render("Participation ID: ") + m.ActiveKey.Id + selection := style.Yellow.Render("Selection Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.ActiveKey.Key.SelectionParticipationKey[:]) + vote := style.Yellow.Render("Vote Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.ActiveKey.Key.VoteParticipationKey[:]) + stateProof := style.Yellow.Render("State Proof Key: ") + *utils.UrlEncodeBytesPtrOrNil(*m.ActiveKey.Key.StateProofKey) + voteFirstValid := style.Purple("Vote First Valid: ") + utils.IntToStr(m.ActiveKey.Key.VoteFirstValid) + voteLastValid := style.Purple("Vote Last Valid: ") + utils.IntToStr(m.ActiveKey.Key.VoteLastValid) + voteKeyDilution := style.Purple("Vote Key Dilution: ") + utils.IntToStr(m.ActiveKey.Key.VoteKeyDilution) + + return ansi.Hardwrap(lipgloss.JoinVertical(lipgloss.Left, + id, + selection, + vote, + stateProof, + voteFirstValid, + voteLastValid, + voteKeyDilution, + ), m.Width, true) + +} diff --git a/ui/modals/modal.go b/ui/modals/modal.go new file mode 100644 index 00000000..dba42d1a --- /dev/null +++ b/ui/modals/modal.go @@ -0,0 +1,177 @@ +package modal + +import ( + "context" + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/modals/confirm" + "github.com/algorandfoundation/hack-tui/ui/modals/info" + "github.com/algorandfoundation/hack-tui/ui/modals/transaction" + "github.com/algorandfoundation/hack-tui/ui/style" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func DeleteKeyCmd(ctx context.Context, client *api.ClientWithResponses, id string) tea.Cmd { + return func() tea.Msg { + err := internal.DeletePartKey(ctx, client, id) + if err != nil { + return DeleteFinished(err.Error()) + } + return DeleteFinished(id) + } +} + +type DeleteFinished string + +type DeleteKey *api.ParticipationKey + +type Page string + +const ( + InfoModal Page = "accounts" + ConfirmModal Page = "confirm" + TransactionModal Page = "transaction" +) + +type ViewModel struct { + // Width and Height + Width int + Height int + + // State for Context/Client + State *internal.StateModel + + // Views + infoModal *info.ViewModel + transactionModal *transaction.ViewModel + confirmModal *confirm.ViewModel + + // Current Component Data + title string + controls string + borderColor string + Page Page +} + +func New(state *internal.StateModel) *ViewModel { + return &ViewModel{ + Width: 0, + Height: 0, + + State: state, + + infoModal: info.New(state), + transactionModal: transaction.New(state), + confirmModal: confirm.New(state), + + Page: InfoModal, + controls: "", + borderColor: "3", + } +} +func (m ViewModel) SetKey(key *api.ParticipationKey) { + m.infoModal.ActiveKey = key + m.confirmModal.ActiveKey = key + m.transactionModal.ActiveKey = key +} +func (m ViewModel) Init() tea.Cmd { + return nil +} +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + switch msg := msg.(type) { + case DeleteFinished: + m.Page = InfoModal + case confirm.Msg: + if msg != nil { + return &m, DeleteKeyCmd(m.State.Context, m.State.Client, msg.Id) + } else { + m.Page = InfoModal + } + case tea.KeyMsg: + switch msg.String() { + case "esc": + m.Page = InfoModal + case "d": + if m.Page == InfoModal { + m.Page = ConfirmModal + } + case "o": + if m.Page == InfoModal { + m.Page = TransactionModal + m.transactionModal.UpdateState() + } + case "enter": + if m.Page == InfoModal { + m.Page = TransactionModal + } + if m.Page == TransactionModal { + m.Page = InfoModal + } + } + // Handle View Size changes + case tea.WindowSizeMsg: + m.Width = msg.Width + m.Height = msg.Height + + b := style.Border.Render("") + // Custom size message + modalMsg := tea.WindowSizeMsg{ + Width: m.Width - lipgloss.Width(b), + Height: m.Height - lipgloss.Height(b), + } + + // Handle the page resize event + m.infoModal, cmd = m.infoModal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) + m.transactionModal, cmd = m.transactionModal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) + m.confirmModal, cmd = m.confirmModal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) + return &m, tea.Batch(cmds...) + } + + // Only trigger modal commands when they are active + switch m.Page { + case InfoModal: + m.infoModal, cmd = m.infoModal.HandleMessage(msg) + m.title = m.infoModal.Title + m.controls = m.infoModal.Controls + m.borderColor = m.infoModal.BorderColor + case TransactionModal: + m.transactionModal, cmd = m.transactionModal.HandleMessage(msg) + m.title = m.transactionModal.Title + m.controls = m.transactionModal.Controls + m.borderColor = m.transactionModal.BorderColor + case ConfirmModal: + m.confirmModal, cmd = m.confirmModal.HandleMessage(msg) + m.title = m.confirmModal.Title + m.controls = m.confirmModal.Controls + m.borderColor = m.confirmModal.BorderColor + } + cmds = append(cmds, cmd) + return &m, tea.Batch(cmds...) +} +func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m.HandleMessage(msg) +} + +func (m ViewModel) View() string { + var render = "" + switch m.Page { + case InfoModal: + render = m.infoModal.View() + case TransactionModal: + render = m.transactionModal.View() + case ConfirmModal: + render = m.confirmModal.View() + } + width := lipgloss.Width(render) + 2 + height := lipgloss.Height(render) + return style.WithNavigation(m.controls, style.WithTitle(m.title, style.ApplyBorder(width, height, m.borderColor).PaddingRight(1).PaddingLeft(1).Render(render))) +} diff --git a/ui/modals/transaction/controller.go b/ui/modals/transaction/controller.go index 6f155707..7ab69e77 100644 --- a/ui/modals/transaction/controller.go +++ b/ui/modals/transaction/controller.go @@ -2,16 +2,18 @@ package transaction import ( "encoding/base64" - "fmt" - "github.com/algorandfoundation/hack-tui/ui/style" - "github.com/charmbracelet/lipgloss" - "github.com/algorand/go-algorand-sdk/v2/types" "github.com/algorandfoundation/algourl/encoder" - "github.com/algorandfoundation/hack-tui/api" tea "github.com/charmbracelet/bubbletea" ) +type Title string + +const ( + OnlineTitle Title = "Online Transaction" + OfflineTitle Title = "Offline Transaction" +) + func (m ViewModel) Init() tea.Cmd { return nil } @@ -20,100 +22,57 @@ func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.HandleMessage(msg) } -func (m *ViewModel) UpdateTxnURLAndQRCode() error { - - accountStatus := m.State.Accounts[m.Data.Address].Status - - m.hint = "" - - var isOnline bool - switch accountStatus { - case "Online": - isOnline = true - case "Offline": - isOnline = false - case "Not Participating": // This status means the account can never participate in consensus - m.urlTxn = "" - m.asciiQR = "" - m.hint = fmt.Sprintf("%s is NotParticipating. Cannot register key.", m.FormatedAddress()) - return nil - } - m.IsOnline = isOnline - fee := uint64(1000) - - kr := &encoder.AUrlTxn{} - - if !isOnline { - - // TX take account online - - votePartKey := base64.RawURLEncoding.EncodeToString(m.Data.Key.VoteParticipationKey) - selPartKey := base64.RawURLEncoding.EncodeToString(m.Data.Key.SelectionParticipationKey) - spKey := base64.RawURLEncoding.EncodeToString(*m.Data.Key.StateProofKey) - firstValid := uint64(m.Data.Key.VoteFirstValid) - lastValid := uint64(m.Data.Key.VoteLastValid) - vkDilution := uint64(m.Data.Key.VoteKeyDilution) - - kr = &encoder.AUrlTxn{ - AUrlTxnKeyCommon: encoder.AUrlTxnKeyCommon{ - Sender: m.Data.Address, - Type: string(types.KeyRegistrationTx), - Fee: &fee, - }, - AUrlTxnKeyreg: encoder.AUrlTxnKeyreg{ - VotePK: &votePartKey, - SelectionPK: &selPartKey, - StateProofPK: &spKey, - VoteFirst: &firstValid, - VoteLast: &lastValid, - VoteKeyDilution: &vkDilution, - }, - } - - m.hint = fmt.Sprintf("Scan this QR code to take %s Online.", m.FormatedAddress()) - - } else { - - // TX to take account offline - kr = &encoder.AUrlTxn{ - AUrlTxnKeyCommon: encoder.AUrlTxnKeyCommon{ - Sender: m.Data.Address, - Type: string(types.KeyRegistrationTx), - Fee: &fee, - }} - - m.hint = fmt.Sprintf("Scan this QR code to take %s Offline.", m.FormatedAddress()) - } - - qrCode, err := kr.ProduceQRCode() - - if err != nil { - return err - } - - m.urlTxn = kr.String() - m.asciiQR = qrCode - return nil -} - // HandleMessage is called by the viewport to update its Model -func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { - // When the participation key updates, set the models data - case *api.ParticipationKey: - m.Data = *msg - - err := m.UpdateTxnURLAndQRCode() - if err != nil { - panic(err) - } - // Handle View Size changes case tea.WindowSizeMsg: - borderRender := style.Border.Render("") - m.Width = max(0, msg.Width-lipgloss.Width(borderRender)) - m.Height = max(0, msg.Height-lipgloss.Height(borderRender)) + m.Width = msg.Width + m.Height = msg.Height + } + m.UpdateState() + return &m, cmd +} + +func (m *ViewModel) UpdateState() { + if m.ActiveKey == nil { + return + } + accountStatus := m.State.Accounts[m.ActiveKey.Address].Status + + if m.ATxn == nil { + m.ATxn = &encoder.AUrlTxn{} + } + fee := uint64(1000) + m.ATxn.AUrlTxnKeyCommon.Sender = m.ActiveKey.Address + m.ATxn.AUrlTxnKeyCommon.Type = string(types.KeyRegistrationTx) + m.ATxn.AUrlTxnKeyCommon.Fee = &fee + + if accountStatus != "Online" { + m.Title = string(OnlineTitle) + m.BorderColor = "2" + votePartKey := base64.RawURLEncoding.EncodeToString(m.ActiveKey.Key.VoteParticipationKey) + selPartKey := base64.RawURLEncoding.EncodeToString(m.ActiveKey.Key.SelectionParticipationKey) + spKey := base64.RawURLEncoding.EncodeToString(*m.ActiveKey.Key.StateProofKey) + firstValid := uint64(m.ActiveKey.Key.VoteFirstValid) + lastValid := uint64(m.ActiveKey.Key.VoteLastValid) + vkDilution := uint64(m.ActiveKey.Key.VoteKeyDilution) + + m.ATxn.AUrlTxnKeyreg.VotePK = &votePartKey + m.ATxn.AUrlTxnKeyreg.SelectionPK = &selPartKey + m.ATxn.AUrlTxnKeyreg.StateProofPK = &spKey + m.ATxn.AUrlTxnKeyreg.VoteFirst = &firstValid + m.ATxn.AUrlTxnKeyreg.VoteLast = &lastValid + m.ATxn.AUrlTxnKeyreg.VoteKeyDilution = &vkDilution + } else { + m.Title = string(OfflineTitle) + m.BorderColor = "9" + m.ATxn.AUrlTxnKeyreg.VotePK = nil + m.ATxn.AUrlTxnKeyreg.SelectionPK = nil + m.ATxn.AUrlTxnKeyreg.StateProofPK = nil + m.ATxn.AUrlTxnKeyreg.VoteFirst = nil + m.ATxn.AUrlTxnKeyreg.VoteLast = nil + m.ATxn.AUrlTxnKeyreg.VoteKeyDilution = nil } - return m, cmd } diff --git a/ui/modals/transaction/model.go b/ui/modals/transaction/model.go index ac4b2b57..cd1cbe6c 100644 --- a/ui/modals/transaction/model.go +++ b/ui/modals/transaction/model.go @@ -2,6 +2,7 @@ package transaction import ( "fmt" + "github.com/algorandfoundation/algourl/encoder" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" "github.com/algorandfoundation/hack-tui/ui/style" @@ -13,33 +14,37 @@ type ViewModel struct { // Height is the last known vertical lines Height int - // Participation Key - Data api.ParticipationKey + Title string + + // Active Participation Key + ActiveKey *api.ParticipationKey // Pointer to the State State *internal.StateModel IsOnline bool // Components - controls string - navigation string + BorderColor string + Controls string + navigation string - // QR Code, URL and hint text - asciiQR string - urlTxn string - hint string + // QR Code + ATxn *encoder.AUrlTxn } func (m ViewModel) FormatedAddress() string { - return fmt.Sprintf("%s...%s", m.Data.Address[0:4], m.Data.Address[len(m.Data.Address)-4:]) + return fmt.Sprintf("%s...%s", m.ActiveKey.Address[0:4], m.ActiveKey.Address[len(m.ActiveKey.Address)-4:]) } // New creates and instance of the ViewModel with a default controls.Model -func New(state *internal.StateModel) ViewModel { - return ViewModel{ - State: state, - IsOnline: false, - navigation: "| accounts | keys | " + style.Green.Render("txn") + " |", - controls: "( <- back )", +func New(state *internal.StateModel) *ViewModel { + return &ViewModel{ + State: state, + Title: "Offline Transaction", + IsOnline: false, + BorderColor: "9", + navigation: "| accounts | keys | " + style.Green.Render("txn") + " |", + Controls: "( " + style.Red.Render("esc") + " )", + ATxn: nil, } } diff --git a/ui/modals/transaction/style.go b/ui/modals/transaction/style.go index 4e4151d1..08f91ff6 100644 --- a/ui/modals/transaction/style.go +++ b/ui/modals/transaction/style.go @@ -5,5 +5,3 @@ import "github.com/charmbracelet/lipgloss" var qrStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("15")). Background(lipgloss.Color("0")) - -var urlStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#2596be")) diff --git a/ui/modals/transaction/view.go b/ui/modals/transaction/view.go index 4d972c2c..f7dbc0d6 100644 --- a/ui/modals/transaction/view.go +++ b/ui/modals/transaction/view.go @@ -3,60 +3,30 @@ package transaction import ( "github.com/algorandfoundation/hack-tui/ui/style" "github.com/charmbracelet/lipgloss" - "strings" + "github.com/charmbracelet/x/ansi" ) func (m ViewModel) View() string { - qrCode := qrStyle.Render(m.asciiQR) - qrWidth := lipgloss.Width(qrCode) + 1 - qrHeight := lipgloss.Height(qrCode) - title := "" - if m.IsOnline { - title = "Offline Transaction" - } else { - title = "Online Transaction" + if m.ActiveKey == nil { + return "No key selected" } - - url := "" - if lipgloss.Width(m.urlTxn) > qrWidth { - url = m.urlTxn[:(qrWidth-3)] + "..." - } else { - url = m.urlTxn + if m.ATxn == nil { + return "Loading..." + } + txn, err := m.ATxn.ProduceQRCode() + if err != nil { + return "Something went wrong" } - var render string - if qrWidth > m.Width || qrHeight+2 > m.Height { - text := style.Red.Render("QR Code too large to display... Please adjust terminal dimensions or font.") - padHeight := max(0, m.Height-lipgloss.Height(text)) - padHString := strings.Repeat("\n", padHeight/2) - padWidth := max(0, m.Width-lipgloss.Width(text)) - padWString := strings.Repeat(" ", padWidth/2) - paddedStr := lipgloss.JoinVertical( - lipgloss.Left, - padHString, - lipgloss.JoinHorizontal(lipgloss.Left, padWString, style.Red.Render("QR Code too large to display... Please adjust terminal dimensions or font.")), - ) - render = style.ApplyBorder(m.Width, m.Height, "8").Render(paddedStr) - } else { - qRemainingWidth := max(0, (m.Width-lipgloss.Width(qrCode))/2) - qrCode = lipgloss.JoinHorizontal(lipgloss.Left, strings.Repeat(" ", qRemainingWidth), qrCode, strings.Repeat(" ", qRemainingWidth)) - qRemainingHeight := max(0, (m.Height-2-lipgloss.Height(qrCode))/2) - if qrHeight+2 == m.Height { - qrCode = lipgloss.JoinVertical(lipgloss.Center, style.Yellow.Render(m.hint), qrCode, urlStyle.Render(url)) - } else { - qrCode = lipgloss.JoinVertical(lipgloss.Center, strings.Repeat("\n", qRemainingHeight), style.Yellow.Render(m.hint), qrCode, urlStyle.Render(url)) + render := qrStyle.Render(txn) + + width := lipgloss.Width(render) + height := lipgloss.Height(render) + + if width > m.Width || height > m.Height { + return style.Red.Render(ansi.Wordwrap("QR Code too large to display... Please adjust terminal dimensions or font.", m.Width, " ")) - } - render = style.ApplyBorder(m.Width, m.Height, "8").Render(qrCode) } - return style.WithNavigation( - m.navigation, - style.WithControls( - m.controls, - style.WithTitle( - title, - render, - ), - ), - ) + + return render } diff --git a/ui/overlay/overlay.go b/ui/overlay/overlay.go new file mode 100644 index 00000000..0c4d983a --- /dev/null +++ b/ui/overlay/overlay.go @@ -0,0 +1,62 @@ +package overlay + +import ( + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/ui/modals" + "github.com/algorandfoundation/hack-tui/ui/style" + tea "github.com/charmbracelet/bubbletea" +) + +type ShowModal *api.ParticipationKey + +func EmitShowModal(key *api.ParticipationKey) tea.Cmd { + return func() tea.Msg { + return ShowModal(key) + } +} + +type ViewModel struct { + Parent string + Open bool + modal *modal.ViewModel +} + +func (m ViewModel) SetKey(key *api.ParticipationKey) { + m.modal.SetKey(key) +} +func New(parent string, open bool, modal *modal.ViewModel) ViewModel { + return ViewModel{ + Parent: parent, + Open: open, + modal: modal, + } +} + +func (m ViewModel) Init() tea.Cmd { + return nil +} +func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m.HandleMessage(msg) +} +func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case modal.DeleteFinished: + m.modal.Page = modal.InfoModal + case tea.KeyMsg: + switch msg.String() { + case "esc": + if m.modal.Page == modal.InfoModal { + m.Open = false + } + } + } + m.modal, cmd = m.modal.HandleMessage(msg) + return m, cmd +} +func (m ViewModel) View() string { + if !m.Open { + return m.Parent + } + return style.WithOverlay(m.modal.View(), m.Parent) +} diff --git a/ui/pages/accounts/model.go b/ui/pages/accounts/model.go index 57a379d7..8e1ef3e7 100644 --- a/ui/pages/accounts/model.go +++ b/ui/pages/accounts/model.go @@ -27,7 +27,7 @@ func New(state *internal.StateModel) ViewModel { Height: 0, Data: state, controls: "( (g)enerate )", - navigation: "| " + style.Green.Render("accounts") + " | keys | txn |", + navigation: "| " + style.Green.Render("accounts") + " | keys |", } m.table = table.New( diff --git a/ui/pages/accounts/view.go b/ui/pages/accounts/view.go index 56913350..c59b85df 100644 --- a/ui/pages/accounts/view.go +++ b/ui/pages/accounts/view.go @@ -5,7 +5,7 @@ import ( ) func (m ViewModel) View() string { - table := style.ApplyBorder(m.Width, m.Height, "8").Render(m.table.View()) + table := style.ApplyBorder(m.Width, m.Height, "6").Render(m.table.View()) return style.WithNavigation( m.navigation, style.WithControls( diff --git a/ui/pages/keys/cmds.go b/ui/pages/keys/cmds.go deleted file mode 100644 index a8f89ec4..00000000 --- a/ui/pages/keys/cmds.go +++ /dev/null @@ -1,23 +0,0 @@ -package keys - -import ( - "github.com/algorandfoundation/hack-tui/api" - tea "github.com/charmbracelet/bubbletea" -) - -type DeleteKey *api.ParticipationKey - -type DeleteFinished string - -func EmitDeleteKey(key *api.ParticipationKey) tea.Cmd { - return func() tea.Msg { - return DeleteKey(key) - } -} - -// EmitKeySelected waits for and retrieves a new set of table rows from a given channel. -func EmitKeySelected(key *api.ParticipationKey) tea.Cmd { - return func() tea.Msg { - return key - } -} diff --git a/ui/pages/keys/controller.go b/ui/pages/keys/controller.go index 22e242f6..3bb241ec 100644 --- a/ui/pages/keys/controller.go +++ b/ui/pages/keys/controller.go @@ -2,6 +2,8 @@ package keys import ( "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/modals" + "github.com/algorandfoundation/hack-tui/ui/overlay" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -23,43 +25,17 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { case internal.Account: m.Address = msg.Address m.table.SetRows(m.makeRows(m.Data)) - case DeleteFinished: - if m.SelectedKeyToDelete == nil { - panic("SelectedKeyToDelete is unexpectedly nil") - } - internal.RemovePartKeyByID(m.Data, m.SelectedKeyToDelete.Id) - m.SelectedKeyToDelete = nil + case modal.DeleteFinished: + internal.RemovePartKeyByID(m.Data, string(msg)) m.table.SetRows(m.makeRows(m.Data)) - case tea.KeyMsg: switch msg.String() { case "enter": selKey := m.SelectedKey() if selKey != nil { - return m, EmitKeySelected(selKey) - } - return m, nil - case "g": - // TODO: navigation - case "d": - if m.SelectedKeyToDelete == nil { - m.SelectedKeyToDelete = m.SelectedKey() - } else { - m.SelectedKeyToDelete = nil - } - return m, nil - case "y": // "Yes do delete" option in the delete confirmation modal - if m.SelectedKeyToDelete != nil { - return m, EmitDeleteKey(m.SelectedKeyToDelete) - } - return m, nil - case "n": // "do NOT delete" option in the delete confirmation modal - if m.SelectedKeyToDelete != nil { - m.SelectedKeyToDelete = nil + return m, overlay.EmitShowModal(selKey) } return m, nil - case "ctrl+c": - return m, tea.Quit } case tea.WindowSizeMsg: diff --git a/ui/pages/keys/model.go b/ui/pages/keys/model.go index d974c991..e5be7ab8 100644 --- a/ui/pages/keys/model.go +++ b/ui/pages/keys/model.go @@ -31,8 +31,8 @@ func New(address string, keys *[]api.ParticipationKey) ViewModel { Width: 80, Height: 24, - controls: "( (g)enerate )", - navigation: "| accounts | " + style.Green.Render("keys") + " | txn |", + controls: "( (g)enerate | enter )", + navigation: "| accounts | " + style.Green.Render("keys") + " |", table: table.New(), } @@ -74,23 +74,14 @@ func (m ViewModel) SelectedKey() *api.ParticipationKey { } func (m ViewModel) makeColumns(width int) []table.Column { // TODO: refine responsiveness - avgWidth := (width - lipgloss.Width(style.Border.Render("")) - 14) / 7 + avgWidth := (width - lipgloss.Width(style.Border.Render("")) - 14) / 4 //avgWidth := 1 return []table.Column{ {Title: "ID", Width: avgWidth}, {Title: "Address", Width: avgWidth}, - {Title: "SelectionParticipationKey", Width: 0}, - {Title: "VoteParticipationKey", Width: 0}, - {Title: "StateProofKey", Width: 0}, - {Title: "VoteFirstValid", Width: avgWidth}, - {Title: "VoteLastValid", Width: avgWidth}, - {Title: "VoteKeyDilution", Width: avgWidth}, - {Title: "EffectiveLastValid", Width: 0}, - {Title: "EffectiveFirstValid", Width: 0}, - {Title: "LastVote", Width: avgWidth}, - {Title: "LastBlockProposal", Width: avgWidth}, - {Title: "LastStateProof", Width: 0}, + {Title: "Last Vote", Width: avgWidth}, + {Title: "Last Block Proposal", Width: avgWidth}, } } @@ -104,19 +95,8 @@ func (m ViewModel) makeRows(keys *[]api.ParticipationKey) []table.Row { rows = append(rows, table.Row{ key.Id, key.Address, - *utils.UrlEncodeBytesPtrOrNil(key.Key.SelectionParticipationKey[:]), - *utils.UrlEncodeBytesPtrOrNil(key.Key.VoteParticipationKey[:]), - *utils.UrlEncodeBytesPtrOrNil(*key.Key.StateProofKey), - utils.IntToStr(key.Key.VoteFirstValid), - utils.IntToStr(key.Key.VoteLastValid), - utils.IntToStr(key.Key.VoteKeyDilution), - //utils.StrOrNA(key.Key.VoteKeyDilution), - //utils.StrOrNA(key.Key.StateProofKey), - utils.StrOrNA(key.EffectiveLastValid), - utils.StrOrNA(key.EffectiveFirstValid), utils.StrOrNA(key.LastVote), utils.StrOrNA(key.LastBlockProposal), - utils.StrOrNA(key.LastStateProof), }) } } diff --git a/ui/pages/keys/view.go b/ui/pages/keys/view.go index 8107e710..8eafaa1d 100644 --- a/ui/pages/keys/view.go +++ b/ui/pages/keys/view.go @@ -1,20 +1,11 @@ package keys import ( - "fmt" - - "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/ui/style" - "github.com/charmbracelet/lipgloss" ) func (m ViewModel) View() string { - if m.SelectedKeyToDelete != nil { - modal := renderDeleteConfirmationModal(m.SelectedKeyToDelete) - overlay := lipgloss.Place(m.Width, m.Height, lipgloss.Center, lipgloss.Center, modal) - return overlay - } - table := style.ApplyBorder(m.Width, m.Height, "8").Render(m.table.View()) + table := style.ApplyBorder(m.Width, m.Height, "4").Render(m.table.View()) return style.WithNavigation( m.navigation, style.WithControls( @@ -26,16 +17,3 @@ func (m ViewModel) View() string { ), ) } - -func renderDeleteConfirmationModal(partKey *api.ParticipationKey) string { - modalStyle := lipgloss.NewStyle(). - Width(60). - Height(7). - Align(lipgloss.Center). - Border(lipgloss.RoundedBorder()). - Padding(1, 2) - - modalContent := fmt.Sprintf("Participation Key: %v\nAccount Address: %v\nPress either y (yes) or n (no).", partKey.Id, partKey.Address) - - return modalStyle.Render("Are you sure you want to delete this key from your node?\n" + modalContent) -} diff --git a/ui/style/style.go b/ui/style/style.go index 1647f3f7..930bdcb2 100644 --- a/ui/style/style.go +++ b/ui/style/style.go @@ -2,6 +2,8 @@ package style import ( "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "regexp" "strings" ) @@ -52,24 +54,98 @@ func WithControls(nav string, view string) string { return view } controlWidth := lipgloss.Width(nav) + + lines := strings.Split(view, "\n") + padLeft := 5 + if lipgloss.Width(view) >= controlWidth+4 { - b, _, _, _, _ := Border.GetBorder() - find := b.BottomLeft + strings.Repeat(b.Bottom, controlWidth+4) - // TODO: allow other border colors, possibly just grab the last escape char - return strings.Replace(view, find, b.BottomLeft+strings.Repeat(b.Bottom, 4)+"\u001B[0m"+nav+"\u001B[90m", 1) + line := lines[len(lines)-1] + leftEdge := padLeft + lineLeft := ansi.Truncate(line, leftEdge, "") + lineRight := TruncateLeft(line, leftEdge+lipgloss.Width(nav)) + lines[len(lines)-1] = lineLeft + nav + lineRight } - return view + return strings.Join(lines, "\n") } func WithNavigation(controls string, view string) string { if controls == "" { return view } + + padRight := 5 controlWidth := lipgloss.Width(controls) + + lines := strings.Split(view, "\n") + if lipgloss.Width(view) >= controlWidth+4 { - b, _, _, _, _ := Border.GetBorder() - find := strings.Repeat(b.Bottom, controlWidth+4) + b.BottomRight - // TODO: allow other border colors, possibly just grab the last escape char - return strings.Replace(view, find, "\u001B[0m"+controls+"\u001B[90m"+strings.Repeat(b.Bottom, 4)+b.BottomRight, 1) + line := lines[len(lines)-1] + lineWidth := lipgloss.Width(line) + leftEdge := lineWidth - (controlWidth + padRight) + lineLeft := ansi.Truncate(line, leftEdge, "") + lineRight := TruncateLeft(line, leftEdge+lipgloss.Width(controls)) + lines[len(lines)-1] = lineLeft + controls + lineRight } - return view + return strings.Join(lines, "\n") +} + +// WithOverlay is the merging of two views +// Based on https://gist.github.com/Broderick-Westrope/b89b14770c09dda928c4a108f437b927 +func WithOverlay(overlay string, view string) string { + if overlay == "" { + return view + } + + bgLines := strings.Split(view, "\n") + overlayLines := strings.Split(overlay, "\n") + + row := lipgloss.Height(view) / 2 + row -= lipgloss.Height(overlay) / 2 + col := lipgloss.Width(view) / 2 + col -= lipgloss.Width(overlay) / 2 + if col < 0 || row < 0 { + return view + } + + for i, overlayLine := range overlayLines { + targetRow := i + row + + // Ensure the target row exists in the background lines + for len(bgLines) <= targetRow { + bgLines = append(bgLines, "") + } + + bgLine := bgLines[targetRow] + bgLineWidth := ansi.StringWidth(bgLine) + + if bgLineWidth < col { + bgLine += strings.Repeat(" ", col-bgLineWidth) // Add padding + } + + bgLeft := ansi.Truncate(bgLine, col, "") + bgRight := TruncateLeft(bgLine, col+ansi.StringWidth(overlayLine)) + + bgLines[targetRow] = bgLeft + overlayLine + bgRight + } + + return strings.Join(bgLines, "\n") +} + +// TruncateLeft removes characters from the beginning of a line, considering ANSI escape codes. +func TruncateLeft(line string, padding int) string { + + // This is genius, thank you https://gist.github.com/Broderick-Westrope/b89b14770c09dda928c4a108f437b927 + wrapped := strings.Split(ansi.Hardwrap(line, padding, true), "\n") + if len(wrapped) == 1 { + return "" + } + + var ansiStyle string + // Regular expression to match ANSI escape codes. + ansiStyles := regexp.MustCompile(`\x1b[[\d;]*m`).FindAllString(wrapped[0], -1) + if len(ansiStyles) > 0 { + // Pick the last style found + ansiStyle = ansiStyles[len(ansiStyles)-1] + } + + return ansiStyle + strings.Join(wrapped[1:], "") } diff --git a/ui/viewport.go b/ui/viewport.go index 7244cd51..de5ce88e 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -1,13 +1,13 @@ package ui import ( - "context" "fmt" - "github.com/algorandfoundation/hack-tui/ui/modals/generate" - "github.com/algorandfoundation/hack-tui/ui/modals/transaction" + "github.com/algorandfoundation/hack-tui/ui/modals" + "github.com/algorandfoundation/hack-tui/ui/overlay" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/modals/generate" "github.com/algorandfoundation/hack-tui/ui/pages/accounts" "github.com/algorandfoundation/hack-tui/ui/pages/keys" tea "github.com/charmbracelet/bubbletea" @@ -35,11 +35,11 @@ type ViewportViewModel struct { protocol ProtocolViewModel // Pages - accountsPage accounts.ViewModel - keysPage keys.ViewModel - generatePage generate.ViewModel - transactionPage transaction.ViewModel + accountsPage accounts.ViewModel + keysPage keys.ViewModel + generatePage generate.ViewModel + modal overlay.ViewModel page ViewportPage client *api.ClientWithResponses @@ -48,16 +48,6 @@ type ViewportViewModel struct { errorPage ErrorViewModel } -func DeleteKey(client *api.ClientWithResponses, key keys.DeleteKey) tea.Cmd { - return func() tea.Msg { - err := internal.DeletePartKey(context.Background(), client, key.Id) - if err != nil { - return keys.DeleteFinished(err.Error()) - } - return keys.DeleteFinished(key.Id) - } -} - // Init is a no-op func (m ViewportViewModel) Init() tea.Cmd { return nil @@ -76,10 +66,21 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) switch msg := msg.(type) { + case overlay.ShowModal: + m.modal.Open = true + m.modal.SetKey(msg) + case modal.DeleteFinished: + m.modal.Open = false + m.page = AccountsPage + m.modal, cmd = m.modal.HandleMessage(msg) + cmds = append(cmds, cmd) + m.keysPage, cmd = m.keysPage.HandleMessage(msg) + cmds = append(cmds, cmd) case generate.Cancel: m.page = AccountsPage return m, nil case error: + m.modal.Open = false strMsg := msg.Error() m.errorMsg = &strMsg // When the state updates @@ -89,18 +90,20 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.page = AccountsPage } m.Data = &msg - // Navigate to the transaction page when a partkey is selected - case *api.ParticipationKey: - m.page = TransactionPage // Navigate to the keys page when an account is selected case internal.Account: m.page = KeysPage - case keys.DeleteKey: - return m, DeleteKey(m.client, msg) case tea.KeyMsg: switch msg.String() { - // Tab Backwards + case "g": + if m.page != GeneratePage && !m.modal.Open { + m.page = GeneratePage + } case "left": + // Disable when overlay is active + if m.modal.Open { + return m, nil + } if m.page == AccountsPage { return m, nil } @@ -111,8 +114,11 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.page = AccountsPage return m, nil } - // Tab Forwards case "right": + // Disable when overlay is active + if m.modal.Open { + return m, nil + } if m.page == AccountsPage { selAcc := m.accountsPage.SelectedAccount() if selAcc != (internal.Account{}) { @@ -121,48 +127,6 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } - if m.page == KeysPage { - selKey := m.keysPage.SelectedKey() - if selKey != nil && m.Data.Status.State != "SYNCING" { - m.page = TransactionPage - return m, keys.EmitKeySelected(selKey) - } - } - return m, nil - case "a": - m.page = AccountsPage - case "g": - m.generatePage.Inputs[0].SetValue(m.accountsPage.SelectedAccount().Address) - m.page = GeneratePage - return m, nil - case "k": - selAcc := m.accountsPage.SelectedAccount() - if selAcc != (internal.Account{}) { - m.page = KeysPage - return m, accounts.EmitAccountSelected(selAcc) - } - return m, nil - case "t": - if m.Data.Status.State != "SYNCING" { - - if m.page == AccountsPage { - acct := m.accountsPage.SelectedAccount() - data := *m.Data.ParticipationKeys - for i, key := range data { - if key.Address == acct.Address { - m.page = TransactionPage - return m, keys.EmitKeySelected(&data[i]) - } - } - } - if m.page == KeysPage { - selKey := m.keysPage.SelectedKey() - if selKey != nil { - m.page = TransactionPage - return m, keys.EmitKeySelected(selKey) - } - } - } return m, nil case "ctrl+c": if m.page != GeneratePage { @@ -176,6 +140,14 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.PageWidth = msg.Width m.PageHeight = max(0, msg.Height-lipgloss.Height(m.headerView())-1) + modalMsg := tea.WindowSizeMsg{ + Width: m.PageWidth - 2, + Height: m.PageHeight, + } + + m.modal, cmd = m.modal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) + // Custom size message pageMsg := tea.WindowSizeMsg{ Height: m.PageHeight, @@ -192,9 +164,6 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.generatePage, cmd = m.generatePage.HandleMessage(pageMsg) cmds = append(cmds, cmd) - m.transactionPage, cmd = m.transactionPage.HandleMessage(pageMsg) - cmds = append(cmds, cmd) - m.errorPage, cmd = m.errorPage.HandleMessage(pageMsg) cmds = append(cmds, cmd) // Avoid triggering commands again @@ -209,12 +178,15 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keysPage, cmd = m.keysPage.HandleMessage(msg) case GeneratePage: m.generatePage, cmd = m.generatePage.HandleMessage(msg) - case TransactionPage: - m.transactionPage, cmd = m.transactionPage.HandleMessage(msg) case ErrorPage: m.errorPage, cmd = m.errorPage.HandleMessage(msg) } + // Ignore commands while open + if m.modal.Open { + m.modal, cmd = m.modal.HandleMessage(msg) + } cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) } @@ -236,8 +208,6 @@ func (m ViewportViewModel) View() string { page = m.generatePage case KeysPage: page = m.keysPage - case TransactionPage: - page = m.transactionPage case ErrorPage: page = m.errorPage } @@ -246,7 +216,8 @@ func (m ViewportViewModel) View() string { return "Error loading page..." } - return fmt.Sprintf("%s\n%s", m.headerView(), page.View()) + m.modal.Parent = fmt.Sprintf("%s\n%s", m.headerView(), page.View()) + return m.modal.View() } // headerView generates the top elements @@ -275,10 +246,12 @@ func MakeViewportViewModel(state *internal.StateModel, client *api.ClientWithRes protocol: MakeProtocolViewModel(state), // Pages - accountsPage: accounts.New(state), - keysPage: keys.New("", state.ParticipationKeys), - generatePage: generate.New("", client), - transactionPage: transaction.New(state), + accountsPage: accounts.New(state), + keysPage: keys.New("", state.ParticipationKeys), + generatePage: generate.New("", client), + + // Modal + modal: overlay.New("", false, modal.New(state)), // Current Page page: AccountsPage, From b5cb153af11d24bf2bf74a84d5a5d0bb730bbc5e Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Fri, 8 Nov 2024 10:45:04 -0500 Subject: [PATCH 05/55] refactor: generate modal --- cmd/root.go | 1 + internal/accounts.go | 27 ++-- internal/participation.go | 9 +- internal/participation_test.go | 26 +++- internal/state.go | 8 +- ui/error.go | 67 ---------- ui/modal/cmds.go | 24 ++++ ui/{modals/modal.go => modal/controller.go} | 130 ++++++-------------- ui/modal/model.go | 82 ++++++++++++ ui/modal/view.go | 38 ++++++ ui/modals/confirm/confirm.go | 22 +++- ui/modals/exception/error.go | 57 +++++++++ ui/modals/generate/cmds.go | 34 +++++ ui/modals/generate/controller.go | 88 ++----------- ui/modals/generate/model.go | 65 +++++----- ui/modals/generate/view.go | 30 +---- ui/overlay/overlay.go | 62 ---------- ui/pages/accounts/controller.go | 3 + ui/pages/accounts/model.go | 29 +++-- ui/pages/accounts/view.go | 8 +- ui/pages/keys/controller.go | 30 ++++- ui/pages/keys/model.go | 54 +++++--- ui/pages/keys/view.go | 8 +- ui/status.go | 1 + ui/viewport.go | 86 ++++++------- 25 files changed, 532 insertions(+), 457 deletions(-) delete mode 100644 ui/error.go create mode 100644 ui/modal/cmds.go rename ui/{modals/modal.go => modal/controller.go} (51%) create mode 100644 ui/modal/model.go create mode 100644 ui/modal/view.go create mode 100644 ui/modals/exception/error.go delete mode 100644 ui/overlay/overlay.go diff --git a/cmd/root.go b/cmd/root.go index 50cdcc4a..cbc24c01 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -61,6 +61,7 @@ var ( } state := internal.StateModel{ + Offset: viper.GetInt("offset"), Status: internal.StatusModel{ State: "INITIALIZING", Version: "NA", diff --git a/internal/accounts.go b/internal/accounts.go index b7492b03..417c1d14 100644 --- a/internal/accounts.go +++ b/internal/accounts.go @@ -112,6 +112,17 @@ func GetAccount(client *api.ClientWithResponses, address string) (api.Account, e return *r.JSON200, nil } +func getExpiresTime(t Time, key api.ParticipationKey, state *StateModel) time.Time { + now := t.Now() + var expires = now.Add(-(time.Hour * 24 * 365 * 100)) + if key.LastBlockProposal != nil && state.Status.LastRound != 0 && state.Metrics.RoundTime != 0 { + roundDiff := max(0, *key.EffectiveLastValid-int(state.Status.LastRound)) + distance := int(state.Metrics.RoundTime) * roundDiff + expires = now.Add(time.Duration(distance)) + } + return expires +} + // AccountsFromParticipationKeys maps an array of api.ParticipationKey to a keyed map of Account func AccountsFromState(state *StateModel, t Time, client *api.ClientWithResponses) map[string]Account { values := make(map[string]Account) @@ -135,23 +146,23 @@ func AccountsFromState(state *StateModel, t Time, client *api.ClientWithResponse panic(err) } } - now := t.Now() - var expires = now.Add(-(time.Hour * 24 * 365 * 100)) - if key.EffectiveLastValid != nil { - roundDiff := max(0, *key.EffectiveLastValid-int(state.Status.LastRound)) - distance := int(state.Metrics.RoundTime) * roundDiff - expires = now.Add(time.Duration(distance)) - } values[key.Address] = Account{ Address: key.Address, Status: account.Status, Balance: account.Amount / 1000000, - Expires: expires, + Expires: getExpiresTime(t, key, state), Keys: 1, } } else { val.Keys++ + if val.Expires.Before(t.Now()) { + now := t.Now() + var expires = getExpiresTime(t, key, state) + if !expires.Before(now) { + val.Expires = expires + } + } values[key.Address] = val } } diff --git a/internal/participation.go b/internal/participation.go index 29ef856a..28e8f8b6 100644 --- a/internal/participation.go +++ b/internal/participation.go @@ -39,7 +39,12 @@ func waitForNewKey( client *api.ClientWithResponses, keys *[]api.ParticipationKey, interval time.Duration, + timeout time.Duration, ) (*[]api.ParticipationKey, error) { + if timeout <= 0*time.Second { + return nil, errors.New("timeout occurred waiting for new key") + } + timeout = timeout - interval // Fetch the latest keys currentKeys, err := GetPartKeys(ctx, client) if err != nil { @@ -49,7 +54,7 @@ func waitForNewKey( if len(*currentKeys) == len(*keys) { // Sleep then try again time.Sleep(interval) - return waitForNewKey(ctx, client, keys, interval) + return waitForNewKey(ctx, client, keys, interval, timeout) } return currentKeys, nil } @@ -97,7 +102,7 @@ func GenerateKeyPair( } // Wait for the api to have a new key - keys, err := waitForNewKey(ctx, client, originalKeys, 2*time.Second) + keys, err := waitForNewKey(ctx, client, originalKeys, 2*time.Second, 20*time.Second) if err != nil { return nil, err } diff --git a/internal/participation_test.go b/internal/participation_test.go index 25883663..f0a401f6 100644 --- a/internal/participation_test.go +++ b/internal/participation_test.go @@ -3,10 +3,10 @@ package internal import ( "context" "fmt" - "testing" - "github.com/algorandfoundation/hack-tui/api" "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" + "testing" + "time" ) func Test_ListParticipationKeys(t *testing.T) { @@ -201,3 +201,25 @@ func Test_RemovePartKeyByID(t *testing.T) { } }) } + +func Test_Timeout(t *testing.T) { + ctx := context.Background() + // Setup elevated client + apiToken, err := securityprovider.NewSecurityProviderApiKey("header", "X-Algo-API-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + if err != nil { + t.Fatal(err) + } + client, err := api.NewClientWithResponses("http://localhost:8080", api.WithRequestEditorFn(apiToken.Intercept)) + if err != nil { + t.Fatal(err) + } + + keys, err := GetPartKeys(ctx, client) + if err != nil { + t.Fatal(err) + } + _, err = waitForNewKey(ctx, client, keys, 100*time.Millisecond, 1*time.Second) + if err == nil { + t.Fatal("Did not error") + } +} diff --git a/internal/state.go b/internal/state.go index 62a0dc94..922cb674 100644 --- a/internal/state.go +++ b/internal/state.go @@ -9,14 +9,20 @@ import ( ) type StateModel struct { + // Models Status StatusModel Metrics MetricsModel Accounts map[string]Account ParticipationKeys *[]api.ParticipationKey + + // Application State + Admin bool + Offset int + // TODO: handle contexts instead of adding it to state - Admin bool Watching bool + // RPC Client *api.ClientWithResponses Context context.Context } diff --git a/ui/error.go b/ui/error.go deleted file mode 100644 index f0742510..00000000 --- a/ui/error.go +++ /dev/null @@ -1,67 +0,0 @@ -package ui - -import ( - "github.com/algorandfoundation/hack-tui/ui/style" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "strings" -) - -type ErrorViewModel struct { - Height int - Width int - Message string -} - -func NewErrorViewModel(message string) ErrorViewModel { - return ErrorViewModel{ - Height: 0, - Width: 0, - Message: message, - } -} - -func (m ErrorViewModel) Init() tea.Cmd { - return nil -} - -func (m ErrorViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m.HandleMessage(msg) -} - -func (m ErrorViewModel) HandleMessage(msg tea.Msg) (ErrorViewModel, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - - case tea.WindowSizeMsg: - borderRender := style.Border.Render("") - m.Width = max(0, msg.Width-lipgloss.Width(borderRender)) - m.Height = max(0, msg.Height-lipgloss.Height(borderRender)) - } - - return m, cmd -} - -func (m ErrorViewModel) View() string { - msgHeight := lipgloss.Height(m.Message) - msgWidth := lipgloss.Width(m.Message) - - if msgWidth > m.Width/2 { - m.Message = m.Message[0:m.Width/2] + "..." - msgWidth = m.Width/2 + 3 - } - - msg := style.Red.Render(m.Message) - padT := strings.Repeat("\n", max(0, (m.Height/2)-msgHeight)) - padL := strings.Repeat(" ", max(0, (m.Width-msgWidth)/2)) - - text := lipgloss.JoinHorizontal(lipgloss.Left, padL, msg) - render := style.ApplyBorder(m.Width, m.Height, "8").Render(lipgloss.JoinVertical(lipgloss.Center, padT, text)) - return style.WithNavigation( - "( Waiting for recovery... )", - style.WithTitle( - "System Error", - render, - ), - ) -} diff --git a/ui/modal/cmds.go b/ui/modal/cmds.go new file mode 100644 index 00000000..f7092d1a --- /dev/null +++ b/ui/modal/cmds.go @@ -0,0 +1,24 @@ +package modal + +import ( + "github.com/algorandfoundation/hack-tui/api" + tea "github.com/charmbracelet/bubbletea" +) + +type Event struct { + Key *api.ParticipationKey + Address string + Type string +} + +type ShowModal Event + +func EmitShowModal(evt Event) tea.Cmd { + return func() tea.Msg { + return ShowModal(evt) + } +} + +type DeleteFinished string + +type DeleteKey *api.ParticipationKey diff --git a/ui/modals/modal.go b/ui/modal/controller.go similarity index 51% rename from ui/modals/modal.go rename to ui/modal/controller.go index dba42d1a..77c985c0 100644 --- a/ui/modals/modal.go +++ b/ui/modal/controller.go @@ -1,80 +1,12 @@ package modal import ( - "context" - "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/internal" "github.com/algorandfoundation/hack-tui/ui/modals/confirm" - "github.com/algorandfoundation/hack-tui/ui/modals/info" - "github.com/algorandfoundation/hack-tui/ui/modals/transaction" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -func DeleteKeyCmd(ctx context.Context, client *api.ClientWithResponses, id string) tea.Cmd { - return func() tea.Msg { - err := internal.DeletePartKey(ctx, client, id) - if err != nil { - return DeleteFinished(err.Error()) - } - return DeleteFinished(id) - } -} - -type DeleteFinished string - -type DeleteKey *api.ParticipationKey - -type Page string - -const ( - InfoModal Page = "accounts" - ConfirmModal Page = "confirm" - TransactionModal Page = "transaction" -) - -type ViewModel struct { - // Width and Height - Width int - Height int - - // State for Context/Client - State *internal.StateModel - - // Views - infoModal *info.ViewModel - transactionModal *transaction.ViewModel - confirmModal *confirm.ViewModel - - // Current Component Data - title string - controls string - borderColor string - Page Page -} - -func New(state *internal.StateModel) *ViewModel { - return &ViewModel{ - Width: 0, - Height: 0, - - State: state, - - infoModal: info.New(state), - transactionModal: transaction.New(state), - confirmModal: confirm.New(state), - - Page: InfoModal, - controls: "", - borderColor: "3", - } -} -func (m ViewModel) SetKey(key *api.ParticipationKey) { - m.infoModal.ActiveKey = key - m.confirmModal.ActiveKey = key - m.transactionModal.ActiveKey = key -} func (m ViewModel) Init() tea.Cmd { return nil } @@ -83,20 +15,41 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { cmd tea.Cmd cmds []tea.Cmd ) - switch msg := msg.(type) { - case DeleteFinished: - m.Page = InfoModal + case error: + m.Open = true + m.Page = ExceptionModal + m.exceptionModal.Message = msg.Error() + // Handle Confirmation Dialog Cancel case confirm.Msg: - if msg != nil { - return &m, DeleteKeyCmd(m.State.Context, m.State.Client, msg.Id) - } else { + if msg == nil { m.Page = InfoModal } + // Handle Confirmation Dialog Delete Finished + case confirm.DeleteFinished: + m.Open = false + m.Page = InfoModal + case tea.KeyMsg: switch msg.String() { case "esc": - m.Page = InfoModal + switch m.Page { + case InfoModal: + m.Open = false + case GenerateModal: + m.Open = false + m.Page = InfoModal + case TransactionModal: + m.Page = InfoModal + case ExceptionModal: + m.Open = false + case ConfirmModal: + m.Page = InfoModal + } + case "g": + if m.Page != GenerateModal { + m.Page = GenerateModal + } case "d": if m.Page == InfoModal { m.Page = ConfirmModal @@ -133,11 +86,18 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { cmds = append(cmds, cmd) m.confirmModal, cmd = m.confirmModal.HandleMessage(modalMsg) cmds = append(cmds, cmd) + m.generateModal, cmd = m.generateModal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) return &m, tea.Batch(cmds...) } // Only trigger modal commands when they are active switch m.Page { + case ExceptionModal: + m.exceptionModal, cmd = m.exceptionModal.HandleMessage(msg) + m.title = m.exceptionModal.Title + m.controls = m.exceptionModal.Controls + m.borderColor = m.exceptionModal.BorderColor case InfoModal: m.infoModal, cmd = m.infoModal.HandleMessage(msg) m.title = m.infoModal.Title @@ -153,6 +113,11 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { m.title = m.confirmModal.Title m.controls = m.confirmModal.Controls m.borderColor = m.confirmModal.BorderColor + case GenerateModal: + m.generateModal, cmd = m.generateModal.HandleMessage(msg) + m.title = m.generateModal.Title + m.controls = m.generateModal.Controls + m.borderColor = m.generateModal.BorderColor } cmds = append(cmds, cmd) return &m, tea.Batch(cmds...) @@ -160,18 +125,3 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.HandleMessage(msg) } - -func (m ViewModel) View() string { - var render = "" - switch m.Page { - case InfoModal: - render = m.infoModal.View() - case TransactionModal: - render = m.transactionModal.View() - case ConfirmModal: - render = m.confirmModal.View() - } - width := lipgloss.Width(render) + 2 - height := lipgloss.Height(render) - return style.WithNavigation(m.controls, style.WithTitle(m.title, style.ApplyBorder(width, height, m.borderColor).PaddingRight(1).PaddingLeft(1).Render(render))) -} diff --git a/ui/modal/model.go b/ui/modal/model.go new file mode 100644 index 00000000..f0e78baa --- /dev/null +++ b/ui/modal/model.go @@ -0,0 +1,82 @@ +package modal + +import ( + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/modals/confirm" + "github.com/algorandfoundation/hack-tui/ui/modals/exception" + "github.com/algorandfoundation/hack-tui/ui/modals/generate" + "github.com/algorandfoundation/hack-tui/ui/modals/info" + "github.com/algorandfoundation/hack-tui/ui/modals/transaction" +) + +type Page string + +const ( + InfoModal Page = "accounts" + ConfirmModal Page = "confirm" + TransactionModal Page = "transaction" + GenerateModal Page = "generate" + ExceptionModal Page = "exception" +) + +type ViewModel struct { + // Parent render which the modal will be displayed on + Parent string + // Open indicates whether the modal is open or closed. + Open bool + // Width specifies the width in units. + Width int + // Height specifies the height in units. + Height int + + // State for Context/Client + State *internal.StateModel + // Address defines the string format address of the entity + Address string + + // Views + infoModal *info.ViewModel + transactionModal *transaction.ViewModel + confirmModal *confirm.ViewModel + generateModal *generate.ViewModel + exceptionModal *exception.ViewModel + + // Current Component Data + title string + controls string + borderColor string + Page Page +} + +func (m ViewModel) SetAddress(address string) { + m.Address = address + m.generateModal.SetAddress(address) +} +func (m ViewModel) SetKey(key *api.ParticipationKey) { + m.infoModal.ActiveKey = key + m.confirmModal.ActiveKey = key + m.transactionModal.ActiveKey = key +} +func New(parent string, open bool, state *internal.StateModel) *ViewModel { + return &ViewModel{ + Parent: parent, + Open: open, + + Width: 0, + Height: 0, + + Address: "", + State: state, + + infoModal: info.New(state), + transactionModal: transaction.New(state), + confirmModal: confirm.New(state), + generateModal: generate.New("", state), + exceptionModal: exception.New(""), + + Page: InfoModal, + controls: "", + borderColor: "3", + } +} diff --git a/ui/modal/view.go b/ui/modal/view.go new file mode 100644 index 00000000..03a51602 --- /dev/null +++ b/ui/modal/view.go @@ -0,0 +1,38 @@ +package modal + +import ( + "github.com/algorandfoundation/hack-tui/ui/style" + "github.com/charmbracelet/lipgloss" +) + +func (m ViewModel) View() string { + if !m.Open { + return m.Parent + } + var render = "" + switch m.Page { + case InfoModal: + render = m.infoModal.View() + case TransactionModal: + render = m.transactionModal.View() + case ConfirmModal: + render = m.confirmModal.View() + case GenerateModal: + render = m.generateModal.View() + case ExceptionModal: + render = m.exceptionModal.View() + } + width := lipgloss.Width(render) + 2 + height := lipgloss.Height(render) + + return style.WithOverlay(style.WithNavigation( + m.controls, + style.WithTitle( + m.title, + style.ApplyBorder(width, height, m.borderColor). + PaddingRight(1). + PaddingLeft(1). + Render(render), + ), + ), m.Parent) +} diff --git a/ui/modals/confirm/confirm.go b/ui/modals/confirm/confirm.go index 842671e9..7dc277b7 100644 --- a/ui/modals/confirm/confirm.go +++ b/ui/modals/confirm/confirm.go @@ -1,6 +1,7 @@ package confirm import ( + "context" "fmt" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" @@ -11,12 +12,22 @@ import ( type Msg *api.ParticipationKey -func EmitMsg(key *api.ParticipationKey) tea.Cmd { +func EmitCmd(key *api.ParticipationKey) tea.Cmd { return func() tea.Msg { return Msg(key) } } +func DeleteKeyCmd(ctx context.Context, client *api.ClientWithResponses, id string) tea.Cmd { + return func() tea.Msg { + err := internal.DeletePartKey(ctx, client, id) + if err != nil { + return DeleteFinished(err.Error()) + } + return DeleteFinished(id) + } +} +type DeleteFinished string type ViewModel struct { Width int Height int @@ -50,9 +61,14 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "y": - return &m, EmitMsg(m.ActiveKey) + var ( + cmds []tea.Cmd + ) + cmds = append(cmds, EmitCmd(m.ActiveKey)) + cmds = append(cmds, DeleteKeyCmd(m.Data.Context, m.Data.Client, m.ActiveKey.Id)) + return &m, tea.Batch(cmds...) case "n": - return &m, EmitMsg(nil) + return &m, EmitCmd(nil) } case tea.WindowSizeMsg: m.Width = msg.Width diff --git a/ui/modals/exception/error.go b/ui/modals/exception/error.go new file mode 100644 index 00000000..2d99fea9 --- /dev/null +++ b/ui/modals/exception/error.go @@ -0,0 +1,57 @@ +package exception + +import ( + "github.com/algorandfoundation/hack-tui/ui/style" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +type ViewModel struct { + Height int + Width int + Message string + + Title string + BorderColor string + Controls string + Navigation string +} + +func New(message string) *ViewModel { + return &ViewModel{ + Height: 0, + Width: 0, + Message: message, + Title: "Error", + BorderColor: "1", + Controls: "( esc )", + Navigation: "", + } +} + +func (m ViewModel) Init() tea.Cmd { + return nil +} + +func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m.HandleMessage(msg) +} + +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case error: + m.Message = msg.Error() + case tea.WindowSizeMsg: + borderRender := style.Border.Render("") + m.Width = max(0, msg.Width-lipgloss.Width(borderRender)) + m.Height = max(0, msg.Height-lipgloss.Height(borderRender)) + } + + return &m, cmd +} + +func (m ViewModel) View() string { + return ansi.Hardwrap(style.Red.Render(m.Message), m.Width, false) +} diff --git a/ui/modals/generate/cmds.go b/ui/modals/generate/cmds.go index 66900347..8d8f514c 100644 --- a/ui/modals/generate/cmds.go +++ b/ui/modals/generate/cmds.go @@ -1,6 +1,9 @@ package generate import ( + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/pages/accounts" tea "github.com/charmbracelet/bubbletea" ) @@ -12,3 +15,34 @@ func EmitCancel(cg Cancel) tea.Cmd { return cg } } + +func EmitErr(err error) tea.Cmd { + return func() tea.Msg { + return err + } +} + +func (m ViewModel) GenerateCmd() (*ViewModel, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + params := api.GenerateParticipationKeysParams{ + Dilution: nil, + First: int(m.State.Status.LastRound), + Last: int(m.State.Status.LastRound) + m.State.Offset, + } + + key, err := internal.GenerateKeyPair(m.State.Context, m.State.Client, m.Input.Value(), ¶ms) + if err != nil { + return &m, EmitErr(err) + } + + cmd = accounts.EmitAccountSelected(internal.Account{ + Address: key.Address, + }) + cmds = append(cmds, cmd) + cmd = EmitCancel(Cancel{}) + cmds = append(cmds, cmd) + return &m, tea.Batch(cmds...) +} diff --git a/ui/modals/generate/controller.go b/ui/modals/generate/controller.go index c8adbaa6..aabe50fa 100644 --- a/ui/modals/generate/controller.go +++ b/ui/modals/generate/controller.go @@ -1,16 +1,8 @@ package generate import ( - "context" - "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/internal" - "github.com/algorandfoundation/hack-tui/ui/pages/accounts" - "github.com/algorandfoundation/hack-tui/ui/style" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/log" - "strconv" ) func (m ViewModel) Init() tea.Cmd { @@ -21,83 +13,27 @@ func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.HandleMessage(msg) } -func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { - - //var cmd tea.Cmd - +func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { + var ( + cmd tea.Cmd + ) switch msg := msg.(type) { case tea.WindowSizeMsg: - m.Width = msg.Width - lipgloss.Width(style.Border.Render("")) - m.Height = msg.Height - lipgloss.Height(style.Border.Render("")) + m.Width = msg.Width + m.Height = msg.Height case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc": - return m, EmitCancel(Cancel{}) - case "tab", "shift+tab", "up", "down": - s := msg.String() - - // Cycle indexes - if s == "up" || s == "shift+tab" { - m.focusIndex-- - } else { - m.focusIndex++ - } - - if m.focusIndex > len(m.Inputs) { - m.focusIndex = 0 - } else if m.focusIndex < 0 { - m.focusIndex = len(m.Inputs) - } - - cmds := make([]tea.Cmd, len(m.Inputs)) - for i := 0; i <= len(m.Inputs)-1; i++ { - if i == m.focusIndex { - // Set focused state - cmds[i] = m.Inputs[i].Focus() - m.Inputs[i].PromptStyle = focusedStyle - m.Inputs[i].TextStyle = focusedStyle - continue - } - // Remove focused state - m.Inputs[i].Blur() - m.Inputs[i].PromptStyle = noStyle - m.Inputs[i].TextStyle = noStyle - } - - return m, tea.Batch(cmds...) + return &m, EmitCancel(Cancel{}) case "enter": - first, err := strconv.Atoi(m.Inputs[1].Value()) - last, err := strconv.Atoi(m.Inputs[2].Value()) - params := api.GenerateParticipationKeysParams{ - Dilution: nil, - First: first, - Last: last, - } - val := m.Inputs[0].Value() - key, err := internal.GenerateKeyPair(context.Background(), m.client, val, ¶ms) - if err != nil { - log.Fatal(err) - } - return m, accounts.EmitAccountSelected(internal.Account{ - Address: key.Address, - }) - + return m.GenerateCmd() } } // Handle character input and blinking - cmd := m.updateInputs(msg) - return m, cmd -} - -func (m ViewModel) updateInputs(msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, len(m.Inputs)) - - // Only text inputs with Focus() set will respond, so it's safe to simply - // update all of them here without any further logic. - for i := range m.Inputs { - m.Inputs[i], cmds[i] = m.Inputs[i].Update(msg) - } + var val textinput.Model + val, cmd = m.Input.Update(msg) - return tea.Batch(cmds...) + m.Input = &val + return &m, cmd } diff --git a/ui/modals/generate/model.go b/ui/modals/generate/model.go index 0d7f8062..caa69c7c 100644 --- a/ui/modals/generate/model.go +++ b/ui/modals/generate/model.go @@ -1,53 +1,46 @@ package generate import ( - "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" "github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/textinput" ) type ViewModel struct { - Width int - Height int + Width int + Height int + Address string - Inputs []textinput.Model + Input *textinput.Model - controls string + Title string + Controls string + BorderColor string - client *api.ClientWithResponses - focusIndex int + State *internal.StateModel cursorMode cursor.Mode } -func New(address string, client *api.ClientWithResponses) ViewModel { - m := ViewModel{ - Address: address, - Inputs: make([]textinput.Model, 3), - controls: "( ctrl+c to cancel )", - client: client, - } - - var t textinput.Model - for i := range m.Inputs { - t = textinput.New() - t.Cursor.Style = cursorStyle - t.CharLimit = 68 - - switch i { - case 0: - t.Placeholder = "Wallet Address or NFD" - t.Focus() - t.PromptStyle = focusedStyle - t.TextStyle = focusedStyle - t.CharLimit = 68 - case 1: - t.Placeholder = "First Valid Round" - case 2: - t.Placeholder = "Last" - } +func (m ViewModel) SetAddress(address string) { + m.Address = address + m.Input.SetValue(address) +} - m.Inputs[i] = t +func New(address string, state *internal.StateModel) *ViewModel { + input := textinput.New() + m := ViewModel{ + Address: address, + State: state, + Input: &input, + Title: "Generate Participation Key", + Controls: "( esc to cancel )", + BorderColor: "2", } - - return m + input.Cursor.Style = cursorStyle + input.CharLimit = 68 + input.Placeholder = "Wallet Address" + input.Focus() + input.PromptStyle = focusedStyle + input.TextStyle = focusedStyle + return &m } diff --git a/ui/modals/generate/view.go b/ui/modals/generate/view.go index 667410ea..55fe054a 100644 --- a/ui/modals/generate/view.go +++ b/ui/modals/generate/view.go @@ -1,33 +1,15 @@ package generate import ( - "fmt" - "github.com/algorandfoundation/hack-tui/ui/style" - "strings" + "github.com/charmbracelet/lipgloss" ) func (m ViewModel) View() string { - var b strings.Builder + m.Input.Focused() + render := m.Input.View() - for i := range m.Inputs { - b.WriteString(m.Inputs[i].View()) - if i < len(m.Inputs)-1 { - b.WriteRune('\n') - } + if lipgloss.Width(render) < 70 { + return lipgloss.NewStyle().Width(70).Render(render) } - - button := &blurredButton - if m.focusIndex == len(m.Inputs) { - button = &focusedButton - } - fmt.Fprintf(&b, "\n\n%s\n\n", *button) - - render := style.ApplyBorder(m.Width, m.Height, "8").Render(b.String()) - return style.WithControls( - m.controls, - style.WithTitle( - "Generate", - render, - ), - ) + return render } diff --git a/ui/overlay/overlay.go b/ui/overlay/overlay.go deleted file mode 100644 index 0c4d983a..00000000 --- a/ui/overlay/overlay.go +++ /dev/null @@ -1,62 +0,0 @@ -package overlay - -import ( - "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/ui/modals" - "github.com/algorandfoundation/hack-tui/ui/style" - tea "github.com/charmbracelet/bubbletea" -) - -type ShowModal *api.ParticipationKey - -func EmitShowModal(key *api.ParticipationKey) tea.Cmd { - return func() tea.Msg { - return ShowModal(key) - } -} - -type ViewModel struct { - Parent string - Open bool - modal *modal.ViewModel -} - -func (m ViewModel) SetKey(key *api.ParticipationKey) { - m.modal.SetKey(key) -} -func New(parent string, open bool, modal *modal.ViewModel) ViewModel { - return ViewModel{ - Parent: parent, - Open: open, - modal: modal, - } -} - -func (m ViewModel) Init() tea.Cmd { - return nil -} -func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - return m.HandleMessage(msg) -} -func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - case modal.DeleteFinished: - m.modal.Page = modal.InfoModal - case tea.KeyMsg: - switch msg.String() { - case "esc": - if m.modal.Page == modal.InfoModal { - m.Open = false - } - } - } - m.modal, cmd = m.modal.HandleMessage(msg) - return m, cmd -} -func (m ViewModel) View() string { - if !m.Open { - return m.Parent - } - return style.WithOverlay(m.modal.View(), m.Parent) -} diff --git a/ui/pages/accounts/controller.go b/ui/pages/accounts/controller.go index 4e4cf942..22e80f7e 100644 --- a/ui/pages/accounts/controller.go +++ b/ui/pages/accounts/controller.go @@ -48,6 +48,9 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { } m.table, cmd = m.table.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } diff --git a/ui/pages/accounts/model.go b/ui/pages/accounts/model.go index 8e1ef3e7..37e16bd7 100644 --- a/ui/pages/accounts/model.go +++ b/ui/pages/accounts/model.go @@ -12,22 +12,27 @@ import ( ) type ViewModel struct { - Width int - Height int - Data *internal.StateModel + Data *internal.StateModel - table table.Model - navigation string - controls string + Title string + Navigation string + Controls string + BorderColor string + Width int + Height int + + table table.Model } func New(state *internal.StateModel) ViewModel { m := ViewModel{ - Width: 0, - Height: 0, - Data: state, - controls: "( (g)enerate )", - navigation: "| " + style.Green.Render("accounts") + " | keys |", + Title: "Accounts", + Width: 0, + Height: 0, + BorderColor: "6", + Data: state, + Controls: "( (g)enerate )", + Navigation: "| " + style.Green.Render("accounts") + " | keys |", } m.table = table.New( @@ -43,7 +48,7 @@ func New(state *internal.StateModel) ViewModel { Bold(false) s.Selected = s.Selected. Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). + Background(lipgloss.Color(m.BorderColor)). Bold(false) m.table.SetStyles(s) return m diff --git a/ui/pages/accounts/view.go b/ui/pages/accounts/view.go index c59b85df..a3c0461e 100644 --- a/ui/pages/accounts/view.go +++ b/ui/pages/accounts/view.go @@ -5,13 +5,13 @@ import ( ) func (m ViewModel) View() string { - table := style.ApplyBorder(m.Width, m.Height, "6").Render(m.table.View()) + table := style.ApplyBorder(m.Width, m.Height, m.BorderColor).Render(m.table.View()) return style.WithNavigation( - m.navigation, + m.Navigation, style.WithControls( - m.controls, + m.Controls, style.WithTitle( - "Accounts", + m.Title, table, ), ), diff --git a/ui/pages/keys/controller.go b/ui/pages/keys/controller.go index 3bb241ec..3ba6b44c 100644 --- a/ui/pages/keys/controller.go +++ b/ui/pages/keys/controller.go @@ -2,8 +2,8 @@ package keys import ( "github.com/algorandfoundation/hack-tui/internal" - "github.com/algorandfoundation/hack-tui/ui/modals" - "github.com/algorandfoundation/hack-tui/ui/overlay" + "github.com/algorandfoundation/hack-tui/ui/modal" + "github.com/algorandfoundation/hack-tui/ui/modals/confirm" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -18,26 +18,40 @@ func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) switch msg := msg.(type) { + // When the State changes case internal.StateModel: m.Data = msg.ParticipationKeys m.table.SetRows(m.makeRows(m.Data)) + // When the Account is Selected case internal.Account: m.Address = msg.Address m.table.SetRows(m.makeRows(m.Data)) - case modal.DeleteFinished: + // When a confirmation Modal is finished deleting + case confirm.DeleteFinished: internal.RemovePartKeyByID(m.Data, string(msg)) m.table.SetRows(m.makeRows(m.Data)) + // When the user interacts with the render case tea.KeyMsg: switch msg.String() { + // Show the Info Modal case "enter": selKey := m.SelectedKey() if selKey != nil { - return m, overlay.EmitShowModal(selKey) + return m, modal.EmitShowModal(modal.Event{ + Key: selKey, + Address: selKey.Address, + Type: "info", + }) } return m, nil } + // Handle Resize Events case tea.WindowSizeMsg: borderRender := style.Border.Render("") borderWidth := lipgloss.Width(borderRender) @@ -50,9 +64,13 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { m.table.SetColumns(m.makeColumns(m.Width)) } - var cmds []tea.Cmd - var cmd tea.Cmd + // Handle Table Update m.table, cmd = m.table.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } cmds = append(cmds, cmd) + + // Batch all commands return m, tea.Batch(cmds...) } diff --git a/ui/pages/keys/model.go b/ui/pages/keys/model.go index e5be7ab8..508a96fd 100644 --- a/ui/pages/keys/model.go +++ b/ui/pages/keys/model.go @@ -11,31 +11,49 @@ import ( "github.com/charmbracelet/lipgloss" ) +// ViewModel represents the view state and logic for managing participation keys. type ViewModel struct { + // Address for or the filter condition in ViewModel. Address string - Data *[]api.ParticipationKey - Width int - Height int + // Data holds a pointer to a slice of ParticipationKey, representing the set of participation keys managed by the ViewModel. + Data *[]api.ParticipationKey - SelectedKeyToDelete *api.ParticipationKey + // Title represents the title displayed at the top of the ViewModel's UI. + Title string + // Controls describe the set of actions or commands available for the user to interact with the ViewModel. + Controls string + // Navigation represents the navigation bar or breadcrumbs in the ViewModel's UI, indicating the current page or section. + Navigation string + // BorderColor represents the color of the border in the ViewModel's UI. + BorderColor string + // Width represents the width of the ViewModel's UI in terms of display units. + Width int + // Height represents the height of the ViewModel's UI in terms of display units. + Height int - table table.Model - controls string - navigation string + // table manages the tabular representation of participation keys in the ViewModel. + table table.Model } +// New initializes and returns a new ViewModel for managing participation keys. func New(address string, keys *[]api.ParticipationKey) ViewModel { m := ViewModel{ + // State Address: address, Data: keys, - Width: 80, - Height: 24, - controls: "( (g)enerate | enter )", - navigation: "| accounts | " + style.Green.Render("keys") + " |", + // Sizing + Width: 0, + Height: 0, - table: table.New(), + // Page Wrapper + Title: "Keys", + Controls: "( (g)enerate )", + Navigation: "| accounts | " + style.Green.Render("keys") + " |", + BorderColor: "4", } + + // Create Table m.table = table.New( table.WithColumns(m.makeColumns(80)), table.WithRows(m.makeRows(keys)), @@ -44,6 +62,7 @@ func New(address string, keys *[]api.ParticipationKey) ViewModel { table.WithWidth(m.Width), ) + // Style Table s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). @@ -52,26 +71,29 @@ func New(address string, keys *[]api.ParticipationKey) ViewModel { Bold(false) s.Selected = s.Selected. Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). + Background(lipgloss.Color(m.BorderColor)). Bold(false) m.table.SetStyles(s) return m } +// SelectedKey returns the currently selected participation key from the ViewModel's data set, or nil if no key is selected. func (m ViewModel) SelectedKey() *api.ParticipationKey { if m.Data == nil { return nil } var partkey *api.ParticipationKey + selected := m.table.SelectedRow() for _, key := range *m.Data { - selected := m.table.SelectedRow() if len(selected) > 0 && key.Id == selected[0] { partkey = &key } } return partkey } + +// makeColumns generates a set of table columns suitable for displaying participation key data, based on the given `width`. func (m ViewModel) makeColumns(width int) []table.Column { // TODO: refine responsiveness avgWidth := (width - lipgloss.Width(style.Border.Render("")) - 14) / 4 @@ -85,9 +107,11 @@ func (m ViewModel) makeColumns(width int) []table.Column { } } +// makeRows processes a slice of ParticipationKeys and returns a sorted slice of table rows +// filtered by the ViewModel's address. func (m ViewModel) makeRows(keys *[]api.ParticipationKey) []table.Row { rows := make([]table.Row, 0) - if keys == nil { + if keys == nil || m.Address == "" { return rows } for _, key := range *keys { diff --git a/ui/pages/keys/view.go b/ui/pages/keys/view.go index 8eafaa1d..68eb4409 100644 --- a/ui/pages/keys/view.go +++ b/ui/pages/keys/view.go @@ -5,13 +5,13 @@ import ( ) func (m ViewModel) View() string { - table := style.ApplyBorder(m.Width, m.Height, "4").Render(m.table.View()) + table := style.ApplyBorder(m.Width, m.Height, m.BorderColor).Render(m.table.View()) return style.WithNavigation( - m.navigation, + m.Navigation, style.WithControls( - m.controls, + m.Controls, style.WithTitle( - "Keys", + m.Title, table, ), ), diff --git a/ui/status.go b/ui/status.go index 6a178eb4..5e039595 100644 --- a/ui/status.go +++ b/ui/status.go @@ -45,6 +45,7 @@ func (m StatusViewModel) HandleMessage(msg tea.Msg) (StatusViewModel, tea.Cmd) { return m, nil } +// getBitRate converts a given byte rate to a human-readable string format. The output may vary from B/s to GB/s. func getBitRate(bytes int) string { txString := fmt.Sprintf("%d B/s ", bytes) if bytes >= 1024 { diff --git a/ui/viewport.go b/ui/viewport.go index de5ce88e..c6491b3f 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -2,11 +2,11 @@ package ui import ( "fmt" - "github.com/algorandfoundation/hack-tui/ui/modals" - "github.com/algorandfoundation/hack-tui/ui/overlay" - "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/modal" + "github.com/algorandfoundation/hack-tui/ui/modals/confirm" + "github.com/algorandfoundation/hack-tui/ui/modals/exception" "github.com/algorandfoundation/hack-tui/ui/modals/generate" "github.com/algorandfoundation/hack-tui/ui/pages/accounts" "github.com/algorandfoundation/hack-tui/ui/pages/keys" @@ -14,16 +14,16 @@ import ( "github.com/charmbracelet/lipgloss" ) +// ViewportPage represents different pages that can be displayed in the application's viewport. type ViewportPage string const ( - AccountsPage ViewportPage = "accounts" - KeysPage ViewportPage = "keys" - GeneratePage ViewportPage = "generate" - TransactionPage ViewportPage = "transaction" - ErrorPage ViewportPage = "error" + AccountsPage ViewportPage = "accounts" + KeysPage ViewportPage = "keys" + ErrorPage ViewportPage = "error" ) +// ViewportViewModel represents the state and view model for a viewport in the application. type ViewportViewModel struct { PageWidth, PageHeight int TerminalWidth, TerminalHeight int @@ -37,15 +37,14 @@ type ViewportViewModel struct { // Pages accountsPage accounts.ViewModel keysPage keys.ViewModel - generatePage generate.ViewModel - modal overlay.ViewModel + modal *modal.ViewModel page ViewportPage client *api.ClientWithResponses // Error Handler errorMsg *string - errorPage ErrorViewModel + errorPage *exception.ViewModel } // Init is a no-op @@ -66,12 +65,12 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) switch msg := msg.(type) { - case overlay.ShowModal: + case modal.ShowModal: m.modal.Open = true - m.modal.SetKey(msg) - case modal.DeleteFinished: + m.modal.SetKey(msg.Key) + m.modal.SetAddress(msg.Address) + case confirm.DeleteFinished: m.modal.Open = false - m.page = AccountsPage m.modal, cmd = m.modal.HandleMessage(msg) cmds = append(cmds, cmd) m.keysPage, cmd = m.keysPage.HandleMessage(msg) @@ -80,9 +79,8 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.page = AccountsPage return m, nil case error: - m.modal.Open = false - strMsg := msg.Error() - m.errorMsg = &strMsg + m.modal.Open = true + m.modal.Page = modal.ExceptionModal // When the state updates case internal.StateModel: if m.errorMsg != nil { @@ -92,13 +90,23 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Data = &msg // Navigate to the keys page when an account is selected case internal.Account: + if msg.Address != "" { + m.keysPage.Address = msg.Address + if m.modal.Open { + m.modal.Open = false + } + } m.page = KeysPage case tea.KeyMsg: switch msg.String() { case "g": - if m.page != GeneratePage && !m.modal.Open { - m.page = GeneratePage + if !m.modal.Open { + m.modal.Open = true + m.modal.SetAddress(m.accountsPage.SelectedAccount().Address) + m.modal.Page = modal.GenerateModal + return m, cmd } + case "left": // Disable when overlay is active if m.modal.Open { @@ -107,9 +115,6 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.page == AccountsPage { return m, nil } - if m.page == TransactionPage { - return m, accounts.EmitAccountSelected(m.accountsPage.SelectedAccount()) - } if m.page == KeysPage { m.page = AccountsPage return m, nil @@ -129,9 +134,7 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case "ctrl+c": - if m.page != GeneratePage { - return m, tea.Quit - } + return m, tea.Quit } case tea.WindowSizeMsg: @@ -161,29 +164,25 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keysPage, cmd = m.keysPage.HandleMessage(pageMsg) cmds = append(cmds, cmd) - m.generatePage, cmd = m.generatePage.HandleMessage(pageMsg) - cmds = append(cmds, cmd) - m.errorPage, cmd = m.errorPage.HandleMessage(pageMsg) cmds = append(cmds, cmd) // Avoid triggering commands again return m, tea.Batch(cmds...) } - // Get Page Updates - switch m.page { - case AccountsPage: - m.accountsPage, cmd = m.accountsPage.HandleMessage(msg) - case KeysPage: - m.keysPage, cmd = m.keysPage.HandleMessage(msg) - case GeneratePage: - m.generatePage, cmd = m.generatePage.HandleMessage(msg) - case ErrorPage: - m.errorPage, cmd = m.errorPage.HandleMessage(msg) - } // Ignore commands while open if m.modal.Open { m.modal, cmd = m.modal.HandleMessage(msg) + } else { + // Get Page Updates + switch m.page { + case AccountsPage: + m.accountsPage, cmd = m.accountsPage.HandleMessage(msg) + case KeysPage: + m.keysPage, cmd = m.keysPage.HandleMessage(msg) + case ErrorPage: + m.errorPage, cmd = m.errorPage.HandleMessage(msg) + } } cmds = append(cmds, cmd) @@ -204,8 +203,6 @@ func (m ViewportViewModel) View() string { switch m.page { case AccountsPage: page = m.accountsPage - case GeneratePage: - page = m.generatePage case KeysPage: page = m.keysPage case ErrorPage: @@ -248,17 +245,16 @@ func MakeViewportViewModel(state *internal.StateModel, client *api.ClientWithRes // Pages accountsPage: accounts.New(state), keysPage: keys.New("", state.ParticipationKeys), - generatePage: generate.New("", client), // Modal - modal: overlay.New("", false, modal.New(state)), + modal: modal.New("", false, state), // Current Page page: AccountsPage, // RPC client client: client, - errorPage: NewErrorViewModel(""), + errorPage: exception.New(""), } return &m, nil From 159688bab43c9f0ca65adee55d29b5a40341ce5e Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 12 Nov 2024 07:54:21 -0500 Subject: [PATCH 06/55] test: add last voted to test --- internal/accounts_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/accounts_test.go b/internal/accounts_test.go index 234bf7f8..b105f8fd 100644 --- a/internal/accounts_test.go +++ b/internal/accounts_test.go @@ -67,7 +67,7 @@ func Test_AccountsFromState(t *testing.T) { effectiveFirstValid := 0 effectiveLastValid := 10000 - + lastProposedRound := 1336 // Create mockedPart Keys var mockedPartKeys = []api.ParticipationKey{ { @@ -83,7 +83,7 @@ func Test_AccountsFromState(t *testing.T) { VoteLastValid: 9999999, VoteKeyDilution: 0, }, - LastBlockProposal: nil, + LastBlockProposal: &lastProposedRound, LastStateProof: nil, LastVote: nil, }, @@ -117,7 +117,7 @@ func Test_AccountsFromState(t *testing.T) { VoteLastValid: 9999999, VoteKeyDilution: 0, }, - LastBlockProposal: nil, + LastBlockProposal: &lastProposedRound, LastStateProof: nil, LastVote: nil, }, From 76974ddcfed5068d1fd531116908ea21a0cc1b88 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 12 Nov 2024 10:20:49 -0500 Subject: [PATCH 07/55] fix: title loading --- ui/modal/controller.go | 18 ++---------------- ui/modal/model.go | 27 +++++++++++++++++++++++++++ ui/viewport.go | 2 +- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/ui/modal/controller.go b/ui/modal/controller.go index 77c985c0..5151b81b 100644 --- a/ui/modal/controller.go +++ b/ui/modal/controller.go @@ -90,36 +90,22 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { cmds = append(cmds, cmd) return &m, tea.Batch(cmds...) } - + m.SetPage(m.Page) // Only trigger modal commands when they are active switch m.Page { case ExceptionModal: m.exceptionModal, cmd = m.exceptionModal.HandleMessage(msg) - m.title = m.exceptionModal.Title - m.controls = m.exceptionModal.Controls - m.borderColor = m.exceptionModal.BorderColor case InfoModal: m.infoModal, cmd = m.infoModal.HandleMessage(msg) - m.title = m.infoModal.Title - m.controls = m.infoModal.Controls - m.borderColor = m.infoModal.BorderColor case TransactionModal: m.transactionModal, cmd = m.transactionModal.HandleMessage(msg) - m.title = m.transactionModal.Title - m.controls = m.transactionModal.Controls - m.borderColor = m.transactionModal.BorderColor case ConfirmModal: m.confirmModal, cmd = m.confirmModal.HandleMessage(msg) - m.title = m.confirmModal.Title - m.controls = m.confirmModal.Controls - m.borderColor = m.confirmModal.BorderColor case GenerateModal: m.generateModal, cmd = m.generateModal.HandleMessage(msg) - m.title = m.generateModal.Title - m.controls = m.generateModal.Controls - m.borderColor = m.generateModal.BorderColor } cmds = append(cmds, cmd) + return &m, tea.Batch(cmds...) } func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { diff --git a/ui/modal/model.go b/ui/modal/model.go index f0e78baa..50dc6f2f 100644 --- a/ui/modal/model.go +++ b/ui/modal/model.go @@ -58,6 +58,33 @@ func (m ViewModel) SetKey(key *api.ParticipationKey) { m.confirmModal.ActiveKey = key m.transactionModal.ActiveKey = key } + +func (m *ViewModel) SetPage(page Page) { + m.Page = page + switch page { + case InfoModal: + m.title = m.infoModal.Title + m.controls = m.infoModal.Controls + m.borderColor = m.infoModal.BorderColor + case ConfirmModal: + m.title = m.confirmModal.Title + m.controls = m.confirmModal.Controls + m.borderColor = m.confirmModal.BorderColor + case GenerateModal: + m.title = m.generateModal.Title + m.controls = m.generateModal.Controls + m.borderColor = m.generateModal.BorderColor + case TransactionModal: + m.title = m.transactionModal.Title + m.controls = m.transactionModal.Controls + m.borderColor = m.transactionModal.BorderColor + case ExceptionModal: + m.title = m.exceptionModal.Title + m.controls = m.exceptionModal.Controls + m.borderColor = m.exceptionModal.BorderColor + } +} + func New(parent string, open bool, state *internal.StateModel) *ViewModel { return &ViewModel{ Parent: parent, diff --git a/ui/viewport.go b/ui/viewport.go index c6491b3f..aa8441d9 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -103,7 +103,7 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.modal.Open { m.modal.Open = true m.modal.SetAddress(m.accountsPage.SelectedAccount().Address) - m.modal.Page = modal.GenerateModal + m.modal.SetPage(modal.GenerateModal) return m, cmd } From 7b2ff73054958c598dd6597153a3b8b7b5b2d210 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 13 Nov 2024 18:02:02 -0500 Subject: [PATCH 08/55] refactor: create app module, refactors cmds --- cmd/root.go | 3 +- internal/accounts.go | 2 +- internal/state.go | 2 +- internal/state_test.go | 2 +- internal/status.go | 13 +- .../accounts/cmds.go => app/accounts.go} | 6 +- ui/app/keys.go | 56 +++++++++ ui/app/modal.go | 37 ++++++ ui/app/viewport.go | 19 +++ ui/modal/cmds.go | 24 ---- ui/modal/controller.go | 94 +++++++------- ui/modal/model.go | 31 ++--- ui/modal/view.go | 13 +- ui/modals/confirm/confirm.go | 29 +---- ui/modals/exception/error.go | 9 ++ ui/modals/generate/cmds.go | 48 -------- ui/modals/generate/controller.go | 9 +- ui/modals/info/info.go | 15 ++- ui/modals/transaction/controller.go | 8 ++ ui/pages/accounts/controller.go | 7 +- ui/pages/accounts/model.go | 2 +- ui/pages/keys/controller.go | 19 +-- ui/protocol_test.go | 2 +- ui/status.go | 7 +- ui/status_test.go | 2 +- ui/viewport.go | 115 ++++++------------ ui/viewport_test.go | 4 +- 27 files changed, 297 insertions(+), 281 deletions(-) rename ui/{pages/accounts/cmds.go => app/accounts.go} (77%) create mode 100644 ui/app/keys.go create mode 100644 ui/app/modal.go create mode 100644 ui/app/viewport.go delete mode 100644 ui/modal/cmds.go delete mode 100644 ui/modals/generate/cmds.go diff --git a/cmd/root.go b/cmd/root.go index cbc24c01..014680ac 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -59,7 +59,6 @@ var ( style.Red.Render("failed to get participation keys: %s"), err) } - state := internal.StateModel{ Offset: viper.GetInt("offset"), Status: internal.StatusModel{ @@ -87,7 +86,7 @@ var ( err = state.Status.Fetch(ctx, client) cobra.CheckErr(err) - m, err := ui.MakeViewportViewModel(&state, client) + m, err := ui.NewViewportViewModel(&state, client) cobra.CheckErr(err) p := tea.NewProgram( diff --git a/internal/accounts.go b/internal/accounts.go index 417c1d14..ddf825f1 100644 --- a/internal/accounts.go +++ b/internal/accounts.go @@ -137,7 +137,7 @@ func AccountsFromState(state *StateModel, t Time, client *api.ClientWithResponse Status: "Unknown", Amount: 0, } - if state.Status.State != "SYNCING" { + if state.Status.State != SyncingState { var err error account, err = GetAccount(client, key.Address) // TODO: handle error diff --git a/internal/state.go b/internal/state.go index 922cb674..06b7e82b 100644 --- a/internal/state.go +++ b/internal/state.go @@ -71,7 +71,7 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co // Fetch Keys s.UpdateKeys() - if s.Status.State == "SYNCING" { + if s.Status.State == SyncingState { lastRound = s.Status.LastRound cb(s, nil) continue diff --git a/internal/state_test.go b/internal/state_test.go index c5a7e666..898e0135 100644 --- a/internal/state_test.go +++ b/internal/state_test.go @@ -21,7 +21,7 @@ func Test_StateModel(t *testing.T) { Status: StatusModel{ LastRound: 1337, NeedsUpdate: true, - State: "SYNCING", + State: SyncingState, }, Metrics: MetricsModel{ RoundTime: 0, diff --git a/internal/status.go b/internal/status.go index 0c417880..8d8be308 100644 --- a/internal/status.go +++ b/internal/status.go @@ -7,9 +7,16 @@ import ( "github.com/algorandfoundation/hack-tui/api" ) +type State string + +const ( + SyncingState State = "SYNCING" + StableState State = "RUNNING" +) + // StatusModel represents a status response from algod.Status type StatusModel struct { - State string + State State Version string Network string Voting bool @@ -24,9 +31,9 @@ func (m *StatusModel) String() string { func (m *StatusModel) Update(lastRound int, catchupTime int, upgradeNodeVote *bool) { m.LastRound = uint64(lastRound) if catchupTime > 0 { - m.State = "SYNCING" + m.State = SyncingState } else { - m.State = "WATCHING" + m.State = StableState } if upgradeNodeVote != nil { m.Voting = *upgradeNodeVote diff --git a/ui/pages/accounts/cmds.go b/ui/app/accounts.go similarity index 77% rename from ui/pages/accounts/cmds.go rename to ui/app/accounts.go index a4d1a455..9c2971e9 100644 --- a/ui/pages/accounts/cmds.go +++ b/ui/app/accounts.go @@ -1,13 +1,15 @@ -package accounts +package app import ( "github.com/algorandfoundation/hack-tui/internal" tea "github.com/charmbracelet/bubbletea" ) +type AccountSelected internal.Account + // EmitAccountSelected waits for and retrieves a new set of table rows from a given channel. func EmitAccountSelected(account internal.Account) tea.Cmd { return func() tea.Msg { - return account + return AccountSelected(account) } } diff --git a/ui/app/keys.go b/ui/app/keys.go new file mode 100644 index 00000000..ab1ca247 --- /dev/null +++ b/ui/app/keys.go @@ -0,0 +1,56 @@ +package app + +import ( + "context" + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + tea "github.com/charmbracelet/bubbletea" +) + +type DeleteFinished struct { + Err *error + Id string +} + +type DeleteKey *api.ParticipationKey + +func EmitDeleteKey(ctx context.Context, client *api.ClientWithResponses, id string) tea.Cmd { + return func() tea.Msg { + err := internal.DeletePartKey(ctx, client, id) + if err != nil { + return DeleteFinished{ + Err: &err, + Id: "", + } + } + return DeleteFinished{ + Err: nil, + Id: id, + } + } +} + +func GenerateCmd(account string, state *internal.StateModel) tea.Cmd { + params := api.GenerateParticipationKeysParams{ + Dilution: nil, + First: int(state.Status.LastRound), + Last: int(state.Status.LastRound) + state.Offset, + } + + key, err := internal.GenerateKeyPair(state.Context, state.Client, account, ¶ms) + if err != nil { + return EmitModalEvent(ModalEvent{ + Key: nil, + Address: "", + Err: &err, + Type: ExceptionModal, + }) + } + + return EmitModalEvent(ModalEvent{ + Key: key, + Address: key.Address, + Err: nil, + Type: InfoModal, + }) +} diff --git a/ui/app/modal.go b/ui/app/modal.go new file mode 100644 index 00000000..de1e443c --- /dev/null +++ b/ui/app/modal.go @@ -0,0 +1,37 @@ +package app + +import ( + "github.com/algorandfoundation/hack-tui/api" + tea "github.com/charmbracelet/bubbletea" +) + +type ModalType string + +const ( + CloseModal ModalType = "" + CancelModal ModalType = "cancel" + InfoModal ModalType = "info" + ConfirmModal ModalType = "confirm" + TransactionModal ModalType = "transaction" + GenerateModal ModalType = "generate" + ExceptionModal ModalType = "exception" +) + +func EmitShowModal(modal ModalType) tea.Cmd { + return func() tea.Msg { + return modal + } +} + +type ModalEvent struct { + Key *api.ParticipationKey + Address string + Err *error + Type ModalType +} + +func EmitModalEvent(event ModalEvent) tea.Cmd { + return func() tea.Msg { + return event + } +} diff --git a/ui/app/viewport.go b/ui/app/viewport.go new file mode 100644 index 00000000..a7ed03f2 --- /dev/null +++ b/ui/app/viewport.go @@ -0,0 +1,19 @@ +package app + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// Page represents different pages that can be displayed in the application's viewport. +type Page string + +const ( + AccountsPage Page = "accounts" + KeysPage Page = "keys" +) + +func EmitShowPage(page Page) tea.Cmd { + return func() tea.Msg { + return page + } +} diff --git a/ui/modal/cmds.go b/ui/modal/cmds.go deleted file mode 100644 index f7092d1a..00000000 --- a/ui/modal/cmds.go +++ /dev/null @@ -1,24 +0,0 @@ -package modal - -import ( - "github.com/algorandfoundation/hack-tui/api" - tea "github.com/charmbracelet/bubbletea" -) - -type Event struct { - Key *api.ParticipationKey - Address string - Type string -} - -type ShowModal Event - -func EmitShowModal(evt Event) tea.Cmd { - return func() tea.Msg { - return ShowModal(evt) - } -} - -type DeleteFinished string - -type DeleteKey *api.ParticipationKey diff --git a/ui/modal/controller.go b/ui/modal/controller.go index 5151b81b..d22a2033 100644 --- a/ui/modal/controller.go +++ b/ui/modal/controller.go @@ -1,7 +1,7 @@ package modal import ( - "github.com/algorandfoundation/hack-tui/ui/modals/confirm" + "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -18,55 +18,46 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { switch msg := msg.(type) { case error: m.Open = true - m.Page = ExceptionModal + m.Type = app.ExceptionModal m.exceptionModal.Message = msg.Error() - // Handle Confirmation Dialog Cancel - case confirm.Msg: - if msg == nil { - m.Page = InfoModal + case app.ModalEvent: + // On closing events + if msg.Type == app.CloseModal { + m.Open = false + } else { + m.Open = true } - // Handle Confirmation Dialog Delete Finished - case confirm.DeleteFinished: - m.Open = false - m.Page = InfoModal - - case tea.KeyMsg: - switch msg.String() { - case "esc": - switch m.Page { - case InfoModal: + // When something has triggered a cancel + if msg.Type == app.CancelModal { + switch m.Type { + case app.InfoModal: m.Open = false - case GenerateModal: + case app.GenerateModal: m.Open = false - m.Page = InfoModal - case TransactionModal: - m.Page = InfoModal - case ExceptionModal: + m.SetType(app.InfoModal) + case app.TransactionModal: + m.SetType(app.InfoModal) + case app.ExceptionModal: m.Open = false - case ConfirmModal: - m.Page = InfoModal - } - case "g": - if m.Page != GenerateModal { - m.Page = GenerateModal - } - case "d": - if m.Page == InfoModal { - m.Page = ConfirmModal - } - case "o": - if m.Page == InfoModal { - m.Page = TransactionModal - m.transactionModal.UpdateState() - } - case "enter": - if m.Page == InfoModal { - m.Page = TransactionModal - } - if m.Page == TransactionModal { - m.Page = InfoModal + case app.ConfirmModal: + m.SetType(app.InfoModal) } } + + if msg.Type != app.CloseModal && msg.Type != app.CancelModal { + m.SetType(msg.Type) + m.SetKey(msg.Key) + m.SetAddress(msg.Address) + } + + // Handle Modal Type + case app.ModalType: + m.SetType(msg) + + // Handle Confirmation Dialog Delete Finished + case app.DeleteFinished: + m.Open = false + m.Type = app.InfoModal // Handle View Size changes case tea.WindowSizeMsg: m.Width = msg.Width @@ -88,20 +79,23 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { cmds = append(cmds, cmd) m.generateModal, cmd = m.generateModal.HandleMessage(modalMsg) cmds = append(cmds, cmd) + m.exceptionModal, cmd = m.exceptionModal.HandleMessage(modalMsg) + cmds = append(cmds, cmd) return &m, tea.Batch(cmds...) } - m.SetPage(m.Page) + // Only trigger modal commands when they are active - switch m.Page { - case ExceptionModal: + switch m.Type { + case app.ExceptionModal: m.exceptionModal, cmd = m.exceptionModal.HandleMessage(msg) - case InfoModal: + case app.InfoModal: m.infoModal, cmd = m.infoModal.HandleMessage(msg) - case TransactionModal: + case app.TransactionModal: m.transactionModal, cmd = m.transactionModal.HandleMessage(msg) - case ConfirmModal: + + case app.ConfirmModal: m.confirmModal, cmd = m.confirmModal.HandleMessage(msg) - case GenerateModal: + case app.GenerateModal: m.generateModal, cmd = m.generateModal.HandleMessage(msg) } cmds = append(cmds, cmd) diff --git a/ui/modal/model.go b/ui/modal/model.go index 50dc6f2f..22a3aa45 100644 --- a/ui/modal/model.go +++ b/ui/modal/model.go @@ -3,6 +3,7 @@ package modal import ( "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/modals/confirm" "github.com/algorandfoundation/hack-tui/ui/modals/exception" "github.com/algorandfoundation/hack-tui/ui/modals/generate" @@ -10,16 +11,6 @@ import ( "github.com/algorandfoundation/hack-tui/ui/modals/transaction" ) -type Page string - -const ( - InfoModal Page = "accounts" - ConfirmModal Page = "confirm" - TransactionModal Page = "transaction" - GenerateModal Page = "generate" - ExceptionModal Page = "exception" -) - type ViewModel struct { // Parent render which the modal will be displayed on Parent string @@ -46,7 +37,7 @@ type ViewModel struct { title string controls string borderColor string - Page Page + Type app.ModalType } func (m ViewModel) SetAddress(address string) { @@ -59,26 +50,26 @@ func (m ViewModel) SetKey(key *api.ParticipationKey) { m.transactionModal.ActiveKey = key } -func (m *ViewModel) SetPage(page Page) { - m.Page = page - switch page { - case InfoModal: +func (m *ViewModel) SetType(modal app.ModalType) { + m.Type = modal + switch modal { + case app.InfoModal: m.title = m.infoModal.Title m.controls = m.infoModal.Controls m.borderColor = m.infoModal.BorderColor - case ConfirmModal: + case app.ConfirmModal: m.title = m.confirmModal.Title m.controls = m.confirmModal.Controls m.borderColor = m.confirmModal.BorderColor - case GenerateModal: + case app.GenerateModal: m.title = m.generateModal.Title m.controls = m.generateModal.Controls m.borderColor = m.generateModal.BorderColor - case TransactionModal: + case app.TransactionModal: m.title = m.transactionModal.Title m.controls = m.transactionModal.Controls m.borderColor = m.transactionModal.BorderColor - case ExceptionModal: + case app.ExceptionModal: m.title = m.exceptionModal.Title m.controls = m.exceptionModal.Controls m.borderColor = m.exceptionModal.BorderColor @@ -102,7 +93,7 @@ func New(parent string, open bool, state *internal.StateModel) *ViewModel { generateModal: generate.New("", state), exceptionModal: exception.New(""), - Page: InfoModal, + Type: app.InfoModal, controls: "", borderColor: "3", } diff --git a/ui/modal/view.go b/ui/modal/view.go index 03a51602..f02aad8f 100644 --- a/ui/modal/view.go +++ b/ui/modal/view.go @@ -1,6 +1,7 @@ package modal import ( + "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/style" "github.com/charmbracelet/lipgloss" ) @@ -10,16 +11,16 @@ func (m ViewModel) View() string { return m.Parent } var render = "" - switch m.Page { - case InfoModal: + switch m.Type { + case app.InfoModal: render = m.infoModal.View() - case TransactionModal: + case app.TransactionModal: render = m.transactionModal.View() - case ConfirmModal: + case app.ConfirmModal: render = m.confirmModal.View() - case GenerateModal: + case app.GenerateModal: render = m.generateModal.View() - case ExceptionModal: + case app.ExceptionModal: render = m.exceptionModal.View() } width := lipgloss.Width(render) + 2 diff --git a/ui/modals/confirm/confirm.go b/ui/modals/confirm/confirm.go index 7dc277b7..c5580a42 100644 --- a/ui/modals/confirm/confirm.go +++ b/ui/modals/confirm/confirm.go @@ -1,33 +1,15 @@ package confirm import ( - "context" "fmt" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -type Msg *api.ParticipationKey - -func EmitCmd(key *api.ParticipationKey) tea.Cmd { - return func() tea.Msg { - return Msg(key) - } -} -func DeleteKeyCmd(ctx context.Context, client *api.ClientWithResponses, id string) tea.Cmd { - return func() tea.Msg { - err := internal.DeletePartKey(ctx, client, id) - if err != nil { - return DeleteFinished(err.Error()) - } - return DeleteFinished(id) - } -} - -type DeleteFinished string type ViewModel struct { Width int Height int @@ -60,15 +42,16 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { + case "esc", "n": + return &m, app.EmitModalEvent(app.ModalEvent{ + Type: app.CancelModal, + }) case "y": var ( cmds []tea.Cmd ) - cmds = append(cmds, EmitCmd(m.ActiveKey)) - cmds = append(cmds, DeleteKeyCmd(m.Data.Context, m.Data.Client, m.ActiveKey.Id)) + cmds = append(cmds, app.EmitDeleteKey(m.Data.Context, m.Data.Client, m.ActiveKey.Id)) return &m, tea.Batch(cmds...) - case "n": - return &m, EmitCmd(nil) } case tea.WindowSizeMsg: m.Width = msg.Width diff --git a/ui/modals/exception/error.go b/ui/modals/exception/error.go index 2d99fea9..23fcb642 100644 --- a/ui/modals/exception/error.go +++ b/ui/modals/exception/error.go @@ -1,6 +1,7 @@ package exception import ( + "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -43,6 +44,14 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { switch msg := msg.(type) { case error: m.Message = msg.Error() + case tea.KeyMsg: + switch msg.String() { + case "esc": + return &m, app.EmitModalEvent(app.ModalEvent{ + Type: app.CancelModal, + }) + + } case tea.WindowSizeMsg: borderRender := style.Border.Render("") m.Width = max(0, msg.Width-lipgloss.Width(borderRender)) diff --git a/ui/modals/generate/cmds.go b/ui/modals/generate/cmds.go deleted file mode 100644 index 8d8f514c..00000000 --- a/ui/modals/generate/cmds.go +++ /dev/null @@ -1,48 +0,0 @@ -package generate - -import ( - "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/internal" - "github.com/algorandfoundation/hack-tui/ui/pages/accounts" - tea "github.com/charmbracelet/bubbletea" -) - -type Cancel struct{} - -// EmitCancelGenerate cancel generation -func EmitCancel(cg Cancel) tea.Cmd { - return func() tea.Msg { - return cg - } -} - -func EmitErr(err error) tea.Cmd { - return func() tea.Msg { - return err - } -} - -func (m ViewModel) GenerateCmd() (*ViewModel, tea.Cmd) { - var ( - cmd tea.Cmd - cmds []tea.Cmd - ) - params := api.GenerateParticipationKeysParams{ - Dilution: nil, - First: int(m.State.Status.LastRound), - Last: int(m.State.Status.LastRound) + m.State.Offset, - } - - key, err := internal.GenerateKeyPair(m.State.Context, m.State.Client, m.Input.Value(), ¶ms) - if err != nil { - return &m, EmitErr(err) - } - - cmd = accounts.EmitAccountSelected(internal.Account{ - Address: key.Address, - }) - cmds = append(cmds, cmd) - cmd = EmitCancel(Cancel{}) - cmds = append(cmds, cmd) - return &m, tea.Batch(cmds...) -} diff --git a/ui/modals/generate/controller.go b/ui/modals/generate/controller.go index aabe50fa..3d0958ad 100644 --- a/ui/modals/generate/controller.go +++ b/ui/modals/generate/controller.go @@ -1,6 +1,7 @@ package generate import ( + "github.com/algorandfoundation/hack-tui/ui/app" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" ) @@ -23,10 +24,12 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { m.Height = msg.Height case tea.KeyMsg: switch msg.String() { - case "ctrl+c", "esc": - return &m, EmitCancel(Cancel{}) + case "esc": + return &m, app.EmitModalEvent(app.ModalEvent{ + Type: app.CancelModal, + }) case "enter": - return m.GenerateCmd() + return &m, app.GenerateCmd(m.Input.Value(), m.State) } } diff --git a/ui/modals/info/info.go b/ui/modals/info/info.go index e1a15e7b..2dc87376 100644 --- a/ui/modals/info/info.go +++ b/ui/modals/info/info.go @@ -3,6 +3,7 @@ package info import ( "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/style" "github.com/algorandfoundation/hack-tui/ui/utils" tea "github.com/charmbracelet/bubbletea" @@ -41,6 +42,17 @@ func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + return &m, app.EmitModalEvent(app.ModalEvent{ + Type: app.CancelModal, + }) + case "d": + return &m, app.EmitShowModal(app.ConfirmModal) + case "o": + return &m, app.EmitShowModal(app.TransactionModal) + } case tea.WindowSizeMsg: m.Width = msg.Width m.Height = msg.Height @@ -64,7 +76,7 @@ func (m ViewModel) View() string { if m.ActiveKey == nil { return "No key selected" } - + account := style.Cyan.Render("Account: ") + m.ActiveKey.Address id := style.Cyan.Render("Participation ID: ") + m.ActiveKey.Id selection := style.Yellow.Render("Selection Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.ActiveKey.Key.SelectionParticipationKey[:]) vote := style.Yellow.Render("Vote Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.ActiveKey.Key.VoteParticipationKey[:]) @@ -74,6 +86,7 @@ func (m ViewModel) View() string { voteKeyDilution := style.Purple("Vote Key Dilution: ") + utils.IntToStr(m.ActiveKey.Key.VoteKeyDilution) return ansi.Hardwrap(lipgloss.JoinVertical(lipgloss.Left, + account, id, selection, vote, diff --git a/ui/modals/transaction/controller.go b/ui/modals/transaction/controller.go index 7ab69e77..04d80be0 100644 --- a/ui/modals/transaction/controller.go +++ b/ui/modals/transaction/controller.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "github.com/algorand/go-algorand-sdk/v2/types" "github.com/algorandfoundation/algourl/encoder" + "github.com/algorandfoundation/hack-tui/ui/app" tea "github.com/charmbracelet/bubbletea" ) @@ -26,6 +27,13 @@ func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + return &m, app.EmitModalEvent(app.ModalEvent{ + Type: app.CancelModal, + }) + } // Handle View Size changes case tea.WindowSizeMsg: m.Width = msg.Width diff --git a/ui/pages/accounts/controller.go b/ui/pages/accounts/controller.go index 22e80f7e..408ce060 100644 --- a/ui/pages/accounts/controller.go +++ b/ui/pages/accounts/controller.go @@ -2,6 +2,7 @@ package accounts import ( "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -28,11 +29,11 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { case "enter": selAcc := m.SelectedAccount() if selAcc != (internal.Account{}) { - return m, EmitAccountSelected(selAcc) + cmds = append(cmds, app.EmitAccountSelected(selAcc)) + cmds = append(cmds, app.EmitShowPage(app.KeysPage)) + return m, tea.Batch(cmds...) } return m, nil - case "ctrl+c": - return m, tea.Quit } case tea.WindowSizeMsg: borderRender := style.Border.Render("") diff --git a/ui/pages/accounts/model.go b/ui/pages/accounts/model.go index 37e16bd7..7581304f 100644 --- a/ui/pages/accounts/model.go +++ b/ui/pages/accounts/model.go @@ -78,7 +78,7 @@ func (m ViewModel) makeRows() *[]table.Row { for key := range m.Data.Accounts { expires := m.Data.Accounts[key].Expires.String() - if m.Data.Status.State == "SYNCING" { + if m.Data.Status.State == internal.SyncingState { expires = "SYNCING" } if !m.Data.Accounts[key].Expires.After(time.Now().Add(-(time.Hour * 24 * 365 * 50))) { diff --git a/ui/pages/keys/controller.go b/ui/pages/keys/controller.go index 3ba6b44c..46ee9296 100644 --- a/ui/pages/keys/controller.go +++ b/ui/pages/keys/controller.go @@ -2,8 +2,7 @@ package keys import ( "github.com/algorandfoundation/hack-tui/internal" - "github.com/algorandfoundation/hack-tui/ui/modal" - "github.com/algorandfoundation/hack-tui/ui/modals/confirm" + "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -28,24 +27,30 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { m.Data = msg.ParticipationKeys m.table.SetRows(m.makeRows(m.Data)) // When the Account is Selected - case internal.Account: + case app.AccountSelected: m.Address = msg.Address m.table.SetRows(m.makeRows(m.Data)) // When a confirmation Modal is finished deleting - case confirm.DeleteFinished: - internal.RemovePartKeyByID(m.Data, string(msg)) + case app.DeleteFinished: + if msg.Err != nil { + panic(msg.Err) + } + internal.RemovePartKeyByID(m.Data, msg.Id) m.table.SetRows(m.makeRows(m.Data)) // When the user interacts with the render case tea.KeyMsg: switch msg.String() { + case "esc": + return m, app.EmitShowPage(app.AccountsPage) // Show the Info Modal case "enter": selKey := m.SelectedKey() if selKey != nil { - return m, modal.EmitShowModal(modal.Event{ + // Show the Info Modal with the selected Key + return m, app.EmitModalEvent(app.ModalEvent{ Key: selKey, Address: selKey.Address, - Type: "info", + Type: app.InfoModal, }) } return m, nil diff --git a/ui/protocol_test.go b/ui/protocol_test.go index 74e1d672..1a3d6208 100644 --- a/ui/protocol_test.go +++ b/ui/protocol_test.go @@ -14,7 +14,7 @@ func Test_ProtocolViewRender(t *testing.T) { Status: internal.StatusModel{ LastRound: 1337, NeedsUpdate: true, - State: "SYNCING", + State: internal.SyncingState, }, Metrics: internal.MetricsModel{ RoundTime: 0, diff --git a/ui/status.go b/ui/status.go index 5e039595..6dd1e955 100644 --- a/ui/status.go +++ b/ui/status.go @@ -80,14 +80,15 @@ func (m StatusViewModel) View() string { size = m.TerminalWidth / 2 } beginning := style.Blue.Render(" Latest Round: ") + strconv.Itoa(int(m.Data.Status.LastRound)) - end := style.Yellow.Render(strings.ToUpper(m.Data.Status.State)) + " " + + end := style.Yellow.Render(strings.ToUpper(string(m.Data.Status.State))) + " " middle := strings.Repeat(" ", max(0, size-(lipgloss.Width(beginning)+lipgloss.Width(end)+2))) // Last Round row1 := lipgloss.JoinHorizontal(lipgloss.Left, beginning, middle, end) roundTime := fmt.Sprintf("%.2fs", float64(m.Data.Metrics.RoundTime)/float64(time.Second)) - if m.Data.Status.State == "SYNCING" { + if m.Data.Status.State == internal.SyncingState { roundTime = "--" } beginning = style.Blue.Render(" Round time: ") + roundTime @@ -97,7 +98,7 @@ func (m StatusViewModel) View() string { row2 := lipgloss.JoinHorizontal(lipgloss.Left, beginning, middle, end) tps := fmt.Sprintf("%.2f", m.Data.Metrics.TPS) - if m.Data.Status.State == "SYNCING" { + if m.Data.Status.State == internal.SyncingState { tps = "--" } beginning = style.Blue.Render(" TPS: ") + tps diff --git a/ui/status_test.go b/ui/status_test.go index 83e77ab8..03d80289 100644 --- a/ui/status_test.go +++ b/ui/status_test.go @@ -16,7 +16,7 @@ func Test_StatusViewRender(t *testing.T) { Status: internal.StatusModel{ LastRound: 1337, NeedsUpdate: true, - State: "SYNCING", + State: internal.SyncingState, }, Metrics: internal.MetricsModel{ RoundTime: 0, diff --git a/ui/viewport.go b/ui/viewport.go index aa8441d9..d0f6050e 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -4,25 +4,15 @@ import ( "fmt" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/modal" - "github.com/algorandfoundation/hack-tui/ui/modals/confirm" "github.com/algorandfoundation/hack-tui/ui/modals/exception" - "github.com/algorandfoundation/hack-tui/ui/modals/generate" "github.com/algorandfoundation/hack-tui/ui/pages/accounts" "github.com/algorandfoundation/hack-tui/ui/pages/keys" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -// ViewportPage represents different pages that can be displayed in the application's viewport. -type ViewportPage string - -const ( - AccountsPage ViewportPage = "accounts" - KeysPage ViewportPage = "keys" - ErrorPage ViewportPage = "error" -) - // ViewportViewModel represents the state and view model for a viewport in the application. type ViewportViewModel struct { PageWidth, PageHeight int @@ -39,7 +29,7 @@ type ViewportViewModel struct { keysPage keys.ViewModel modal *modal.ViewModel - page ViewportPage + page app.Page client *api.ClientWithResponses // Error Handler @@ -65,70 +55,49 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) switch msg := msg.(type) { - case modal.ShowModal: - m.modal.Open = true - m.modal.SetKey(msg.Key) - m.modal.SetAddress(msg.Address) - case confirm.DeleteFinished: - m.modal.Open = false - m.modal, cmd = m.modal.HandleMessage(msg) - cmds = append(cmds, cmd) - m.keysPage, cmd = m.keysPage.HandleMessage(msg) - cmds = append(cmds, cmd) - case generate.Cancel: - m.page = AccountsPage - return m, nil - case error: - m.modal.Open = true - m.modal.Page = modal.ExceptionModal + case app.Page: + if msg == app.KeysPage { + m.keysPage.Address = m.accountsPage.SelectedAccount().Address + } + m.page = msg // When the state updates case internal.StateModel: if m.errorMsg != nil { m.errorMsg = nil - m.page = AccountsPage + m.page = app.AccountsPage } m.Data = &msg - // Navigate to the keys page when an account is selected - case internal.Account: - if msg.Address != "" { - m.keysPage.Address = msg.Address - if m.modal.Open { - m.modal.Open = false - } - } - m.page = KeysPage case tea.KeyMsg: switch msg.String() { case "g": - if !m.modal.Open { - m.modal.Open = true - m.modal.SetAddress(m.accountsPage.SelectedAccount().Address) - m.modal.SetPage(modal.GenerateModal) - return m, cmd + // Only open modal when it is closed and not syncing + if !m.modal.Open && m.Data.Status.State != internal.SyncingState { + return m, app.EmitModalEvent(app.ModalEvent{ + Key: nil, + Address: m.accountsPage.SelectedAccount().Address, + Type: app.GenerateModal, + }) } case "left": - // Disable when overlay is active - if m.modal.Open { + // Disable when overlay is active or on Accounts + if m.modal.Open || m.page == app.AccountsPage { return m, nil } - if m.page == AccountsPage { - return m, nil - } - if m.page == KeysPage { - m.page = AccountsPage - return m, nil + // Navigate to the Keys Page + if m.page == app.KeysPage { + return m, app.EmitShowPage(app.AccountsPage) } case "right": // Disable when overlay is active if m.modal.Open { return m, nil } - if m.page == AccountsPage { + if m.page == app.AccountsPage { selAcc := m.accountsPage.SelectedAccount() if selAcc != (internal.Account{}) { - m.page = KeysPage - return m, accounts.EmitAccountSelected(selAcc) + m.page = app.KeysPage + return m, app.EmitAccountSelected(selAcc) } return m, nil } @@ -164,49 +133,39 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keysPage, cmd = m.keysPage.HandleMessage(pageMsg) cmds = append(cmds, cmd) - m.errorPage, cmd = m.errorPage.HandleMessage(pageMsg) - cmds = append(cmds, cmd) // Avoid triggering commands again return m, tea.Batch(cmds...) - } + // Ignore commands while open - if m.modal.Open { - m.modal, cmd = m.modal.HandleMessage(msg) - } else { + if !m.modal.Open { // Get Page Updates switch m.page { - case AccountsPage: + case app.AccountsPage: m.accountsPage, cmd = m.accountsPage.HandleMessage(msg) - case KeysPage: + case app.KeysPage: m.keysPage, cmd = m.keysPage.HandleMessage(msg) - case ErrorPage: - m.errorPage, cmd = m.errorPage.HandleMessage(msg) } + cmds = append(cmds, cmd) } - cmds = append(cmds, cmd) + // Run Modal Updates Last, + // This ensures Page Behavior is checked before mutating modal state + m.modal, cmd = m.modal.HandleMessage(msg) + cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } // View renders the viewport.Model func (m ViewportViewModel) View() string { - errMsg := m.errorMsg - - if errMsg != nil { - m.errorPage.Message = *errMsg - m.page = ErrorPage - } // Handle Page render var page tea.Model switch m.page { - case AccountsPage: + case app.AccountsPage: page = m.accountsPage - case KeysPage: + case app.KeysPage: page = m.keysPage - case ErrorPage: - page = m.errorPage } if page == nil { @@ -233,8 +192,8 @@ func (m ViewportViewModel) headerView() string { return lipgloss.JoinHorizontal(lipgloss.Center, m.status.View(), m.protocol.View()) } -// MakeViewportViewModel handles the construction of the TUI viewport -func MakeViewportViewModel(state *internal.StateModel, client *api.ClientWithResponses) (*ViewportViewModel, error) { +// NewViewportViewModel handles the construction of the TUI viewport +func NewViewportViewModel(state *internal.StateModel, client *api.ClientWithResponses) (*ViewportViewModel, error) { m := ViewportViewModel{ Data: state, @@ -250,7 +209,7 @@ func MakeViewportViewModel(state *internal.StateModel, client *api.ClientWithRes modal: modal.New("", false, state), // Current Page - page: AccountsPage, + page: app.AccountsPage, // RPC client client: client, diff --git a/ui/viewport_test.go b/ui/viewport_test.go index 513808e7..5037ffd4 100644 --- a/ui/viewport_test.go +++ b/ui/viewport_test.go @@ -22,7 +22,7 @@ func Test_ViewportViewRender(t *testing.T) { Status: internal.StatusModel{ LastRound: 1337, NeedsUpdate: true, - State: "SYNCING", + State: internal.SyncingState, }, Metrics: internal.MetricsModel{ RoundTime: 0, @@ -32,7 +32,7 @@ func Test_ViewportViewRender(t *testing.T) { }, } // Create the Model - m, err := MakeViewportViewModel(&state, client) + m, err := NewViewportViewModel(&state, client) if err != nil { t.Fatal(err) } From e3f2e8f602d7f48824d2498ae63992f74bab7e5d Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 13 Nov 2024 18:18:16 -0500 Subject: [PATCH 09/55] chore: move account address in confirm modal --- ui/modals/confirm/confirm.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/ui/modals/confirm/confirm.go b/ui/modals/confirm/confirm.go index c5580a42..5d852058 100644 --- a/ui/modals/confirm/confirm.go +++ b/ui/modals/confirm/confirm.go @@ -1,7 +1,6 @@ package confirm import ( - "fmt" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" "github.com/algorandfoundation/hack-tui/ui/app" @@ -68,13 +67,11 @@ func (m ViewModel) View() string { } func renderDeleteConfirmationModal(partKey *api.ParticipationKey) string { - modalStyle := lipgloss.NewStyle(). - Width(60). - Height(7). - Align(lipgloss.Center). - Padding(1, 2) - - modalContent := fmt.Sprintf("Participation Key: %v\nAccount Address: %v", partKey.Id, partKey.Address) - - return modalStyle.Render("Are you sure you want to delete this key from your node?\n" + modalContent) + return lipgloss.NewStyle().Padding(1).Render(lipgloss.JoinVertical(lipgloss.Center, + "Are you sure you want to delete this key from your node?\n", + style.Cyan.Render("Account Address:"), + partKey.Address+"\n", + style.Cyan.Render("Participation Key:"), + partKey.Id, + )) } From 723007def2bc6cbe541d7147e6024016b7951e2c Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 13 Nov 2024 20:37:25 -0500 Subject: [PATCH 10/55] fix: online/offline transactions --- internal/accounts.go | 12 ++++--- internal/accounts_test.go | 22 +++++++------ internal/participation.go | 9 +++++ ui/app/modal.go | 1 + ui/modal/controller.go | 5 +-- ui/modal/model.go | 14 +++++--- ui/modals/info/info.go | 51 ++++++++++++++++------------- ui/modals/transaction/controller.go | 19 +++++------ ui/modals/transaction/model.go | 5 +-- ui/modals/transaction/view.go | 2 +- ui/pages/accounts/model.go | 2 +- ui/pages/keys/controller.go | 4 ++- ui/pages/keys/model.go | 25 +++++++++++--- ui/style/style.go | 2 -- ui/viewport.go | 4 --- 15 files changed, 108 insertions(+), 69 deletions(-) diff --git a/internal/accounts.go b/internal/accounts.go index ddf825f1..c5c2ab50 100644 --- a/internal/accounts.go +++ b/internal/accounts.go @@ -13,6 +13,7 @@ import ( // Account represents a user's account, including address, status, balance, and number of keys. type Account struct { + Participation *api.AccountParticipation // Account Address is the algorand encoded address Address string // Status is the Online/Offline/"NotParticipating" status of the account @@ -148,11 +149,12 @@ func AccountsFromState(state *StateModel, t Time, client *api.ClientWithResponse } values[key.Address] = Account{ - Address: key.Address, - Status: account.Status, - Balance: account.Amount / 1000000, - Expires: getExpiresTime(t, key, state), - Keys: 1, + Participation: account.Participation, + Address: key.Address, + Status: account.Status, + Balance: account.Amount / 1000000, + Expires: getExpiresTime(t, key, state), + Keys: 1, } } else { val.Keys++ diff --git a/internal/accounts_test.go b/internal/accounts_test.go index b105f8fd..73c75d12 100644 --- a/internal/accounts_test.go +++ b/internal/accounts_test.go @@ -154,18 +154,20 @@ func Test_AccountsFromState(t *testing.T) { // Construct expected accounts expectedAccounts := map[string]Account{ onlineAccounts[0].Address: { - Address: onlineAccounts[0].Address, - Status: onlineAccounts[0].Status, - Balance: onlineAccounts[0].Amount / 1_000_000, - Keys: 2, - Expires: expires, + Participation: onlineAccounts[0].Participation, + Address: onlineAccounts[0].Address, + Status: onlineAccounts[0].Status, + Balance: onlineAccounts[0].Amount / 1_000_000, + Keys: 2, + Expires: expires, }, onlineAccounts[1].Address: { - Address: onlineAccounts[1].Address, - Status: onlineAccounts[1].Status, - Balance: onlineAccounts[1].Amount / 1_000_000, - Keys: 1, - Expires: expires, + Participation: onlineAccounts[1].Participation, + Address: onlineAccounts[1].Address, + Status: onlineAccounts[1].Status, + Balance: onlineAccounts[1].Amount / 1_000_000, + Keys: 1, + Expires: expires, }, } diff --git a/internal/participation.go b/internal/participation.go index 28e8f8b6..9131cfab 100644 --- a/internal/participation.go +++ b/internal/participation.go @@ -132,3 +132,12 @@ func RemovePartKeyByID(slice *[]api.ParticipationKey, id string) { } } } + +func FindParticipationIdForVoteKey(slice *[]api.ParticipationKey, votekey []byte) *string { + for _, item := range *slice { + if string(item.Key.VoteParticipationKey) == string(votekey) { + return &item.Id + } + } + return nil +} diff --git a/ui/app/modal.go b/ui/app/modal.go index de1e443c..ab9318db 100644 --- a/ui/app/modal.go +++ b/ui/app/modal.go @@ -25,6 +25,7 @@ func EmitShowModal(modal ModalType) tea.Cmd { type ModalEvent struct { Key *api.ParticipationKey + Active bool Address string Err *error Type ModalType diff --git a/ui/modal/controller.go b/ui/modal/controller.go index d22a2033..24213a28 100644 --- a/ui/modal/controller.go +++ b/ui/modal/controller.go @@ -18,8 +18,8 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { switch msg := msg.(type) { case error: m.Open = true - m.Type = app.ExceptionModal m.exceptionModal.Message = msg.Error() + m.SetType(app.ExceptionModal) case app.ModalEvent: // On closing events if msg.Type == app.CloseModal { @@ -45,9 +45,10 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { } if msg.Type != app.CloseModal && msg.Type != app.CancelModal { - m.SetType(msg.Type) m.SetKey(msg.Key) m.SetAddress(msg.Address) + m.SetActive(msg.Active) + m.SetType(msg.Type) } // Handle Modal Type diff --git a/ui/modal/model.go b/ui/modal/model.go index 22a3aa45..b22d6be7 100644 --- a/ui/modal/model.go +++ b/ui/modal/model.go @@ -40,14 +40,20 @@ type ViewModel struct { Type app.ModalType } -func (m ViewModel) SetAddress(address string) { +func (m *ViewModel) SetAddress(address string) { m.Address = address m.generateModal.SetAddress(address) } -func (m ViewModel) SetKey(key *api.ParticipationKey) { - m.infoModal.ActiveKey = key +func (m *ViewModel) SetKey(key *api.ParticipationKey) { + m.infoModal.Participation = key m.confirmModal.ActiveKey = key - m.transactionModal.ActiveKey = key + m.transactionModal.Participation = key +} +func (m *ViewModel) SetActive(active bool) { + m.infoModal.Active = active + m.infoModal.UpdateState() + m.transactionModal.Active = active + m.transactionModal.UpdateState() } func (m *ViewModel) SetType(modal app.ModalType) { diff --git a/ui/modals/info/info.go b/ui/modals/info/info.go index 2dc87376..2679a2db 100644 --- a/ui/modals/info/info.go +++ b/ui/modals/info/info.go @@ -12,13 +12,14 @@ import ( ) type ViewModel struct { - Width int - Height int - Title string - Controls string - BorderColor string - ActiveKey *api.ParticipationKey - Data *internal.StateModel + Width int + Height int + Title string + Controls string + BorderColor string + Active bool + Participation *api.ParticipationKey + State *internal.StateModel } func New(state *internal.StateModel) *ViewModel { @@ -28,7 +29,7 @@ func New(state *internal.StateModel) *ViewModel { Title: "Key Information", BorderColor: "3", Controls: "( " + style.Red.Render("(d)elete") + " | " + style.Green.Render("(o)nline") + " )", - Data: state, + State: state, } } @@ -61,29 +62,33 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { return &m, nil } func (m *ViewModel) UpdateState() { - if m.ActiveKey == nil { + if m.Participation == nil { return } - accountStatus := m.Data.Accounts[m.ActiveKey.Address].Status + accountStatus := m.State.Accounts[m.Participation.Address].Status - if accountStatus == "Online" { - m.Controls = "( " + style.Red.Render("(d)elete") + " | " + style.Yellow.Render("(o)ffline") + " )" - } else { - m.Controls = "( " + style.Red.Render("(d)elete") + " | " + style.Green.Render("(o)nline") + " )" + if accountStatus == "Online" && m.Active { + m.BorderColor = "1" + m.Controls = "( take " + style.Red.Render(style.Red.Render("(o)ffline")) + " )" + } + + if !m.Active { + m.BorderColor = "3" + m.Controls = "( " + style.Red.Render("(d)elete") + " | take " + style.Green.Render("(o)nline") + " )" } } func (m ViewModel) View() string { - if m.ActiveKey == nil { + if m.Participation == nil { return "No key selected" } - account := style.Cyan.Render("Account: ") + m.ActiveKey.Address - id := style.Cyan.Render("Participation ID: ") + m.ActiveKey.Id - selection := style.Yellow.Render("Selection Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.ActiveKey.Key.SelectionParticipationKey[:]) - vote := style.Yellow.Render("Vote Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.ActiveKey.Key.VoteParticipationKey[:]) - stateProof := style.Yellow.Render("State Proof Key: ") + *utils.UrlEncodeBytesPtrOrNil(*m.ActiveKey.Key.StateProofKey) - voteFirstValid := style.Purple("Vote First Valid: ") + utils.IntToStr(m.ActiveKey.Key.VoteFirstValid) - voteLastValid := style.Purple("Vote Last Valid: ") + utils.IntToStr(m.ActiveKey.Key.VoteLastValid) - voteKeyDilution := style.Purple("Vote Key Dilution: ") + utils.IntToStr(m.ActiveKey.Key.VoteKeyDilution) + account := style.Cyan.Render("Account: ") + m.Participation.Address + id := style.Cyan.Render("Participation ID: ") + m.Participation.Id + selection := style.Yellow.Render("Selection Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.Participation.Key.SelectionParticipationKey[:]) + vote := style.Yellow.Render("Vote Key: ") + *utils.UrlEncodeBytesPtrOrNil(m.Participation.Key.VoteParticipationKey[:]) + stateProof := style.Yellow.Render("State Proof Key: ") + *utils.UrlEncodeBytesPtrOrNil(*m.Participation.Key.StateProofKey) + voteFirstValid := style.Purple("Vote First Valid: ") + utils.IntToStr(m.Participation.Key.VoteFirstValid) + voteLastValid := style.Purple("Vote Last Valid: ") + utils.IntToStr(m.Participation.Key.VoteLastValid) + voteKeyDilution := style.Purple("Vote Key Dilution: ") + utils.IntToStr(m.Participation.Key.VoteKeyDilution) return ansi.Hardwrap(lipgloss.JoinVertical(lipgloss.Left, account, diff --git a/ui/modals/transaction/controller.go b/ui/modals/transaction/controller.go index 04d80be0..0c1da0a5 100644 --- a/ui/modals/transaction/controller.go +++ b/ui/modals/transaction/controller.go @@ -44,28 +44,27 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { } func (m *ViewModel) UpdateState() { - if m.ActiveKey == nil { + if m.Participation == nil { return } - accountStatus := m.State.Accounts[m.ActiveKey.Address].Status if m.ATxn == nil { m.ATxn = &encoder.AUrlTxn{} } fee := uint64(1000) - m.ATxn.AUrlTxnKeyCommon.Sender = m.ActiveKey.Address + m.ATxn.AUrlTxnKeyCommon.Sender = m.Participation.Address m.ATxn.AUrlTxnKeyCommon.Type = string(types.KeyRegistrationTx) m.ATxn.AUrlTxnKeyCommon.Fee = &fee - if accountStatus != "Online" { + if !m.Active { m.Title = string(OnlineTitle) m.BorderColor = "2" - votePartKey := base64.RawURLEncoding.EncodeToString(m.ActiveKey.Key.VoteParticipationKey) - selPartKey := base64.RawURLEncoding.EncodeToString(m.ActiveKey.Key.SelectionParticipationKey) - spKey := base64.RawURLEncoding.EncodeToString(*m.ActiveKey.Key.StateProofKey) - firstValid := uint64(m.ActiveKey.Key.VoteFirstValid) - lastValid := uint64(m.ActiveKey.Key.VoteLastValid) - vkDilution := uint64(m.ActiveKey.Key.VoteKeyDilution) + votePartKey := base64.RawURLEncoding.EncodeToString(m.Participation.Key.VoteParticipationKey) + selPartKey := base64.RawURLEncoding.EncodeToString(m.Participation.Key.SelectionParticipationKey) + spKey := base64.RawURLEncoding.EncodeToString(*m.Participation.Key.StateProofKey) + firstValid := uint64(m.Participation.Key.VoteFirstValid) + lastValid := uint64(m.Participation.Key.VoteLastValid) + vkDilution := uint64(m.Participation.Key.VoteKeyDilution) m.ATxn.AUrlTxnKeyreg.VotePK = &votePartKey m.ATxn.AUrlTxnKeyreg.SelectionPK = &selPartKey diff --git a/ui/modals/transaction/model.go b/ui/modals/transaction/model.go index cd1cbe6c..c394e133 100644 --- a/ui/modals/transaction/model.go +++ b/ui/modals/transaction/model.go @@ -17,7 +17,8 @@ type ViewModel struct { Title string // Active Participation Key - ActiveKey *api.ParticipationKey + Participation *api.ParticipationKey + Active bool // Pointer to the State State *internal.StateModel @@ -33,7 +34,7 @@ type ViewModel struct { } func (m ViewModel) FormatedAddress() string { - return fmt.Sprintf("%s...%s", m.ActiveKey.Address[0:4], m.ActiveKey.Address[len(m.ActiveKey.Address)-4:]) + return fmt.Sprintf("%s...%s", m.Participation.Address[0:4], m.Participation.Address[len(m.Participation.Address)-4:]) } // New creates and instance of the ViewModel with a default controls.Model diff --git a/ui/modals/transaction/view.go b/ui/modals/transaction/view.go index f7dbc0d6..4ad4ad4f 100644 --- a/ui/modals/transaction/view.go +++ b/ui/modals/transaction/view.go @@ -7,7 +7,7 @@ import ( ) func (m ViewModel) View() string { - if m.ActiveKey == nil { + if m.Participation == nil { return "No key selected" } if m.ATxn == nil { diff --git a/ui/pages/accounts/model.go b/ui/pages/accounts/model.go index 7581304f..f85d3cbf 100644 --- a/ui/pages/accounts/model.go +++ b/ui/pages/accounts/model.go @@ -63,7 +63,7 @@ func (m ViewModel) SelectedAccount() internal.Account { return account } func (m ViewModel) makeColumns(width int) []table.Column { - avgWidth := (width - lipgloss.Width(style.Border.Render("")) - 14) / 5 + avgWidth := (width - lipgloss.Width(style.Border.Render("")) - 9) / 5 return []table.Column{ {Title: "Account", Width: avgWidth}, {Title: "Keys", Width: avgWidth}, diff --git a/ui/pages/keys/controller.go b/ui/pages/keys/controller.go index 46ee9296..81561875 100644 --- a/ui/pages/keys/controller.go +++ b/ui/pages/keys/controller.go @@ -29,6 +29,7 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { // When the Account is Selected case app.AccountSelected: m.Address = msg.Address + m.Participation = msg.Participation m.table.SetRows(m.makeRows(m.Data)) // When a confirmation Modal is finished deleting case app.DeleteFinished: @@ -44,11 +45,12 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { return m, app.EmitShowPage(app.AccountsPage) // Show the Info Modal case "enter": - selKey := m.SelectedKey() + selKey, active := m.SelectedKey() if selKey != nil { // Show the Info Modal with the selected Key return m, app.EmitModalEvent(app.ModalEvent{ Key: selKey, + Active: active, Address: selKey.Address, Type: app.InfoModal, }) diff --git a/ui/pages/keys/model.go b/ui/pages/keys/model.go index 508a96fd..be296c85 100644 --- a/ui/pages/keys/model.go +++ b/ui/pages/keys/model.go @@ -1,6 +1,7 @@ package keys import ( + "github.com/algorandfoundation/hack-tui/internal" "sort" "github.com/algorandfoundation/hack-tui/ui/style" @@ -15,6 +16,9 @@ import ( type ViewModel struct { // Address for or the filter condition in ViewModel. Address string + // Participation represents the consensus protocol parameters used by this account. + Participation *api.AccountParticipation + // Data holds a pointer to a slice of ParticipationKey, representing the set of participation keys managed by the ViewModel. Data *[]api.ParticipationKey @@ -79,29 +83,32 @@ func New(address string, keys *[]api.ParticipationKey) ViewModel { } // SelectedKey returns the currently selected participation key from the ViewModel's data set, or nil if no key is selected. -func (m ViewModel) SelectedKey() *api.ParticipationKey { +func (m ViewModel) SelectedKey() (*api.ParticipationKey, bool) { if m.Data == nil { - return nil + return nil, false } var partkey *api.ParticipationKey + var active bool selected := m.table.SelectedRow() for _, key := range *m.Data { if len(selected) > 0 && key.Id == selected[0] { partkey = &key + active = selected[2] == "YES" } } - return partkey + return partkey, active } // makeColumns generates a set of table columns suitable for displaying participation key data, based on the given `width`. func (m ViewModel) makeColumns(width int) []table.Column { // TODO: refine responsiveness - avgWidth := (width - lipgloss.Width(style.Border.Render("")) - 14) / 4 + avgWidth := (width - lipgloss.Width(style.Border.Render("")) - 9) / 5 //avgWidth := 1 return []table.Column{ {Title: "ID", Width: avgWidth}, {Title: "Address", Width: avgWidth}, + {Title: "Active", Width: avgWidth}, {Title: "Last Vote", Width: avgWidth}, {Title: "Last Block Proposal", Width: avgWidth}, } @@ -114,11 +121,21 @@ func (m ViewModel) makeRows(keys *[]api.ParticipationKey) []table.Row { if keys == nil || m.Address == "" { return rows } + + var activeId *string + if m.Participation != nil { + activeId = internal.FindParticipationIdForVoteKey(keys, m.Participation.VoteParticipationKey) + } for _, key := range *keys { if key.Address == m.Address { + isActive := "NA" + if activeId != nil && *activeId == key.Id { + isActive = "YES" + } rows = append(rows, table.Row{ key.Id, key.Address, + isActive, utils.StrOrNA(key.LastVote), utils.StrOrNA(key.LastBlockProposal), }) diff --git a/ui/style/style.go b/ui/style/style.go index 930bdcb2..2fa30a31 100644 --- a/ui/style/style.go +++ b/ui/style/style.go @@ -15,8 +15,6 @@ var ( ApplyBorder = func(width int, height int, color string) lipgloss.Style { return Border. Width(width). - Padding(0). - Margin(0). Height(height). BorderForeground(lipgloss.Color(color)) } diff --git a/ui/viewport.go b/ui/viewport.go index d0f6050e..ed7eced3 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -62,10 +62,6 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.page = msg // When the state updates case internal.StateModel: - if m.errorMsg != nil { - m.errorMsg = nil - m.page = app.AccountsPage - } m.Data = &msg case tea.KeyMsg: switch msg.String() { From 603e9cf7fea21d679fa0ac8ec42346a8dd7e8faf Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 14 Nov 2024 10:35:36 -0500 Subject: [PATCH 11/55] fix: block delete key --- ui/modals/info/info.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/modals/info/info.go b/ui/modals/info/info.go index 2679a2db..012e69fa 100644 --- a/ui/modals/info/info.go +++ b/ui/modals/info/info.go @@ -50,7 +50,9 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { Type: app.CancelModal, }) case "d": - return &m, app.EmitShowModal(app.ConfirmModal) + if !m.Active { + return &m, app.EmitShowModal(app.ConfirmModal) + } case "o": return &m, app.EmitShowModal(app.TransactionModal) } From cd20d0e86a9d870d118025c6909df2c14f33b12a Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 14 Nov 2024 15:43:53 -0500 Subject: [PATCH 12/55] refactor: generate command --- internal/participation.go | 2 +- ui/app/keys.go | 46 ++++++++------- ui/modal/controller.go | 18 +++++- ui/modals/generate/controller.go | 96 ++++++++++++++++++++++++++++---- ui/modals/generate/model.go | 48 ++++++++++++++-- ui/modals/generate/view.go | 29 +++++++++- ui/pages/keys/controller.go | 6 +- ui/viewport.go | 9 ++- 8 files changed, 209 insertions(+), 45 deletions(-) diff --git a/internal/participation.go b/internal/participation.go index 9131cfab..f1db5ff8 100644 --- a/internal/participation.go +++ b/internal/participation.go @@ -102,7 +102,7 @@ func GenerateKeyPair( } // Wait for the api to have a new key - keys, err := waitForNewKey(ctx, client, originalKeys, 2*time.Second, 20*time.Second) + keys, err := waitForNewKey(ctx, client, originalKeys, 2*time.Second, 20*time.Minute) if err != nil { return nil, err } diff --git a/ui/app/keys.go b/ui/app/keys.go index ab1ca247..ab74bff5 100644 --- a/ui/app/keys.go +++ b/ui/app/keys.go @@ -5,6 +5,7 @@ import ( "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" tea "github.com/charmbracelet/bubbletea" + "time" ) type DeleteFinished struct { @@ -30,27 +31,32 @@ func EmitDeleteKey(ctx context.Context, client *api.ClientWithResponses, id stri } } -func GenerateCmd(account string, state *internal.StateModel) tea.Cmd { - params := api.GenerateParticipationKeysParams{ - Dilution: nil, - First: int(state.Status.LastRound), - Last: int(state.Status.LastRound) + state.Offset, - } +func GenerateCmd(account string, duration time.Duration, state *internal.StateModel) tea.Cmd { + return func() tea.Msg { + params := api.GenerateParticipationKeysParams{ + Dilution: nil, + First: int(state.Status.LastRound), + Last: int(state.Status.LastRound) + int((duration / state.Metrics.RoundTime)), + } + + key, err := internal.GenerateKeyPair(state.Context, state.Client, account, ¶ms) + if err != nil { + return ModalEvent{ + Key: nil, + Address: "", + Active: false, + Err: &err, + Type: ExceptionModal, + } + } - key, err := internal.GenerateKeyPair(state.Context, state.Client, account, ¶ms) - if err != nil { - return EmitModalEvent(ModalEvent{ - Key: nil, - Address: "", - Err: &err, - Type: ExceptionModal, - }) + return ModalEvent{ + Key: key, + Address: key.Address, + Active: false, + Err: nil, + Type: InfoModal, + } } - return EmitModalEvent(ModalEvent{ - Key: key, - Address: key.Address, - Err: nil, - Type: InfoModal, - }) } diff --git a/ui/modal/controller.go b/ui/modal/controller.go index 24213a28..cbcc77da 100644 --- a/ui/modal/controller.go +++ b/ui/modal/controller.go @@ -2,13 +2,20 @@ package modal import ( "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/modals/generate" "github.com/algorandfoundation/hack-tui/ui/style" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) func (m ViewModel) Init() tea.Cmd { - return nil + return tea.Batch( + m.infoModal.Init(), + m.exceptionModal.Init(), + m.transactionModal.Init(), + m.confirmModal.Init(), + m.generateModal.Init(), + ) } func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { var ( @@ -21,6 +28,9 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { m.exceptionModal.Message = msg.Error() m.SetType(app.ExceptionModal) case app.ModalEvent: + if msg.Type == app.InfoModal { + m.generateModal.SetStep(generate.AddressStep) + } // On closing events if msg.Type == app.CloseModal { m.Open = false @@ -35,6 +45,7 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { case app.GenerateModal: m.Open = false m.SetType(app.InfoModal) + m.generateModal.SetStep(generate.AddressStep) case app.TransactionModal: m.SetType(app.InfoModal) case app.ExceptionModal: @@ -59,6 +70,11 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { case app.DeleteFinished: m.Open = false m.Type = app.InfoModal + if msg.Err != nil { + m.Open = true + m.Type = app.ExceptionModal + m.exceptionModal.Message = "Delete failed" + } // Handle View Size changes case tea.WindowSizeMsg: m.Width = msg.Width diff --git a/ui/modals/generate/controller.go b/ui/modals/generate/controller.go index 3d0958ad..719b5989 100644 --- a/ui/modals/generate/controller.go +++ b/ui/modals/generate/controller.go @@ -2,21 +2,48 @@ package generate import ( "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "strconv" + "time" ) func (m ViewModel) Init() tea.Cmd { - return textinput.Blink + return tea.Batch(textinput.Blink, spinner.Tick) } func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.HandleMessage(msg) } +func (m ViewModel) SetInterval(dur time.Duration) { + +} +func (m *ViewModel) SetStep(step Step) { + m.Step = step + switch m.Step { + case AddressStep: + m.Controls = "( esc to cancel )" + m.Title = DefaultTitle + m.BorderColor = DefaultBorderColor + case DurationStep: + m.Controls = "( (s)witch range )" + m.Title = "Validity Range" + m.InputTwo.Focus() + m.InputTwo.PromptStyle = focusedStyle + m.InputTwo.TextStyle = focusedStyle + m.Input.Blur() + case WaitingStep: + m.Controls = "" + m.Title = "Generating Keys" + m.BorderColor = "9" + } +} func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { var ( - cmd tea.Cmd + cmd tea.Cmd + cmds []tea.Cmd ) switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -25,18 +52,65 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "esc": - return &m, app.EmitModalEvent(app.ModalEvent{ - Type: app.CancelModal, - }) + if m.Step != WaitingStep { + return &m, app.EmitModalEvent(app.ModalEvent{ + Type: app.CancelModal, + }) + } + case "s": + if m.Step == DurationStep { + switch m.Range { + case Day: + m.Range = Week + case Week: + m.Range = Month + case Month: + m.Range = Year + case Year: + m.Range = Day + } + return &m, nil + } case "enter": - return &m, app.GenerateCmd(m.Input.Value(), m.State) + switch m.Step { + case AddressStep: + m.SetStep(DurationStep) + return &m, app.EmitShowModal(app.GenerateModal) + case DurationStep: + m.SetStep(WaitingStep) + val, _ := strconv.Atoi(m.InputTwo.Value()) + var dur time.Duration + switch m.Range { + case Day: + dur = time.Duration(int(time.Hour*24) * val) + case Week: + dur = time.Duration(int(time.Hour*24*7) * val) + case Month: + dur = time.Duration(int(time.Hour*24*30) * val) + case Year: + dur = time.Duration(int(time.Hour*24*365) * val) + } + return &m, tea.Sequence(app.EmitShowModal(app.GenerateModal), app.GenerateCmd(m.Input.Value(), dur, m.State)) + + } + } } - // Handle character input and blinking - var val textinput.Model - val, cmd = m.Input.Update(msg) - m.Input = &val - return &m, cmd + switch m.Step { + case AddressStep: + // Handle character input and blinking + var val textinput.Model + val, cmd = m.Input.Update(msg) + m.Input = &val + cmds = append(cmds, cmd) + case DurationStep: + var val textinput.Model + val, cmd = m.InputTwo.Update(msg) + m.InputTwo = &val + cmds = append(cmds, cmd) + } + + return &m, tea.Batch(cmds...) } diff --git a/ui/modals/generate/model.go b/ui/modals/generate/model.go index caa69c7c..3fa28edf 100644 --- a/ui/modals/generate/model.go +++ b/ui/modals/generate/model.go @@ -3,15 +3,37 @@ package generate import ( "github.com/algorandfoundation/hack-tui/internal" "github.com/charmbracelet/bubbles/cursor" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" ) +type Step string + +const ( + AddressStep Step = "address" + DurationStep Step = "duration" + WaitingStep Step = "waiting" +) + +type Range string + +const ( + Day Range = "day" + Week Range = "week" + Month Range = "month" + Year Range = "year" +) + type ViewModel struct { Width int Height int - Address string - Input *textinput.Model + Address string + Input *textinput.Model + InputTwo *textinput.Model + Spinner *spinner.Model + Step Step + Range Range Title string Controls string @@ -26,15 +48,24 @@ func (m ViewModel) SetAddress(address string) { m.Input.SetValue(address) } +var DefaultControls = "( esc to cancel )" +var DefaultTitle = "Generate Participation Keys" +var DefaultBorderColor = "2" + func New(address string, state *internal.StateModel) *ViewModel { input := textinput.New() + input2 := textinput.New() + m := ViewModel{ Address: address, State: state, Input: &input, - Title: "Generate Participation Key", - Controls: "( esc to cancel )", - BorderColor: "2", + InputTwo: &input2, + Step: AddressStep, + Range: Day, + Title: DefaultTitle, + Controls: DefaultControls, + BorderColor: DefaultBorderColor, } input.Cursor.Style = cursorStyle input.CharLimit = 68 @@ -42,5 +73,12 @@ func New(address string, state *internal.StateModel) *ViewModel { input.Focus() input.PromptStyle = focusedStyle input.TextStyle = focusedStyle + + input2.Cursor.Style = cursorStyle + input2.CharLimit = 68 + input2.Placeholder = "Length of time" + + input2.PromptStyle = noStyle + input2.TextStyle = noStyle return &m } diff --git a/ui/modals/generate/view.go b/ui/modals/generate/view.go index 55fe054a..acf98a07 100644 --- a/ui/modals/generate/view.go +++ b/ui/modals/generate/view.go @@ -1,12 +1,37 @@ package generate import ( + "fmt" "github.com/charmbracelet/lipgloss" ) func (m ViewModel) View() string { - m.Input.Focused() - render := m.Input.View() + render := "" + //m.Input.Focus() + switch m.Step { + case AddressStep: + render = lipgloss.JoinVertical(lipgloss.Left, + "", + "Create keys required to participate in Algorand consensus.", + "", + "Account address:", + m.Input.View(), + "", + ) + case DurationStep: + render = lipgloss.JoinVertical(lipgloss.Left, + "", + "How long should the keys be valid for?", + "", + fmt.Sprintf("Duration in %ss:", m.Range), + m.InputTwo.View(), + "", + ) + case WaitingStep: + render = lipgloss.JoinVertical(lipgloss.Left, + "Generating Participation Keys...", + "Please wait. This operation can take a few minutes.") + } if lipgloss.Width(render) < 70 { return lipgloss.NewStyle().Width(70).Render(render) diff --git a/ui/pages/keys/controller.go b/ui/pages/keys/controller.go index 81561875..54d761de 100644 --- a/ui/pages/keys/controller.go +++ b/ui/pages/keys/controller.go @@ -33,9 +33,9 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { m.table.SetRows(m.makeRows(m.Data)) // When a confirmation Modal is finished deleting case app.DeleteFinished: - if msg.Err != nil { - panic(msg.Err) - } + //if msg.Err != nil { + // panic(msg.Err) + //} internal.RemovePartKeyByID(m.Data, msg.Id) m.table.SetRows(m.makeRows(m.Data)) // When the user interacts with the render diff --git a/ui/viewport.go b/ui/viewport.go index ed7eced3..92029844 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -39,7 +39,7 @@ type ViewportViewModel struct { // Init is a no-op func (m ViewportViewModel) Init() tea.Cmd { - return nil + return m.modal.Init() } // Update Handle the viewport lifecycle @@ -63,11 +63,16 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // When the state updates case internal.StateModel: m.Data = &msg + m.accountsPage, cmd = m.accountsPage.HandleMessage(msg) + cmds = append(cmds, cmd) + m.keysPage, cmd = m.keysPage.HandleMessage(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) case tea.KeyMsg: switch msg.String() { case "g": // Only open modal when it is closed and not syncing - if !m.modal.Open && m.Data.Status.State != internal.SyncingState { + if !m.modal.Open && m.Data.Status.State != internal.SyncingState && m.Data.Metrics.RoundTime > 0 { return m, app.EmitModalEvent(app.ModalEvent{ Key: nil, Address: m.accountsPage.SelectedAccount().Address, From 23779ebf3e53bee9eb55a7975b6b145a28b69051 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 19 Nov 2024 12:51:13 -0500 Subject: [PATCH 13/55] test(ui): keys page 100% coverage --- ui/pages/keys/controller.go | 16 +- ui/pages/keys/keys_test.go | 170 ++++++++++++++++++ .../testdata/Test_Snapshot/Visible.golden | 39 ++++ 3 files changed, 211 insertions(+), 14 deletions(-) create mode 100644 ui/pages/keys/keys_test.go create mode 100644 ui/pages/keys/testdata/Test_Snapshot/Visible.golden diff --git a/ui/pages/keys/controller.go b/ui/pages/keys/controller.go index 54d761de..cd1d198f 100644 --- a/ui/pages/keys/controller.go +++ b/ui/pages/keys/controller.go @@ -17,10 +17,6 @@ func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { - var ( - cmd tea.Cmd - cmds []tea.Cmd - ) switch msg := msg.(type) { // When the State changes case internal.StateModel: @@ -33,9 +29,6 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { m.table.SetRows(m.makeRows(m.Data)) // When a confirmation Modal is finished deleting case app.DeleteFinished: - //if msg.Err != nil { - // panic(msg.Err) - //} internal.RemovePartKeyByID(m.Data, msg.Id) m.table.SetRows(m.makeRows(m.Data)) // When the user interacts with the render @@ -72,12 +65,7 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { } // Handle Table Update - m.table, cmd = m.table.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - cmds = append(cmds, cmd) + m.table, _ = m.table.Update(msg) - // Batch all commands - return m, tea.Batch(cmds...) + return m, nil } diff --git a/ui/pages/keys/keys_test.go b/ui/pages/keys/keys_test.go new file mode 100644 index 00000000..0f10f733 --- /dev/null +++ b/ui/pages/keys/keys_test.go @@ -0,0 +1,170 @@ +package keys + +import ( + "bytes" + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/app" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +var testVoteKey = []byte("TESTKEY") +var testKeys = []api.ParticipationKey{ + { + Address: "ABC", + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "123", + Key: api.AccountParticipation{ + SelectionParticipationKey: nil, + StateProofKey: nil, + VoteFirstValid: 0, + VoteKeyDilution: 0, + VoteLastValid: 0, + VoteParticipationKey: testVoteKey, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }, + { + Address: "ABC", + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "1234", + Key: api.AccountParticipation{ + SelectionParticipationKey: nil, + StateProofKey: nil, + VoteFirstValid: 0, + VoteKeyDilution: 0, + VoteLastValid: 0, + VoteParticipationKey: nil, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }, +} + +func Test_New(t *testing.T) { + m := New("ABC", nil) + if m.Address != "ABC" { + t.Errorf("Expected Address to be ABC, got %s", m.Address) + } + d, active := m.SelectedKey() + if active { + t.Errorf("Expected to not find a selected key") + } + m, err := m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + if err != nil { + t.Errorf("Expected no error") + } + m.Data = &testKeys + m, _ = m.HandleMessage(app.AccountSelected{Address: "ABC", Participation: &api.AccountParticipation{ + SelectionParticipationKey: nil, + StateProofKey: nil, + VoteFirstValid: 0, + VoteKeyDilution: 0, + VoteLastValid: 0, + VoteParticipationKey: testVoteKey, + }}) + d, active = m.SelectedKey() + if !active { + t.Errorf("Expected to find a selected key") + } + if d.Address != "ABC" { + t.Errorf("Expected Address to be ABC, got %s", d.Address) + } + + if m.Address != "ABC" { + t.Errorf("Expected Address to be ABC, got %s", m.Address) + } +} + +func Test_Snapshot(t *testing.T) { + t.Run("Visible", func(t *testing.T) { + model := New("ABC", &testKeys) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + sm := &internal.StateModel{ + Status: internal.StatusModel{}, + Metrics: internal.MetricsModel{}, + Accounts: nil, + ParticipationKeys: &testKeys, + Admin: false, + Watching: false, + } + values := make(map[string]internal.Account) + for _, key := range *sm.ParticipationKeys { + val, ok := values[key.Address] + if !ok { + values[key.Address] = internal.Account{ + Address: key.Address, + Status: "Offline", + Balance: 0, + Expires: time.Unix(0, 0), + Keys: 1, + } + } else { + val.Keys++ + values[key.Address] = val + } + } + sm.Accounts = values + // Create the Model + m := New("ABC", &testKeys) + //m, _ = m.Address = "ABC" + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("ABC")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + // Emit a state message + tm.Send(*sm) + + // Send delete finished + tm.Send(app.DeleteFinished{ + Id: "1234", + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("ctrl+c"), + }) + + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/pages/keys/testdata/Test_Snapshot/Visible.golden b/ui/pages/keys/testdata/Test_Snapshot/Visible.golden new file mode 100644 index 00000000..a515ebbd --- /dev/null +++ b/ui/pages/keys/testdata/Test_Snapshot/Visible.golden @@ -0,0 +1,39 @@ +╭──Keys────────────────────────────────────────────────────────────────────────╮ +│ ID Address Active Last Vote Last Block P… │ +│─────────────────────────────────────────────────────────────────────────── │ +│ 123 ABC NA N/A N/A │ +│ 1234 ABC NA N/A N/A │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰────( (g)enerate )─────────────────────────────────────| accounts | keys |────╯ \ No newline at end of file From f6053bf45986cb5ee47c6e4241c76a6ddee0c308 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 19 Nov 2024 13:58:53 -0500 Subject: [PATCH 14/55] test(ui): refactor test fixtures, increase accounts coverage --- internal/accounts.go | 6 +- internal/accounts_test.go | 7 +- ui/pages/accounts/accounts_test.go | 97 ++++++++----------- ui/pages/accounts/controller.go | 18 ++-- ui/pages/accounts/model.go | 7 +- .../testdata/Test_Snapshot/Visible.golden | 2 +- ui/pages/keys/keys_test.go | 83 ++-------------- ui/test/fixtures.go | 94 ++++++++++++++++++ ui/test/mock/clock.go | 7 ++ ui/viewport.go | 4 +- 10 files changed, 172 insertions(+), 153 deletions(-) create mode 100644 ui/test/fixtures.go create mode 100644 ui/test/mock/clock.go diff --git a/internal/accounts.go b/internal/accounts.go index c5c2ab50..6926ab5b 100644 --- a/internal/accounts.go +++ b/internal/accounts.go @@ -113,7 +113,7 @@ func GetAccount(client *api.ClientWithResponses, address string) (api.Account, e return *r.JSON200, nil } -func getExpiresTime(t Time, key api.ParticipationKey, state *StateModel) time.Time { +func GetExpiresTime(t Time, key api.ParticipationKey, state *StateModel) time.Time { now := t.Now() var expires = now.Add(-(time.Hour * 24 * 365 * 100)) if key.LastBlockProposal != nil && state.Status.LastRound != 0 && state.Metrics.RoundTime != 0 { @@ -153,14 +153,14 @@ func AccountsFromState(state *StateModel, t Time, client *api.ClientWithResponse Address: key.Address, Status: account.Status, Balance: account.Amount / 1000000, - Expires: getExpiresTime(t, key, state), + Expires: GetExpiresTime(t, key, state), Keys: 1, } } else { val.Keys++ if val.Expires.Before(t.Now()) { now := t.Now() - var expires = getExpiresTime(t, key, state) + var expires = GetExpiresTime(t, key, state) if !expires.Before(now) { val.Expires = expires } diff --git a/internal/accounts_test.go b/internal/accounts_test.go index 73c75d12..5ae63095 100644 --- a/internal/accounts_test.go +++ b/internal/accounts_test.go @@ -2,16 +2,13 @@ package internal import ( "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/ui/test/mock" "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" "github.com/stretchr/testify/assert" "testing" "time" ) -type TestClock struct{} - -func (TestClock) Now() time.Time { return time.Time{} } - func Test_AccountsFromState(t *testing.T) { // Setup elevated client @@ -145,7 +142,7 @@ func Test_AccountsFromState(t *testing.T) { } // Calculate expiration - clock := new(TestClock) + clock := new(mock.Clock) now := clock.Now() roundDiff := max(0, effectiveLastValid-int(state.Status.LastRound)) distance := int(state.Metrics.RoundTime) * roundDiff diff --git a/ui/pages/accounts/accounts_test.go b/ui/pages/accounts/accounts_test.go index a3589d9a..a5a58cc6 100644 --- a/ui/pages/accounts/accounts_test.go +++ b/ui/pages/accounts/accounts_test.go @@ -2,8 +2,8 @@ package accounts import ( "bytes" - "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -12,16 +12,47 @@ import ( "time" ) +func Test_New(t *testing.T) { + m := New(&internal.StateModel{}) + acc := m.SelectedAccount() + + if acc != nil { + t.Errorf("Expected no accounts to exist, got %s", acc.Address) + } + m, cmd := m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + if cmd != nil { + t.Errorf("Expected no comand") + } + + m = New(test.GetState()) + m, _ = m.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + + if m.Data.Admin { + t.Errorf("Admin flag should be false, got true") + } + + // Fetch state after message handling + acc = m.SelectedAccount() + if acc == nil { + t.Errorf("expected true, got false") + } + + // Update syncing state + m.Data.Status.State = internal.SyncingState + m.makeRows() + if m.Data.Status.State != internal.SyncingState { + + } +} + func Test_Snapshot(t *testing.T) { t.Run("Visible", func(t *testing.T) { - model := New(&internal.StateModel{ - Status: internal.StatusModel{}, - Metrics: internal.MetricsModel{}, - Accounts: nil, - ParticipationKeys: nil, - Admin: false, - Watching: false, - }) + model := New(test.GetState()) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) @@ -29,52 +60,8 @@ func Test_Snapshot(t *testing.T) { } func Test_Messages(t *testing.T) { - var testKeys = []api.ParticipationKey{ - { - Address: "ABC", - EffectiveFirstValid: nil, - EffectiveLastValid: nil, - Id: "", - Key: api.AccountParticipation{ - SelectionParticipationKey: nil, - StateProofKey: nil, - VoteFirstValid: 0, - VoteKeyDilution: 0, - VoteLastValid: 0, - VoteParticipationKey: nil, - }, - LastBlockProposal: nil, - LastStateProof: nil, - LastVote: nil, - }, - } - sm := &internal.StateModel{ - Status: internal.StatusModel{}, - Metrics: internal.MetricsModel{}, - Accounts: nil, - ParticipationKeys: &testKeys, - Admin: false, - Watching: false, - } - values := make(map[string]internal.Account) - for _, key := range *sm.ParticipationKeys { - val, ok := values[key.Address] - if !ok { - values[key.Address] = internal.Account{ - Address: key.Address, - Status: "Offline", - Balance: 0, - Expires: time.Unix(0, 0), - Keys: 1, - } - } else { - val.Keys++ - values[key.Address] = val - } - } - sm.Accounts = values // Create the Model - m := New(sm) + m := New(test.GetState()) tm := teatest.NewTestModel( t, m, @@ -91,7 +78,7 @@ func Test_Messages(t *testing.T) { teatest.WithDuration(time.Second*3), ) - tm.Send(*sm) + tm.Send(test.GetState()) tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, diff --git a/ui/pages/accounts/controller.go b/ui/pages/accounts/controller.go index 408ce060..8bbc39cb 100644 --- a/ui/pages/accounts/controller.go +++ b/ui/pages/accounts/controller.go @@ -17,9 +17,6 @@ func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd - switch msg := msg.(type) { case internal.StateModel: m.Data = &msg @@ -28,8 +25,9 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { switch msg.String() { case "enter": selAcc := m.SelectedAccount() - if selAcc != (internal.Account{}) { - cmds = append(cmds, app.EmitAccountSelected(selAcc)) + if selAcc != nil { + var cmds []tea.Cmd + cmds = append(cmds, app.EmitAccountSelected(*selAcc)) cmds = append(cmds, app.EmitShowPage(app.KeysPage)) return m, tea.Batch(cmds...) } @@ -48,10 +46,8 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { m.table.SetColumns(m.makeColumns(m.Width)) } - m.table, cmd = m.table.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) + // Handle Table Update + m.table, _ = m.table.Update(msg) + + return m, nil } diff --git a/ui/pages/accounts/model.go b/ui/pages/accounts/model.go index f85d3cbf..4b71aab7 100644 --- a/ui/pages/accounts/model.go +++ b/ui/pages/accounts/model.go @@ -54,11 +54,12 @@ func New(state *internal.StateModel) ViewModel { return m } -func (m ViewModel) SelectedAccount() internal.Account { - var account internal.Account +func (m ViewModel) SelectedAccount() *internal.Account { + var account *internal.Account var selectedRow = m.table.SelectedRow() if selectedRow != nil { - account = m.Data.Accounts[selectedRow[0]] + selectedAccount := m.Data.Accounts[selectedRow[0]] + account = &selectedAccount } return account } diff --git a/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden b/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden index 84982831..98a06efd 100644 --- a/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden +++ b/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden @@ -1,7 +1,7 @@ ╭──Accounts────────────────────────────────────────────────────────────────────╮ │ Account Keys Status Expires Balance │ │─────────────────────────────────────────────────────────────────────────── │ -│ │ +│ ABC 2 Offline NA 0 │ │ │ │ │ │ │ diff --git a/ui/pages/keys/keys_test.go b/ui/pages/keys/keys_test.go index 0f10f733..2665d803 100644 --- a/ui/pages/keys/keys_test.go +++ b/ui/pages/keys/keys_test.go @@ -3,8 +3,8 @@ package keys import ( "bytes" "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/internal" "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -13,44 +13,6 @@ import ( "time" ) -var testVoteKey = []byte("TESTKEY") -var testKeys = []api.ParticipationKey{ - { - Address: "ABC", - EffectiveFirstValid: nil, - EffectiveLastValid: nil, - Id: "123", - Key: api.AccountParticipation{ - SelectionParticipationKey: nil, - StateProofKey: nil, - VoteFirstValid: 0, - VoteKeyDilution: 0, - VoteLastValid: 0, - VoteParticipationKey: testVoteKey, - }, - LastBlockProposal: nil, - LastStateProof: nil, - LastVote: nil, - }, - { - Address: "ABC", - EffectiveFirstValid: nil, - EffectiveLastValid: nil, - Id: "1234", - Key: api.AccountParticipation{ - SelectionParticipationKey: nil, - StateProofKey: nil, - VoteFirstValid: 0, - VoteKeyDilution: 0, - VoteLastValid: 0, - VoteParticipationKey: nil, - }, - LastBlockProposal: nil, - LastStateProof: nil, - LastVote: nil, - }, -} - func Test_New(t *testing.T) { m := New("ABC", nil) if m.Address != "ABC" { @@ -60,22 +22,21 @@ func Test_New(t *testing.T) { if active { t.Errorf("Expected to not find a selected key") } - m, err := m.HandleMessage(tea.KeyMsg{ + m, cmd := m.HandleMessage(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("enter"), }) - - if err != nil { - t.Errorf("Expected no error") + if cmd != nil { + t.Errorf("Expected no commands") } - m.Data = &testKeys + m.Data = &test.Keys m, _ = m.HandleMessage(app.AccountSelected{Address: "ABC", Participation: &api.AccountParticipation{ SelectionParticipationKey: nil, StateProofKey: nil, VoteFirstValid: 0, VoteKeyDilution: 0, VoteLastValid: 0, - VoteParticipationKey: testVoteKey, + VoteParticipationKey: test.VoteKey, }}) d, active = m.SelectedKey() if !active { @@ -92,7 +53,7 @@ func Test_New(t *testing.T) { func Test_Snapshot(t *testing.T) { t.Run("Visible", func(t *testing.T) { - model := New("ABC", &testKeys) + model := New("ABC", &test.Keys) model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) @@ -100,33 +61,9 @@ func Test_Snapshot(t *testing.T) { } func Test_Messages(t *testing.T) { - sm := &internal.StateModel{ - Status: internal.StatusModel{}, - Metrics: internal.MetricsModel{}, - Accounts: nil, - ParticipationKeys: &testKeys, - Admin: false, - Watching: false, - } - values := make(map[string]internal.Account) - for _, key := range *sm.ParticipationKeys { - val, ok := values[key.Address] - if !ok { - values[key.Address] = internal.Account{ - Address: key.Address, - Status: "Offline", - Balance: 0, - Expires: time.Unix(0, 0), - Keys: 1, - } - } else { - val.Keys++ - values[key.Address] = val - } - } - sm.Accounts = values + // Create the Model - m := New("ABC", &testKeys) + m := New("ABC", &test.Keys) //m, _ = m.Address = "ABC" tm := teatest.NewTestModel( t, m, @@ -144,7 +81,7 @@ func Test_Messages(t *testing.T) { ) // Emit a state message - tm.Send(*sm) + tm.Send(*test.GetState()) // Send delete finished tm.Send(app.DeleteFinished{ diff --git a/ui/test/fixtures.go b/ui/test/fixtures.go new file mode 100644 index 00000000..688eb09b --- /dev/null +++ b/ui/test/fixtures.go @@ -0,0 +1,94 @@ +package test + +import ( + "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal" + "github.com/algorandfoundation/hack-tui/ui/test/mock" + "time" +) + +var VoteKey = []byte("TESTKEY") +var Keys = []api.ParticipationKey{ + { + Address: "ABC", + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "123", + Key: api.AccountParticipation{ + SelectionParticipationKey: nil, + StateProofKey: nil, + VoteFirstValid: 0, + VoteKeyDilution: 100, + VoteLastValid: 30000, + VoteParticipationKey: VoteKey, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }, + { + Address: "ABC", + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "1234", + Key: api.AccountParticipation{ + SelectionParticipationKey: nil, + StateProofKey: nil, + VoteFirstValid: 0, + VoteKeyDilution: 100, + VoteLastValid: 30000, + VoteParticipationKey: nil, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }, +} + +func GetState() *internal.StateModel { + sm := &internal.StateModel{ + Status: internal.StatusModel{ + State: internal.StableState, + Version: "v-test", + Network: "v-test-network", + Voting: false, + NeedsUpdate: false, + LastRound: 0, + }, + Metrics: internal.MetricsModel{ + Enabled: true, + Window: 100, + RoundTime: time.Second * 2, + TPS: 2.5, + RX: 0, + TX: 0, + LastTS: time.Time{}, + LastRX: 0, + LastTX: 0, + }, + Accounts: nil, + ParticipationKeys: &Keys, + Admin: false, + Watching: false, + } + values := make(map[string]internal.Account) + clock := new(mock.Clock) + for _, key := range *sm.ParticipationKeys { + val, ok := values[key.Address] + if !ok { + values[key.Address] = internal.Account{ + Address: key.Address, + Status: "Offline", + Balance: 0, + Expires: internal.GetExpiresTime(clock, key, sm), + Keys: 1, + } + } else { + val.Keys++ + values[key.Address] = val + } + } + sm.Accounts = values + + return sm +} diff --git a/ui/test/mock/clock.go b/ui/test/mock/clock.go new file mode 100644 index 00000000..66039202 --- /dev/null +++ b/ui/test/mock/clock.go @@ -0,0 +1,7 @@ +package mock + +import "time" + +type Clock struct{} + +func (Clock) Now() time.Time { return time.Time{} } diff --git a/ui/viewport.go b/ui/viewport.go index 92029844..909f20d5 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -96,9 +96,9 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.page == app.AccountsPage { selAcc := m.accountsPage.SelectedAccount() - if selAcc != (internal.Account{}) { + if selAcc != nil { m.page = app.KeysPage - return m, app.EmitAccountSelected(selAcc) + return m, app.EmitAccountSelected(*selAcc) } return m, nil } From a0dd193a63a89931825a82904ea805c3e015bbfa Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 19 Nov 2024 14:11:49 -0500 Subject: [PATCH 15/55] test(ui): accounts page 100% coverage --- ui/pages/accounts/accounts_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/pages/accounts/accounts_test.go b/ui/pages/accounts/accounts_test.go index a5a58cc6..df0e7c44 100644 --- a/ui/pages/accounts/accounts_test.go +++ b/ui/pages/accounts/accounts_test.go @@ -78,7 +78,7 @@ func Test_Messages(t *testing.T) { teatest.WithDuration(time.Second*3), ) - tm.Send(test.GetState()) + tm.Send(*test.GetState()) tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, From 233968d130b4e918bd50e5639bc7f1a7c6bfd619 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 19 Nov 2024 14:35:37 -0500 Subject: [PATCH 16/55] test(ui): confirm modal 100% coverage --- ui/modals/confirm/confirm_test.go | 78 +++++++++++++++++++ .../testdata/Test_Snapshot/NoKey.golden | 1 + .../testdata/Test_Snapshot/Visible.golden | 9 +++ 3 files changed, 88 insertions(+) create mode 100644 ui/modals/confirm/confirm_test.go create mode 100644 ui/modals/confirm/testdata/Test_Snapshot/NoKey.golden create mode 100644 ui/modals/confirm/testdata/Test_Snapshot/Visible.golden diff --git a/ui/modals/confirm/confirm_test.go b/ui/modals/confirm/confirm_test.go new file mode 100644 index 00000000..a28c922d --- /dev/null +++ b/ui/modals/confirm/confirm_test.go @@ -0,0 +1,78 @@ +package confirm + +import ( + "bytes" + "github.com/algorandfoundation/hack-tui/ui/test" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +func Test_New(t *testing.T) { + m := New(test.GetState()) + if m.ActiveKey != nil { + t.Errorf("expected ActiveKey to be nil") + } + m.ActiveKey = &test.Keys[0] + // Handle Delete + m, cmd := m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("y"), + }) + + if cmd == nil { + t.Errorf("expected cmd to be non-nil") + } +} +func Test_Snapshot(t *testing.T) { + t.Run("NoKey", func(t *testing.T) { + model := New(test.GetState()) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Visible", func(t *testing.T) { + model := New(test.GetState()) + model.ActiveKey = &test.Keys[0] + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + // Create the Model + m := New(test.GetState()) + m.ActiveKey = &test.Keys[0] + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("Are you sure you want to delete this key from your node?")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(*test.GetState()) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("n"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/modals/confirm/testdata/Test_Snapshot/NoKey.golden b/ui/modals/confirm/testdata/Test_Snapshot/NoKey.golden new file mode 100644 index 00000000..eacdfa5a --- /dev/null +++ b/ui/modals/confirm/testdata/Test_Snapshot/NoKey.golden @@ -0,0 +1 @@ +No key selected \ No newline at end of file diff --git a/ui/modals/confirm/testdata/Test_Snapshot/Visible.golden b/ui/modals/confirm/testdata/Test_Snapshot/Visible.golden new file mode 100644 index 00000000..290e2e59 --- /dev/null +++ b/ui/modals/confirm/testdata/Test_Snapshot/Visible.golden @@ -0,0 +1,9 @@ + + Are you sure you want to delete this key from your node? + + Account Address: + ABC + + Participation Key: + 123 + \ No newline at end of file From 4e4e9d91fdf8cab62829ef4b83eeb0be1e0f5f18 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 19 Nov 2024 14:38:52 -0500 Subject: [PATCH 17/55] test(ui): exception modal 100% coverage --- ui/modals/exception/error_test.go | 49 +++++++++++++++++++ .../testdata/Test_Snapshot/Visible.golden | 1 + 2 files changed, 50 insertions(+) create mode 100644 ui/modals/exception/error_test.go create mode 100644 ui/modals/exception/testdata/Test_Snapshot/Visible.golden diff --git a/ui/modals/exception/error_test.go b/ui/modals/exception/error_test.go new file mode 100644 index 00000000..99bb9c2b --- /dev/null +++ b/ui/modals/exception/error_test.go @@ -0,0 +1,49 @@ +package exception + +import ( + "bytes" + "errors" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +func Test_Snapshot(t *testing.T) { + t.Run("Visible", func(t *testing.T) { + model := New("Something went wrong") + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + // Create the Model + m := New("Something went wrong") + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("Something went wrong")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(errors.New("Something else went wrong")) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/modals/exception/testdata/Test_Snapshot/Visible.golden b/ui/modals/exception/testdata/Test_Snapshot/Visible.golden new file mode 100644 index 00000000..72f8f900 --- /dev/null +++ b/ui/modals/exception/testdata/Test_Snapshot/Visible.golden @@ -0,0 +1 @@ +Something went wrong \ No newline at end of file From 79628d2a85273e28e567274a7554243eeabb7feb Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 19 Nov 2024 15:13:00 -0500 Subject: [PATCH 18/55] test(ui): info modal 100% coverage --- ui/modals/info/info_test.go | 82 +++++++++++++++++++ .../info/testdata/Test_Snapshot/NoKey.golden | 1 + .../testdata/Test_Snapshot/Visible.golden | 8 ++ ui/test/fixtures.go | 6 +- 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 ui/modals/info/info_test.go create mode 100644 ui/modals/info/testdata/Test_Snapshot/NoKey.golden create mode 100644 ui/modals/info/testdata/Test_Snapshot/Visible.golden diff --git a/ui/modals/info/info_test.go b/ui/modals/info/info_test.go new file mode 100644 index 00000000..b636990a --- /dev/null +++ b/ui/modals/info/info_test.go @@ -0,0 +1,82 @@ +package info + +import ( + "bytes" + "github.com/algorandfoundation/hack-tui/ui/test" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +func Test_New(t *testing.T) { + m := New(test.GetState()) + if m == nil { + t.Fatal("New returned nil") + } + m.Participation = &test.Keys[0] + account := m.State.Accounts[test.Keys[0].Address] + account.Status = "Online" + m.State.Accounts[test.Keys[0].Address] = account + m.Active = true + m.UpdateState() + if m.BorderColor != "1" { + t.Error("State is not correct, border should be 1") + } + if m.Controls != "( take (o)ffline )" { + t.Error("Controls are not correct") + } +} +func Test_Snapshot(t *testing.T) { + t.Run("Visible", func(t *testing.T) { + model := New(test.GetState()) + model.Participation = &test.Keys[0] + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("NoKey", func(t *testing.T) { + model := New(test.GetState()) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + // Create the Model + m := New(test.GetState()) + m.Participation = &test.Keys[0] + + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("Account: ABC")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("o"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("d"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/modals/info/testdata/Test_Snapshot/NoKey.golden b/ui/modals/info/testdata/Test_Snapshot/NoKey.golden new file mode 100644 index 00000000..eacdfa5a --- /dev/null +++ b/ui/modals/info/testdata/Test_Snapshot/NoKey.golden @@ -0,0 +1 @@ +No key selected \ No newline at end of file diff --git a/ui/modals/info/testdata/Test_Snapshot/Visible.golden b/ui/modals/info/testdata/Test_Snapshot/Visible.golden new file mode 100644 index 00000000..b1199d3d --- /dev/null +++ b/ui/modals/info/testdata/Test_Snapshot/Visible.golden @@ -0,0 +1,8 @@ +Account: ABC +Participation ID: 123 +Selection Key: VEVTVEtFWQ +Vote Key: VEVTVEtFWQ +State Proof Key: VEVTVEtFWQ +Vote First Valid: 0 +Vote Last Valid: 30000 +Vote Key Dilution: 100 \ No newline at end of file diff --git a/ui/test/fixtures.go b/ui/test/fixtures.go index 688eb09b..c8c471ed 100644 --- a/ui/test/fixtures.go +++ b/ui/test/fixtures.go @@ -8,6 +8,8 @@ import ( ) var VoteKey = []byte("TESTKEY") +var SelectionKey = []byte("TESTKEY") +var StateProofKey = []byte("TESTKEY") var Keys = []api.ParticipationKey{ { Address: "ABC", @@ -15,8 +17,8 @@ var Keys = []api.ParticipationKey{ EffectiveLastValid: nil, Id: "123", Key: api.AccountParticipation{ - SelectionParticipationKey: nil, - StateProofKey: nil, + SelectionParticipationKey: SelectionKey, + StateProofKey: &StateProofKey, VoteFirstValid: 0, VoteKeyDilution: 100, VoteLastValid: 30000, From 53ca01cd317780ead6f378cc9796ee2860d2f61c Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 19 Nov 2024 16:02:29 -0500 Subject: [PATCH 19/55] test(ui): transaction modal increase coverage --- .../testdata/Test_Snapshot/Loading.golden | 1 + .../testdata/Test_Snapshot/NoKey.golden | 1 + .../testdata/Test_Snapshot/NotVisible.golden | 1 + .../testdata/Test_Snapshot/Offline.golden | 17 ++++ .../testdata/Test_Snapshot/Online.golden | 27 ++++++ ui/modals/transaction/transaction_test.go | 97 +++++++++++++++++++ ui/modals/transaction/view.go | 1 + 7 files changed, 145 insertions(+) create mode 100644 ui/modals/transaction/testdata/Test_Snapshot/Loading.golden create mode 100644 ui/modals/transaction/testdata/Test_Snapshot/NoKey.golden create mode 100644 ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden create mode 100644 ui/modals/transaction/testdata/Test_Snapshot/Offline.golden create mode 100644 ui/modals/transaction/testdata/Test_Snapshot/Online.golden create mode 100644 ui/modals/transaction/transaction_test.go diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Loading.golden b/ui/modals/transaction/testdata/Test_Snapshot/Loading.golden new file mode 100644 index 00000000..37d70925 --- /dev/null +++ b/ui/modals/transaction/testdata/Test_Snapshot/Loading.golden @@ -0,0 +1 @@ +Loading... \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/NoKey.golden b/ui/modals/transaction/testdata/Test_Snapshot/NoKey.golden new file mode 100644 index 00000000..eacdfa5a --- /dev/null +++ b/ui/modals/transaction/testdata/Test_Snapshot/NoKey.golden @@ -0,0 +1 @@ +No key selected \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden b/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden new file mode 100644 index 00000000..d5be7b34 --- /dev/null +++ b/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden @@ -0,0 +1 @@ +QR Code too large to display... Please adjust terminal dimensions or font. \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden b/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden new file mode 100644 index 00000000..83505f8a --- /dev/null +++ b/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden @@ -0,0 +1,17 @@ +█████████████████████████████████ +██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ +██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ +██ █▄▄▄█ █▄ ▄▀▄▄▀▀ █ ▄█ █▄▄▄█ ██ +██▄▄▄▄▄▄▄█▄▀▄▀ █▄█ ▀▄▀▄█▄▄▄▄▄▄▄██ +██ ▀ █ ▀▄██▀█ ▀▀██ ▀██▀██▄█▀ ▀██ +██▄ ▀▄██▄ ▀▄ ▄▀▀ ▀▀▀█▀█▀ ▄▄█▄ ██ +██ ▄▀▄ █▄ ██▀▄█ █ ▄▀██ ▄█▄ █ ██ +██▀▄▀▀█▀▄▀▀▀ ██▄██▄▀ ▀▀█ ▄▀██ ▄██ +███▀██▀▄▄ ▄▀▀▀▄▀██▀ ▀▄██ ▀█▀█ ▀██ +██▄▀ ▀▄▄▄ ▄ █▀▀ ▀█▀▄▀▀▄▀▄▀▄▄▄▄██ +██▄█▄▄██▄█▀█▀███ ▄▀ █▀ ▄▄▄ █▀▀ ██ +██ ▄▄▄▄▄ ██▄█▄█▄█▀▀▀▀▄ █▄█ ██▄ ██ +██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ +██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ +██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ + \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Online.golden b/ui/modals/transaction/testdata/Test_Snapshot/Online.golden new file mode 100644 index 00000000..7d356f9e --- /dev/null +++ b/ui/modals/transaction/testdata/Test_Snapshot/Online.golden @@ -0,0 +1,27 @@ +█████████████████████████████████████████████████████ +██ ▄▄▄▄▄ █▀ █▄▀█▀ ▀▄ ▄ █▄ ▄█▄ ▄█▄ ▄▄▀▀ █ ▄▄▄▄▄ ██ +██ █ █ ███▀ ▀▄█▄▀█▀▄▄ ▄▄▄█▄▀▄██ █▀█ ▄█ █ █ ██ +██ █▄▄▄█ █▄ ▀ ██▀█▄ ██▄█ ▄▄▄ ███ █▄ ▄ ▀███ █▄▄▄█ ██ +██▄▄▄▄▄▄▄█▄█ ▀▄█ █▄█ ▀▄▀ █▄█ █▄█▄▀ █▄▀ █▄█ █▄▄▄▄▄▄▄██ +██▄▀▄█▄█▄██▄▀ ▀█▀ ▀██▄█▄▄▄▄ ▄ ▄▄▄ ███▄██ █▀█▀▄██ ▀██ +██▀▀ ███▄▀▀ ▀▄██ ▄▀ ██▄▄▀▄▄█ ▄██▀ █▀ █▄▀▄ █▄ ▄▄ ▄██ +██▄ █▄█▄▄█▀▄▀▄▄█ ▀▀ ██ ▄▀ █▄ ▄▄▄▀▄▀▄▄ █▄▄█▄▄█▄██ ▀██ +██▄ ▀ █▀▄▀▀██▄█▄▄ ▀▄█▀█▀ ▄▄▄▀▄█▀▀▄▄▀ █▀█▄▀██▄██▄▄▄██ +██▀▄▄▀▀ ▄██▀▄▄ ▀█▀█▀▄▀█▀▄ ▄▄▄ ▀█▀ ▀█▀ ▀▀▄▄▄██▄▄█▀█ ██ +██▀█ ▀█▀▄▀█ █ ▄ ▄█▄▀▀ ▄██▄ ▄ ▄█▀▄▄▀█▄▄▀▄▀█▄▀▄ ▀█▄▄ ██ +██▀▄ █ ▄▀▄▀▀▀▀▄▀▀ ▀▄████▄▄█▀▄▄▀▄▄ ▄▄█▄▄█▀█▀▄ ▀▀ ▀██ +██ ▀▄▀ ▄▄▄ ▀▀██ ▀ ▀ ██▄ ▄▄▄ ▄█▀▀ ▀▀█▄██▄█ ▄▄▄ ▄█ ▄██ +██ █ █▄█ █▄█ ▄██ ▀▀▄██ █▄█ ▄▄ ▄ ▄█▄ ▄▄█ █▄█ ██ ██ +███ ▄ ▄ ▄▄▄ ▀▀▀ ▄▀▀█▄██▀ ▄▄ ▄▄ ▀▄ █▄ ▄█▄▄ ▄██▄▄██ +██▀█▀ █ ▄▀▀ ▀▀█ █ ▀██ ▄▀██ ▄█▄▄▄██▄▀▄█ ▄▄▀▄▄█▄█▄█ ██ +██▄ ██▄▄▄█▄▀█ ▀█▄▀▀ ▄▀██▀ ██▀██▄▀██▄▀██ █ █▄▄ ██ +█████▄ ▄ ▄█ ▀▀ ▀ ▄▄▄▄▀ ▄▄ █▄ ▄█▄ ▄█▄▀▄█ ▀ ███ ▀██ +██▄ ▄ ▄ ▄▄ ▄██▄▄▄ ▄█▀ █▄ ▄█ ▄▄▀ ▄▄█ ▀▄▀▀ █▄▄█ ██ +██▀██▄▀▀▄█▀█▄▄▀▀ ▀█▄▀▄█▀▀▄█ █▄██ ▄▀▄▄▄█ ▄▄ ███▄ ██ +███ ▀▀█▄▄█▄█ █ █▀█▀ ▄██ █▄▄▄▄▀█▄▀ █▄▄█▄██▄▄ ▄▄▄ ██ +██▄▄▄███▄█▀ ▀█▀▄ ▀▄▀█▀ ▄▄▄ ▄▄ █▄ █▄▀ ▄▄▄ █ ▀██ +██ ▄▄▄▄▄ ██▄ ▄ ▄▄ ▀▀▄█ █▄█ ▄ ▄▄█ ▄█ ▄▀ █ █▄█ ▄█▄ ██ +██ █ █ █▄▀▀ ██ █▀█ ▀ ▄ ▄▄▄█▄██ ▄██ ▄▄▄ ▄▀█ ██ +██ █▄▄▄█ █▄▀▀▄ ▄▀▀█ █▀ ▄ ▄ ▄▄▀ █▄▀▀▄█▀█ ██▄ █▄ ▄██ +██▄▄▄▄▄▄▄█▄▄█▄██▄▄███▄▄▄▄██▄▄▄███▄███▄████▄▄█▄█▄█▄▄██ + \ No newline at end of file diff --git a/ui/modals/transaction/transaction_test.go b/ui/modals/transaction/transaction_test.go new file mode 100644 index 00000000..88ccdd61 --- /dev/null +++ b/ui/modals/transaction/transaction_test.go @@ -0,0 +1,97 @@ +package transaction + +import ( + "bytes" + "github.com/algorandfoundation/hack-tui/ui/test" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +func Test_New(t *testing.T) { + model := New(test.GetState()) + model.Participation = &test.Keys[0] + model.Participation.Address = "ALGO123456789" + addr := model.FormatedAddress() + if addr != "ALGO...6789" { + t.Errorf("Expected ALGO123456789, got %s", addr) + } + model.Participation.Address = "ABC" +} +func Test_Snapshot(t *testing.T) { + t.Run("NotVisible", func(t *testing.T) { + model := New(test.GetState()) + model.Participation = &test.Keys[0] + model.UpdateState() + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Offline", func(t *testing.T) { + model := New(test.GetState()) + model.Participation = &test.Keys[0] + model, _ = model.HandleMessage(tea.WindowSizeMsg{ + Height: 40, + Width: 80, + }) + model.Active = true + model.UpdateState() + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Online", func(t *testing.T) { + model := New(test.GetState()) + model.Participation = &test.Keys[0] + model, _ = model.HandleMessage(tea.WindowSizeMsg{ + Height: 40, + Width: 80, + }) + model.UpdateState() + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + + t.Run("Loading", func(t *testing.T) { + model := New(test.GetState()) + model.Participation = &test.Keys[0] + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("NoKey", func(t *testing.T) { + model := New(test.GetState()) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + // Create the Model + m := New(test.GetState()) + m.Participation = &test.Keys[0] + + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("████████")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/modals/transaction/view.go b/ui/modals/transaction/view.go index 4ad4ad4f..9c97b6c3 100644 --- a/ui/modals/transaction/view.go +++ b/ui/modals/transaction/view.go @@ -13,6 +13,7 @@ func (m ViewModel) View() string { if m.ATxn == nil { return "Loading..." } + // TODO: Refactor ATxn to Interface txn, err := m.ATxn.ProduceQRCode() if err != nil { return "Something went wrong" From 7e1a067653fb73e49cf7164a587080350a89090b Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 19 Nov 2024 16:24:36 -0500 Subject: [PATCH 20/55] test(ui): generate modal 100% coverage --- ui/modals/generate/controller.go | 2 - ui/modals/generate/generate_test.go | 150 ++++++++++++++++++ ui/modals/generate/style.go | 5 - .../testdata/Test_Snapshot/Duration.golden | 6 + .../testdata/Test_Snapshot/Visible.golden | 6 + .../testdata/Test_Snapshot/Waiting.golden | 2 + ui/modals/generate/view.go | 6 +- 7 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 ui/modals/generate/generate_test.go create mode 100644 ui/modals/generate/testdata/Test_Snapshot/Duration.golden create mode 100644 ui/modals/generate/testdata/Test_Snapshot/Visible.golden create mode 100644 ui/modals/generate/testdata/Test_Snapshot/Waiting.golden diff --git a/ui/modals/generate/controller.go b/ui/modals/generate/controller.go index 719b5989..1ee5aa11 100644 --- a/ui/modals/generate/controller.go +++ b/ui/modals/generate/controller.go @@ -16,9 +16,7 @@ func (m ViewModel) Init() tea.Cmd { func (m ViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.HandleMessage(msg) } -func (m ViewModel) SetInterval(dur time.Duration) { -} func (m *ViewModel) SetStep(step Step) { m.Step = step switch m.Step { diff --git a/ui/modals/generate/generate_test.go b/ui/modals/generate/generate_test.go new file mode 100644 index 00000000..c484b338 --- /dev/null +++ b/ui/modals/generate/generate_test.go @@ -0,0 +1,150 @@ +package generate + +import ( + "bytes" + "github.com/algorandfoundation/hack-tui/ui/test" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +func Test_New(t *testing.T) { + m := New("ABC", test.GetState()) + + m.SetAddress("ACB") + + if m.Address != "ABC" { + t.Error("Did not set address") + } + + m.SetStep(AddressStep) + if m.Step != AddressStep { + t.Error("Did not advance to address step") + } + if m.Controls != "( esc to cancel )" { + t.Error("Did not set controls") + } + + m.SetStep(DurationStep) + m.InputTwo.SetValue("1") + + m, cmd := m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + if cmd == nil { + t.Error("Did not return the generate command") + } + if m.Step != WaitingStep { + t.Error("Did not advance to waiting step") + } + + m.SetStep(DurationStep) + m.Range = Week + m, cmd = m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + if cmd == nil { + t.Error("Did not return the generate command") + } + + m.SetStep(DurationStep) + m.Range = Month + m, cmd = m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + if cmd == nil { + t.Error("Did not return the generate command") + } + + m.SetStep(DurationStep) + m.Range = Year + m, cmd = m.HandleMessage(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + if cmd == nil { + t.Error("Did not return the generate command") + } +} + +func Test_Snapshot(t *testing.T) { + t.Run("Visible", func(t *testing.T) { + model := New("ABC", test.GetState()) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Duration", func(t *testing.T) { + model := New("ABC", test.GetState()) + model.SetStep(DurationStep) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Waiting", func(t *testing.T) { + model := New("ABC", test.GetState()) + model.SetStep(WaitingStep) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + // Create the Model + m := New("ABC", test.GetState()) + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("Create keys required to participate in Algorand consensus.")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + // Enter into duration mode + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("enter"), + }) + + // Rotate the durations + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("s"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("s"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("s"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("s"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("1"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/modals/generate/style.go b/ui/modals/generate/style.go index 8783d051..25ff905e 100644 --- a/ui/modals/generate/style.go +++ b/ui/modals/generate/style.go @@ -1,16 +1,11 @@ package generate import ( - "fmt" "github.com/charmbracelet/lipgloss" ) var ( focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) cursorStyle = focusedStyle noStyle = lipgloss.NewStyle() - - focusedButton = focusedStyle.Render("[ Submit ]") - blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Submit")) ) diff --git a/ui/modals/generate/testdata/Test_Snapshot/Duration.golden b/ui/modals/generate/testdata/Test_Snapshot/Duration.golden new file mode 100644 index 00000000..cc1ad542 --- /dev/null +++ b/ui/modals/generate/testdata/Test_Snapshot/Duration.golden @@ -0,0 +1,6 @@ + +How long should the keys be valid for? + +Duration in days: +> Length of time + \ No newline at end of file diff --git a/ui/modals/generate/testdata/Test_Snapshot/Visible.golden b/ui/modals/generate/testdata/Test_Snapshot/Visible.golden new file mode 100644 index 00000000..a6014f05 --- /dev/null +++ b/ui/modals/generate/testdata/Test_Snapshot/Visible.golden @@ -0,0 +1,6 @@ + +Create keys required to participate in Algorand consensus. + +Account address: +> Wallet Address + \ No newline at end of file diff --git a/ui/modals/generate/testdata/Test_Snapshot/Waiting.golden b/ui/modals/generate/testdata/Test_Snapshot/Waiting.golden new file mode 100644 index 00000000..344691eb --- /dev/null +++ b/ui/modals/generate/testdata/Test_Snapshot/Waiting.golden @@ -0,0 +1,2 @@ +Generating Participation Keys... +Please wait. This operation can take a few minutes. \ No newline at end of file diff --git a/ui/modals/generate/view.go b/ui/modals/generate/view.go index acf98a07..468cf0c0 100644 --- a/ui/modals/generate/view.go +++ b/ui/modals/generate/view.go @@ -7,7 +7,6 @@ import ( func (m ViewModel) View() string { render := "" - //m.Input.Focus() switch m.Step { case AddressStep: render = lipgloss.JoinVertical(lipgloss.Left, @@ -33,8 +32,5 @@ func (m ViewModel) View() string { "Please wait. This operation can take a few minutes.") } - if lipgloss.Width(render) < 70 { - return lipgloss.NewStyle().Width(70).Render(render) - } - return render + return lipgloss.NewStyle().Width(70).Render(render) } From 8031447cc2e88b6184257469d009fa13347ec5bc Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 19 Nov 2024 16:50:17 -0500 Subject: [PATCH 21/55] test(ui): overlay 100% coverage --- ui/modal/modal_test.go | 192 ++++++++++++++++++ .../Test_Snapshot/ConfirmModal.golden | 80 ++++++++ .../Test_Snapshot/ExceptionModal.golden | 80 ++++++++ .../Test_Snapshot/GenerateModal.golden | 80 ++++++++ .../testdata/Test_Snapshot/InfoModal.golden | 80 ++++++++ ui/modal/testdata/Test_Snapshot/NoKey.golden | 80 ++++++++ .../Test_Snapshot/TransactionModal.golden | 80 ++++++++ 7 files changed, 672 insertions(+) create mode 100644 ui/modal/modal_test.go create mode 100644 ui/modal/testdata/Test_Snapshot/ConfirmModal.golden create mode 100644 ui/modal/testdata/Test_Snapshot/ExceptionModal.golden create mode 100644 ui/modal/testdata/Test_Snapshot/GenerateModal.golden create mode 100644 ui/modal/testdata/Test_Snapshot/InfoModal.golden create mode 100644 ui/modal/testdata/Test_Snapshot/NoKey.golden create mode 100644 ui/modal/testdata/Test_Snapshot/TransactionModal.golden diff --git a/ui/modal/modal_test.go b/ui/modal/modal_test.go new file mode 100644 index 00000000..ebb04733 --- /dev/null +++ b/ui/modal/modal_test.go @@ -0,0 +1,192 @@ +package modal + +import ( + "bytes" + "errors" + "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/test" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/exp/teatest" + "testing" + "time" +) + +func Test_Snapshot(t *testing.T) { + t.Run("NoKey", func(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("InfoModal", func(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model.SetKey(&test.Keys[0]) + model.SetType(app.InfoModal) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("ConfirmModal", func(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model.SetKey(&test.Keys[0]) + model.SetType(app.ConfirmModal) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("ExceptionModal", func(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model.SetKey(&test.Keys[0]) + model.SetType(app.ExceptionModal) + model, _ = model.HandleMessage(errors.New("test error")) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("GenerateModal", func(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model.SetKey(&test.Keys[0]) + model.SetAddress("ABC") + model.SetType(app.GenerateModal) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + + t.Run("TransactionModal", func(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model.SetKey(&test.Keys[0]) + model.SetActive(true) + model.SetType(app.TransactionModal) + model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) +} + +func Test_Messages(t *testing.T) { + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model.SetKey(&test.Keys[0]) + model.SetAddress("ABC") + model.SetType(app.InfoModal) + tm := teatest.NewTestModel( + t, model, + teatest.WithInitialTermSize(80, 40), + ) + + // Wait for prompt to exit + teatest.WaitFor( + t, tm.Output(), + func(bts []byte) bool { + return bytes.Contains(bts, []byte("State Proof Key: VEVTVEtFWQ")) + }, + teatest.WithCheckInterval(time.Millisecond*100), + teatest.WithDuration(time.Second*3), + ) + + tm.Send(errors.New("Something else went wrong")) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("d"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("o"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("esc"), + }) + + tm.Send(app.InfoModal) + + tm.Send(app.DeleteFinished{ + Err: nil, + Id: test.Keys[0].Id, + }) + + delError := errors.New("Something went wrong") + tm.Send(app.DeleteFinished{ + Err: &delError, + Id: "", + }) + + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.InfoModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.CancelModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.GenerateModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.CancelModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.ConfirmModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.CancelModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.TransactionModal, + }) + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.CancelModal, + }) + + tm.Send(app.ModalEvent{ + Key: nil, + Active: false, + Address: "ABC", + Err: nil, + Type: app.CloseModal, + }) + tm.Send(tea.QuitMsg{}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} diff --git a/ui/modal/testdata/Test_Snapshot/ConfirmModal.golden b/ui/modal/testdata/Test_Snapshot/ConfirmModal.golden new file mode 100644 index 00000000..e78113bb --- /dev/null +++ b/ui/modal/testdata/Test_Snapshot/ConfirmModal.golden @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──Delete Key────────────────────────────────────────────────╮ + │ │ + │ Are you sure you want to delete this key from your node? │ + │ │ + │ Account Address: │ + │ ABC │ + │ │ + │ Participation Key: │ + │ 123 │ + │ │ + ╰────────────────────────────────────────( (y)es | (n)o )────╯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/modal/testdata/Test_Snapshot/ExceptionModal.golden b/ui/modal/testdata/Test_Snapshot/ExceptionModal.golden new file mode 100644 index 00000000..5068e929 --- /dev/null +++ b/ui/modal/testdata/Test_Snapshot/ExceptionModal.golden @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──Error─────╮ + │ test error │ + ╰─( esc )────╯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/modal/testdata/Test_Snapshot/GenerateModal.golden b/ui/modal/testdata/Test_Snapshot/GenerateModal.golden new file mode 100644 index 00000000..5f23d044 --- /dev/null +++ b/ui/modal/testdata/Test_Snapshot/GenerateModal.golden @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──Generate Participation Keys───────────────────────────────────────────╮ + │ │ + │ Create keys required to participate in Algorand consensus. │ + │ │ + │ Account address: │ + │ > ABC │ + │ │ + ╰───────────────────────────────────────────────────( esc to cancel )────╯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/modal/testdata/Test_Snapshot/InfoModal.golden b/ui/modal/testdata/Test_Snapshot/InfoModal.golden new file mode 100644 index 00000000..1fa9902e --- /dev/null +++ b/ui/modal/testdata/Test_Snapshot/InfoModal.golden @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──Key Information────────────╮ + │ Account: ABC │ + │ Participation ID: 123 │ + │ Selection Key: VEVTVEtFWQ │ + │ Vote Key: VEVTVEtFWQ │ + │ State Proof Key: VEVTVEtFWQ │ + │ Vote First Valid: 0 │ + │ Vote Last Valid: 30000 │ + │ Vote Key Dilution: 100 │ + ╰──( (d)elete | (o)nline )────╯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/modal/testdata/Test_Snapshot/NoKey.golden b/ui/modal/testdata/Test_Snapshot/NoKey.golden new file mode 100644 index 00000000..6fc9abbd --- /dev/null +++ b/ui/modal/testdata/Test_Snapshot/NoKey.golden @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭─────────────────╮ + │ No key selected │ + ╰─────────────────╯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/modal/testdata/Test_Snapshot/TransactionModal.golden b/ui/modal/testdata/Test_Snapshot/TransactionModal.golden new file mode 100644 index 00000000..8cd8aa09 --- /dev/null +++ b/ui/modal/testdata/Test_Snapshot/TransactionModal.golden @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──Offline Transaction──────────────╮ + │ █████████████████████████████████ │ + │ ██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ │ + │ ██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ │ + │ ██ █▄▄▄█ █▄ ▄▀▄▄▀▀ █ ▄█ █▄▄▄█ ██ │ + │ ██▄▄▄▄▄▄▄█▄▀▄▀ █▄█ ▀▄▀▄█▄▄▄▄▄▄▄██ │ + │ ██ ▀ █ ▀▄██▀█ ▀▀██ ▀██▀██▄█▀ ▀██ │ + │ ██▄ ▀▄██▄ ▀▄ ▄▀▀ ▀▀▀█▀█▀ ▄▄█▄ ██ │ + │ ██ ▄▀▄ █▄ ██▀▄█ █ ▄▀██ ▄█▄ █ ██ │ + │ ██▀▄▀▀█▀▄▀▀▀ ██▄██▄▀ ▀▀█ ▄▀██ ▄██ │ + │ ███▀██▀▄▄ ▄▀▀▀▄▀██▀ ▀▄██ ▀█▀█ ▀██ │ + │ ██▄▀ ▀▄▄▄ ▄ █▀▀ ▀█▀▄▀▀▄▀▄▀▄▄▄▄██ │ + │ ██▄█▄▄██▄█▀█▀███ ▄▀ █▀ ▄▄▄ █▀▀ ██ │ + │ ██ ▄▄▄▄▄ ██▄█▄█▄█▀▀▀▀▄ █▄█ ██▄ ██ │ + │ ██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ │ + │ ██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ │ + │ ██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ │ + │ │ + ╰────────────────────────( esc )────╯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 7abda83851b1acd1565f88d9cae32c52d2d3f8f8 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 19 Nov 2024 17:00:37 -0500 Subject: [PATCH 22/55] test(ui): increase style coverage --- ui/style/style_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 ui/style/style_test.go diff --git a/ui/style/style_test.go b/ui/style/style_test.go new file mode 100644 index 00000000..8d3d7dad --- /dev/null +++ b/ui/style/style_test.go @@ -0,0 +1,20 @@ +package style + +import "testing" + +func Test_WithStyles(t *testing.T) { + testStr := Red.Render("Amazing") + Green.Render("World") + render := WithControls("", testStr) + if render != testStr { + t.Error("Should be empty") + } + render = WithOverlay("", testStr) + if render != testStr { + t.Error("Should be empty") + } + + render = TruncateLeft(testStr, 7) + if render != "World" { + t.Error("Should be World") + } +} From acfbb105dd8c2e2716c39db35848c9600fbc8148 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 20 Nov 2024 12:24:53 -0500 Subject: [PATCH 23/55] test(ui): utils 100% coverage --- ui/utils/utils.go | 15 --------------- ui/utils/utils_test.go | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+), 15 deletions(-) create mode 100644 ui/utils/utils_test.go diff --git a/ui/utils/utils.go b/ui/utils/utils.go index 6415e64b..af9d89fa 100644 --- a/ui/utils/utils.go +++ b/ui/utils/utils.go @@ -5,23 +5,8 @@ import ( "fmt" ) -func IntPtrToZero(num *int) int { - if num == nil { - return 0 - } - return *num -} - func toPtr[T any](constVar T) *T { return &constVar } -func toPtrOrNil[T comparable](comparable T) *T { - var zero T - if comparable == zero { - return nil - } - return &comparable -} - func UrlEncodeBytesPtrOrNil(b []byte) *string { if b == nil || len(b) == 0 || isZeros(b) { return nil diff --git a/ui/utils/utils_test.go b/ui/utils/utils_test.go new file mode 100644 index 00000000..4c7e3e70 --- /dev/null +++ b/ui/utils/utils_test.go @@ -0,0 +1,21 @@ +package utils + +import "testing" + +func Test_Utils(t *testing.T) { + res := UrlEncodeBytesPtrOrNil(nil) + if res != nil { + t.Error("UrlEncodeBytesPtrOrNil was not nil") + } + + zeros := isZeros([]byte("")) + if !zeros { + t.Error("isZeros was not true") + } + val := 5 + str := StrOrNA(&val) + if str != "5" { + t.Error("StrOrNA was not 5") + } + +} From 4fc1270f90d8634ababde5e55d366bb46a61079f Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 20 Nov 2024 14:09:37 -0500 Subject: [PATCH 24/55] test: refactor to interface for client, increase coverage --- internal/accounts.go | 6 +- internal/accounts_test.go | 2 +- internal/block.go | 2 +- internal/metrics.go | 2 +- internal/participation.go | 10 +-- internal/state.go | 6 +- internal/status.go | 2 +- test/client.go | 84 +++++++++++++++++++++++ {ui/test => test}/fixtures.go | 7 +- {ui/test => test}/mock/clock.go | 0 ui/app/app_test.go | 70 +++++++++++++++++++ ui/app/keys.go | 2 +- ui/modal/modal_test.go | 16 ++--- ui/modals/confirm/confirm_test.go | 12 ++-- ui/modals/generate/generate_test.go | 12 ++-- ui/modals/info/info_test.go | 10 +-- ui/modals/transaction/transaction_test.go | 16 ++--- ui/pages/accounts/accounts_test.go | 10 +-- ui/pages/keys/keys_test.go | 4 +- ui/viewport.go | 4 +- ui/viewport_test.go | 52 ++++++++------ 21 files changed, 247 insertions(+), 82 deletions(-) create mode 100644 test/client.go rename {ui/test => test}/fixtures.go (91%) rename {ui/test => test}/mock/clock.go (100%) create mode 100644 ui/app/app_test.go diff --git a/internal/accounts.go b/internal/accounts.go index 6926ab5b..d7043d57 100644 --- a/internal/accounts.go +++ b/internal/accounts.go @@ -29,7 +29,7 @@ type Account struct { } // Gets the list of addresses created at genesis from the genesis file -func getAddressesFromGenesis(client *api.ClientWithResponses) ([]string, string, string, error) { +func getAddressesFromGenesis(client api.ClientInterface) ([]string, string, string, error) { resp, err := client.GetGenesis(context.Background()) if err != nil { return []string{}, "", "", err @@ -92,7 +92,7 @@ func getAddressesFromGenesis(client *api.ClientWithResponses) ([]string, string, } // Get Online Status of Account -func GetAccount(client *api.ClientWithResponses, address string) (api.Account, error) { +func GetAccount(client api.ClientWithResponsesInterface, address string) (api.Account, error) { var format api.AccountInformationParamsFormat = "json" r, err := client.AccountInformationWithResponse( context.Background(), @@ -125,7 +125,7 @@ func GetExpiresTime(t Time, key api.ParticipationKey, state *StateModel) time.Ti } // AccountsFromParticipationKeys maps an array of api.ParticipationKey to a keyed map of Account -func AccountsFromState(state *StateModel, t Time, client *api.ClientWithResponses) map[string]Account { +func AccountsFromState(state *StateModel, t Time, client api.ClientWithResponsesInterface) map[string]Account { values := make(map[string]Account) if state == nil || state.ParticipationKeys == nil { return values diff --git a/internal/accounts_test.go b/internal/accounts_test.go index 5ae63095..eccab4fb 100644 --- a/internal/accounts_test.go +++ b/internal/accounts_test.go @@ -2,7 +2,7 @@ package internal import ( "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/ui/test/mock" + "github.com/algorandfoundation/hack-tui/test/mock" "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" "github.com/stretchr/testify/assert" "testing" diff --git a/internal/block.go b/internal/block.go index 5af2fbae..e1365279 100644 --- a/internal/block.go +++ b/internal/block.go @@ -12,7 +12,7 @@ type BlockMetrics struct { TPS float64 } -func GetBlockMetrics(ctx context.Context, client *api.ClientWithResponses, round uint64, window int) (*BlockMetrics, error) { +func GetBlockMetrics(ctx context.Context, client api.ClientWithResponsesInterface, round uint64, window int) (*BlockMetrics, error) { var avgs = BlockMetrics{ AvgTime: 0, TPS: 0, diff --git a/internal/metrics.go b/internal/metrics.go index b10a9f40..f3283232 100644 --- a/internal/metrics.go +++ b/internal/metrics.go @@ -59,7 +59,7 @@ func parseMetricsContent(content string) (MetricsResponse, error) { } // GetMetrics parses the /metrics endpoint from algod into a map -func GetMetrics(ctx context.Context, client *api.ClientWithResponses) (MetricsResponse, error) { +func GetMetrics(ctx context.Context, client api.ClientWithResponsesInterface) (MetricsResponse, error) { res, err := client.MetricsWithResponse(ctx) if err != nil { return nil, err diff --git a/internal/participation.go b/internal/participation.go index f1db5ff8..199583ed 100644 --- a/internal/participation.go +++ b/internal/participation.go @@ -9,7 +9,7 @@ import ( ) // GetPartKeys get the participation keys from the node -func GetPartKeys(ctx context.Context, client *api.ClientWithResponses) (*[]api.ParticipationKey, error) { +func GetPartKeys(ctx context.Context, client api.ClientWithResponsesInterface) (*[]api.ParticipationKey, error) { parts, err := client.GetParticipationKeysWithResponse(ctx) if err != nil { return nil, err @@ -21,7 +21,7 @@ func GetPartKeys(ctx context.Context, client *api.ClientWithResponses) (*[]api.P } // ReadPartKey get a specific participation key by id -func ReadPartKey(ctx context.Context, client *api.ClientWithResponses, participationId string) (*api.ParticipationKey, error) { +func ReadPartKey(ctx context.Context, client api.ClientWithResponsesInterface, participationId string) (*api.ParticipationKey, error) { key, err := client.GetParticipationKeyByIDWithResponse(ctx, participationId) if err != nil { return nil, err @@ -36,7 +36,7 @@ func ReadPartKey(ctx context.Context, client *api.ClientWithResponses, participa // We should try to update the API endpoint func waitForNewKey( ctx context.Context, - client *api.ClientWithResponses, + client api.ClientWithResponsesInterface, keys *[]api.ParticipationKey, interval time.Duration, timeout time.Duration, @@ -83,7 +83,7 @@ func findKeyPair( // GenerateKeyPair creates a keypair and finds the result func GenerateKeyPair( ctx context.Context, - client *api.ClientWithResponses, + client api.ClientWithResponsesInterface, address string, params *api.GenerateParticipationKeysParams, ) (*api.ParticipationKey, error) { @@ -112,7 +112,7 @@ func GenerateKeyPair( } // DeletePartKey remove a key from the node -func DeletePartKey(ctx context.Context, client *api.ClientWithResponses, participationId string) error { +func DeletePartKey(ctx context.Context, client api.ClientWithResponsesInterface, participationId string) error { deletion, err := client.DeleteParticipationKeyByIDWithResponse(ctx, participationId) if err != nil { return err diff --git a/internal/state.go b/internal/state.go index 06b7e82b..96d1b8de 100644 --- a/internal/state.go +++ b/internal/state.go @@ -23,7 +23,7 @@ type StateModel struct { Watching bool // RPC - Client *api.ClientWithResponses + Client api.ClientWithResponsesInterface Context context.Context } @@ -36,7 +36,7 @@ func (s *StateModel) waitAfterError(err error, cb func(model *StateModel, err er } // TODO: allow context to handle loop -func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Context, client *api.ClientWithResponses) { +func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Context, client api.ClientWithResponsesInterface) { s.Watching = true if s.Metrics.Window == 0 { s.Metrics.Window = 100 @@ -97,7 +97,7 @@ func (s *StateModel) Stop() { s.Watching = false } -func (s *StateModel) UpdateMetricsFromRPC(ctx context.Context, client *api.ClientWithResponses) { +func (s *StateModel) UpdateMetricsFromRPC(ctx context.Context, client api.ClientWithResponsesInterface) { // Fetch RX/TX res, err := GetMetrics(ctx, client) if err != nil { diff --git a/internal/status.go b/internal/status.go index 8d8be308..6f73a8cf 100644 --- a/internal/status.go +++ b/internal/status.go @@ -41,7 +41,7 @@ func (m *StatusModel) Update(lastRound int, catchupTime int, upgradeNodeVote *bo } // Fetch handles algod.Status -func (m *StatusModel) Fetch(ctx context.Context, client *api.ClientWithResponses) error { +func (m *StatusModel) Fetch(ctx context.Context, client api.ClientWithResponsesInterface) error { if m.Version == "" || m.Version == "NA" { v, err := client.GetVersionWithResponse(ctx) if err != nil { diff --git a/test/client.go b/test/client.go new file mode 100644 index 00000000..ef1233ee --- /dev/null +++ b/test/client.go @@ -0,0 +1,84 @@ +package test + +import ( + "context" + "errors" + "github.com/algorandfoundation/hack-tui/api" + "net/http" +) + +func GetClient(throws bool) api.ClientWithResponsesInterface { + res, _ := NewTestClient(throws) + return res +} + +type TestClient struct { + api.ClientWithResponsesInterface + Errors bool +} + +func NewTestClient(throws bool) (api.ClientWithResponsesInterface, error) { + client := new(TestClient) + if throws { + client.Errors = true + } + return client, nil +} +func (c *TestClient) GetParticipationKeysWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.GetParticipationKeysResponse, error) { + httpResponse := http.Response{StatusCode: 200} + clone := Keys + res := api.GetParticipationKeysResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: &clone, + JSON400: nil, + JSON401: nil, + JSON404: nil, + JSON500: nil, + } + if c.Errors { + return nil, errors.New("test error") + } + return &res, nil +} + +func (c *TestClient) DeleteParticipationKeyByIDWithResponse(ctx context.Context, participationId string, reqEditors ...api.RequestEditorFn) (*api.DeleteParticipationKeyByIDResponse, error) { + httpResponse := http.Response{StatusCode: 200} + res := api.DeleteParticipationKeyByIDResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON400: nil, + JSON401: nil, + JSON404: nil, + JSON500: nil, + } + + if c.Errors { + return nil, errors.New("test error") + } + return &res, nil +} + +func (c *TestClient) GenerateParticipationKeysWithResponse(ctx context.Context, address string, params *api.GenerateParticipationKeysParams, reqEditors ...api.RequestEditorFn) (*api.GenerateParticipationKeysResponse, error) { + Keys = append(Keys, api.ParticipationKey{ + Address: "", + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "", + Key: api.AccountParticipation{}, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }) + httpResponse := http.Response{StatusCode: 200} + res := api.GenerateParticipationKeysResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: nil, + JSON400: nil, + JSON401: nil, + JSON500: nil, + } + + return &res, nil +} diff --git a/ui/test/fixtures.go b/test/fixtures.go similarity index 91% rename from ui/test/fixtures.go rename to test/fixtures.go index c8c471ed..c827cc0f 100644 --- a/ui/test/fixtures.go +++ b/test/fixtures.go @@ -1,9 +1,10 @@ package test import ( + "context" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" - "github.com/algorandfoundation/hack-tui/ui/test/mock" + "github.com/algorandfoundation/hack-tui/test/mock" "time" ) @@ -47,7 +48,7 @@ var Keys = []api.ParticipationKey{ }, } -func GetState() *internal.StateModel { +func GetState(client api.ClientWithResponsesInterface) *internal.StateModel { sm := &internal.StateModel{ Status: internal.StatusModel{ State: internal.StableState, @@ -72,6 +73,8 @@ func GetState() *internal.StateModel { ParticipationKeys: &Keys, Admin: false, Watching: false, + Client: client, + Context: context.Background(), } values := make(map[string]internal.Account) clock := new(mock.Clock) diff --git a/ui/test/mock/clock.go b/test/mock/clock.go similarity index 100% rename from ui/test/mock/clock.go rename to test/mock/clock.go diff --git a/ui/app/app_test.go b/ui/app/app_test.go new file mode 100644 index 00000000..b04da474 --- /dev/null +++ b/ui/app/app_test.go @@ -0,0 +1,70 @@ +package app + +import ( + "context" + test2 "github.com/algorandfoundation/hack-tui/test" + "net/http" + "testing" + "time" +) + +func Intercept(ctx context.Context, req *http.Request) error { + req.Response = &http.Response{} + return nil +} + +func Test_GenerateCmd(t *testing.T) { + client := test2.GetClient(false) + fn := GenerateCmd("ABC", time.Second*60, test2.GetState(client)) + res := fn() + evt, ok := res.(ModalEvent) + if !ok { + t.Error("Expected ModalEvent") + } + if evt.Type != InfoModal { + t.Error("Expected InfoModal") + } + + client = test2.GetClient(true) + fn = GenerateCmd("ABC", time.Second*60, test2.GetState(client)) + res = fn() + evt, ok = res.(ModalEvent) + if !ok { + t.Error("Expected ModalEvent") + } + if evt.Type != ExceptionModal { + t.Error("Expected ExceptionModal") + } + +} + +func Test_EmitDeleteKey(t *testing.T) { + client := test2.GetClient(false) + fn := EmitDeleteKey(context.Background(), client, "ABC") + res := fn() + evt, ok := res.(DeleteFinished) + if !ok { + t.Error("Expected DeleteFinished") + } + if evt.Id != "ABC" { + t.Error("Expected ABC") + } + if evt.Err != nil { + t.Error("Expected no errors") + } + + client = test2.GetClient(true) + fn = EmitDeleteKey(context.Background(), client, "ABC") + res = fn() + evt, ok = res.(DeleteFinished) + if !ok { + t.Error("Expected DeleteFinished") + } + if evt.Id != "" { + t.Error("Expected no response") + } + if evt.Err == nil { + t.Error("Expected errors") + } + +} diff --git a/ui/app/keys.go b/ui/app/keys.go index ab74bff5..34db1a3e 100644 --- a/ui/app/keys.go +++ b/ui/app/keys.go @@ -15,7 +15,7 @@ type DeleteFinished struct { type DeleteKey *api.ParticipationKey -func EmitDeleteKey(ctx context.Context, client *api.ClientWithResponses, id string) tea.Cmd { +func EmitDeleteKey(ctx context.Context, client api.ClientWithResponsesInterface, id string) tea.Cmd { return func() tea.Msg { err := internal.DeletePartKey(ctx, client, id) if err != nil { diff --git a/ui/modal/modal_test.go b/ui/modal/modal_test.go index ebb04733..a8c56ed5 100644 --- a/ui/modal/modal_test.go +++ b/ui/modal/modal_test.go @@ -3,8 +3,8 @@ package modal import ( "bytes" "errors" + "github.com/algorandfoundation/hack-tui/test" "github.com/algorandfoundation/hack-tui/ui/app" - "github.com/algorandfoundation/hack-tui/ui/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" @@ -16,14 +16,14 @@ import ( func Test_Snapshot(t *testing.T) { t.Run("NoKey", func(t *testing.T) { - model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) t.Run("InfoModal", func(t *testing.T) { - model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) model.SetKey(&test.Keys[0]) model.SetType(app.InfoModal) model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) @@ -31,7 +31,7 @@ func Test_Snapshot(t *testing.T) { golden.RequireEqual(t, []byte(got)) }) t.Run("ConfirmModal", func(t *testing.T) { - model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) model.SetKey(&test.Keys[0]) model.SetType(app.ConfirmModal) model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) @@ -39,7 +39,7 @@ func Test_Snapshot(t *testing.T) { golden.RequireEqual(t, []byte(got)) }) t.Run("ExceptionModal", func(t *testing.T) { - model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) model.SetKey(&test.Keys[0]) model.SetType(app.ExceptionModal) model, _ = model.HandleMessage(errors.New("test error")) @@ -47,7 +47,7 @@ func Test_Snapshot(t *testing.T) { golden.RequireEqual(t, []byte(got)) }) t.Run("GenerateModal", func(t *testing.T) { - model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) model.SetKey(&test.Keys[0]) model.SetAddress("ABC") model.SetType(app.GenerateModal) @@ -57,7 +57,7 @@ func Test_Snapshot(t *testing.T) { }) t.Run("TransactionModal", func(t *testing.T) { - model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) model.SetKey(&test.Keys[0]) model.SetActive(true) model.SetType(app.TransactionModal) @@ -68,7 +68,7 @@ func Test_Snapshot(t *testing.T) { } func Test_Messages(t *testing.T) { - model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState()) + model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) model.SetKey(&test.Keys[0]) model.SetAddress("ABC") model.SetType(app.InfoModal) diff --git a/ui/modals/confirm/confirm_test.go b/ui/modals/confirm/confirm_test.go index a28c922d..91bc6440 100644 --- a/ui/modals/confirm/confirm_test.go +++ b/ui/modals/confirm/confirm_test.go @@ -2,7 +2,7 @@ package confirm import ( "bytes" - "github.com/algorandfoundation/hack-tui/ui/test" + "github.com/algorandfoundation/hack-tui/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -12,7 +12,7 @@ import ( ) func Test_New(t *testing.T) { - m := New(test.GetState()) + m := New(test.GetState(nil)) if m.ActiveKey != nil { t.Errorf("expected ActiveKey to be nil") } @@ -29,12 +29,12 @@ func Test_New(t *testing.T) { } func Test_Snapshot(t *testing.T) { t.Run("NoKey", func(t *testing.T) { - model := New(test.GetState()) + model := New(test.GetState(nil)) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) t.Run("Visible", func(t *testing.T) { - model := New(test.GetState()) + model := New(test.GetState(nil)) model.ActiveKey = &test.Keys[0] model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) got := ansi.Strip(model.View()) @@ -44,7 +44,7 @@ func Test_Snapshot(t *testing.T) { func Test_Messages(t *testing.T) { // Create the Model - m := New(test.GetState()) + m := New(test.GetState(nil)) m.ActiveKey = &test.Keys[0] tm := teatest.NewTestModel( t, m, @@ -61,7 +61,7 @@ func Test_Messages(t *testing.T) { teatest.WithDuration(time.Second*3), ) - tm.Send(*test.GetState()) + tm.Send(*test.GetState(nil)) tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, diff --git a/ui/modals/generate/generate_test.go b/ui/modals/generate/generate_test.go index c484b338..062be3d0 100644 --- a/ui/modals/generate/generate_test.go +++ b/ui/modals/generate/generate_test.go @@ -2,7 +2,7 @@ package generate import ( "bytes" - "github.com/algorandfoundation/hack-tui/ui/test" + "github.com/algorandfoundation/hack-tui/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -12,7 +12,7 @@ import ( ) func Test_New(t *testing.T) { - m := New("ABC", test.GetState()) + m := New("ABC", test.GetState(nil)) m.SetAddress("ACB") @@ -75,18 +75,18 @@ func Test_New(t *testing.T) { func Test_Snapshot(t *testing.T) { t.Run("Visible", func(t *testing.T) { - model := New("ABC", test.GetState()) + model := New("ABC", test.GetState(nil)) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) t.Run("Duration", func(t *testing.T) { - model := New("ABC", test.GetState()) + model := New("ABC", test.GetState(nil)) model.SetStep(DurationStep) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) t.Run("Waiting", func(t *testing.T) { - model := New("ABC", test.GetState()) + model := New("ABC", test.GetState(nil)) model.SetStep(WaitingStep) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) @@ -95,7 +95,7 @@ func Test_Snapshot(t *testing.T) { func Test_Messages(t *testing.T) { // Create the Model - m := New("ABC", test.GetState()) + m := New("ABC", test.GetState(nil)) tm := teatest.NewTestModel( t, m, teatest.WithInitialTermSize(80, 40), diff --git a/ui/modals/info/info_test.go b/ui/modals/info/info_test.go index b636990a..21c429bb 100644 --- a/ui/modals/info/info_test.go +++ b/ui/modals/info/info_test.go @@ -2,7 +2,7 @@ package info import ( "bytes" - "github.com/algorandfoundation/hack-tui/ui/test" + "github.com/algorandfoundation/hack-tui/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -12,7 +12,7 @@ import ( ) func Test_New(t *testing.T) { - m := New(test.GetState()) + m := New(test.GetState(nil)) if m == nil { t.Fatal("New returned nil") } @@ -31,13 +31,13 @@ func Test_New(t *testing.T) { } func Test_Snapshot(t *testing.T) { t.Run("Visible", func(t *testing.T) { - model := New(test.GetState()) + model := New(test.GetState(nil)) model.Participation = &test.Keys[0] got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) t.Run("NoKey", func(t *testing.T) { - model := New(test.GetState()) + model := New(test.GetState(nil)) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) @@ -45,7 +45,7 @@ func Test_Snapshot(t *testing.T) { func Test_Messages(t *testing.T) { // Create the Model - m := New(test.GetState()) + m := New(test.GetState(nil)) m.Participation = &test.Keys[0] tm := teatest.NewTestModel( diff --git a/ui/modals/transaction/transaction_test.go b/ui/modals/transaction/transaction_test.go index 88ccdd61..d29705a0 100644 --- a/ui/modals/transaction/transaction_test.go +++ b/ui/modals/transaction/transaction_test.go @@ -2,7 +2,7 @@ package transaction import ( "bytes" - "github.com/algorandfoundation/hack-tui/ui/test" + "github.com/algorandfoundation/hack-tui/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -12,7 +12,7 @@ import ( ) func Test_New(t *testing.T) { - model := New(test.GetState()) + model := New(test.GetState(nil)) model.Participation = &test.Keys[0] model.Participation.Address = "ALGO123456789" addr := model.FormatedAddress() @@ -23,14 +23,14 @@ func Test_New(t *testing.T) { } func Test_Snapshot(t *testing.T) { t.Run("NotVisible", func(t *testing.T) { - model := New(test.GetState()) + model := New(test.GetState(nil)) model.Participation = &test.Keys[0] model.UpdateState() got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) t.Run("Offline", func(t *testing.T) { - model := New(test.GetState()) + model := New(test.GetState(nil)) model.Participation = &test.Keys[0] model, _ = model.HandleMessage(tea.WindowSizeMsg{ Height: 40, @@ -42,7 +42,7 @@ func Test_Snapshot(t *testing.T) { golden.RequireEqual(t, []byte(got)) }) t.Run("Online", func(t *testing.T) { - model := New(test.GetState()) + model := New(test.GetState(nil)) model.Participation = &test.Keys[0] model, _ = model.HandleMessage(tea.WindowSizeMsg{ Height: 40, @@ -54,13 +54,13 @@ func Test_Snapshot(t *testing.T) { }) t.Run("Loading", func(t *testing.T) { - model := New(test.GetState()) + model := New(test.GetState(nil)) model.Participation = &test.Keys[0] got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) t.Run("NoKey", func(t *testing.T) { - model := New(test.GetState()) + model := New(test.GetState(nil)) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) @@ -68,7 +68,7 @@ func Test_Snapshot(t *testing.T) { func Test_Messages(t *testing.T) { // Create the Model - m := New(test.GetState()) + m := New(test.GetState(nil)) m.Participation = &test.Keys[0] tm := teatest.NewTestModel( diff --git a/ui/pages/accounts/accounts_test.go b/ui/pages/accounts/accounts_test.go index df0e7c44..15de15fc 100644 --- a/ui/pages/accounts/accounts_test.go +++ b/ui/pages/accounts/accounts_test.go @@ -3,7 +3,7 @@ package accounts import ( "bytes" "github.com/algorandfoundation/hack-tui/internal" - "github.com/algorandfoundation/hack-tui/ui/test" + "github.com/algorandfoundation/hack-tui/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -28,7 +28,7 @@ func Test_New(t *testing.T) { t.Errorf("Expected no comand") } - m = New(test.GetState()) + m = New(test.GetState(nil)) m, _ = m.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) if m.Data.Admin { @@ -51,7 +51,7 @@ func Test_New(t *testing.T) { func Test_Snapshot(t *testing.T) { t.Run("Visible", func(t *testing.T) { - model := New(test.GetState()) + model := New(test.GetState(nil)) model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) got := ansi.Strip(model.View()) @@ -61,7 +61,7 @@ func Test_Snapshot(t *testing.T) { func Test_Messages(t *testing.T) { // Create the Model - m := New(test.GetState()) + m := New(test.GetState(nil)) tm := teatest.NewTestModel( t, m, @@ -78,7 +78,7 @@ func Test_Messages(t *testing.T) { teatest.WithDuration(time.Second*3), ) - tm.Send(*test.GetState()) + tm.Send(*test.GetState(nil)) tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, diff --git a/ui/pages/keys/keys_test.go b/ui/pages/keys/keys_test.go index 2665d803..c1e3b13e 100644 --- a/ui/pages/keys/keys_test.go +++ b/ui/pages/keys/keys_test.go @@ -3,8 +3,8 @@ package keys import ( "bytes" "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/test" "github.com/algorandfoundation/hack-tui/ui/app" - "github.com/algorandfoundation/hack-tui/ui/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -81,7 +81,7 @@ func Test_Messages(t *testing.T) { ) // Emit a state message - tm.Send(*test.GetState()) + tm.Send(*test.GetState(nil)) // Send delete finished tm.Send(app.DeleteFinished{ diff --git a/ui/viewport.go b/ui/viewport.go index 909f20d5..417461df 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -30,7 +30,7 @@ type ViewportViewModel struct { modal *modal.ViewModel page app.Page - client *api.ClientWithResponses + client api.ClientWithResponsesInterface // Error Handler errorMsg *string @@ -194,7 +194,7 @@ func (m ViewportViewModel) headerView() string { } // NewViewportViewModel handles the construction of the TUI viewport -func NewViewportViewModel(state *internal.StateModel, client *api.ClientWithResponses) (*ViewportViewModel, error) { +func NewViewportViewModel(state *internal.StateModel, client api.ClientWithResponsesInterface) (*ViewportViewModel, error) { m := ViewportViewModel{ Data: state, diff --git a/ui/viewport_test.go b/ui/viewport_test.go index 5037ffd4..1ec01860 100644 --- a/ui/viewport_test.go +++ b/ui/viewport_test.go @@ -2,37 +2,20 @@ package ui import ( "bytes" + test2 "github.com/algorandfoundation/hack-tui/test" + "github.com/algorandfoundation/hack-tui/ui/app" "testing" "time" - "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/internal" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/exp/teatest" - "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" ) func Test_ViewportViewRender(t *testing.T) { - apiToken, err := securityprovider.NewSecurityProviderApiKey("header", "X-Algo-API-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - client, err := api.NewClientWithResponses("http://localhost:8080", api.WithRequestEditorFn(apiToken.Intercept)) - if err != nil { - t.Fatal(err) - } - state := internal.StateModel{ - Status: internal.StatusModel{ - LastRound: 1337, - NeedsUpdate: true, - State: internal.SyncingState, - }, - Metrics: internal.MetricsModel{ - RoundTime: 0, - TX: 0, - RX: 0, - TPS: 0, - }, - } + client := test2.GetClient(false) + state := test2.GetState(client) // Create the Model - m, err := NewViewportViewModel(&state, client) + m, err := NewViewportViewModel(state, client) if err != nil { t.Fatal(err) } @@ -51,7 +34,32 @@ func Test_ViewportViewRender(t *testing.T) { teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*3), ) + tm.Send(app.AccountSelected( + state.Accounts["ABC"])) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("left"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("right"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("right"), + }) + + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("left"), + }) + tm.Send(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune("left"), + }) // Send quit key tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, From a39d905e4f2cde7ae032cad95959df4924a0c62e Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 20 Nov 2024 14:37:21 -0500 Subject: [PATCH 25/55] test: allow mock api in internal --- internal/accounts_test.go | 2 +- {test => internal/test}/client.go | 22 ++++----- {test => internal/test}/mock/clock.go | 0 internal/test/mock/fixtures.go | 45 ++++++++++++++++++ ui/app/app_test.go | 15 +++--- test/fixtures.go => ui/internal/test/state.go | 46 ++----------------- ui/modal/modal_test.go | 17 +++---- ui/modals/confirm/confirm_test.go | 9 ++-- ui/modals/generate/generate_test.go | 2 +- ui/modals/info/info_test.go | 13 +++--- ui/modals/transaction/transaction_test.go | 15 +++--- ui/pages/accounts/accounts_test.go | 2 +- ui/pages/keys/keys_test.go | 11 +++-- ui/viewport_test.go | 7 +-- 14 files changed, 109 insertions(+), 97 deletions(-) rename {test => internal/test}/client.go (62%) rename {test => internal/test}/mock/clock.go (100%) create mode 100644 internal/test/mock/fixtures.go rename test/fixtures.go => ui/internal/test/state.go (51%) diff --git a/internal/accounts_test.go b/internal/accounts_test.go index eccab4fb..583d365c 100644 --- a/internal/accounts_test.go +++ b/internal/accounts_test.go @@ -2,7 +2,7 @@ package internal import ( "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/test/mock" + "github.com/algorandfoundation/hack-tui/internal/test/mock" "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" "github.com/stretchr/testify/assert" "testing" diff --git a/test/client.go b/internal/test/client.go similarity index 62% rename from test/client.go rename to internal/test/client.go index ef1233ee..af63b40c 100644 --- a/test/client.go +++ b/internal/test/client.go @@ -4,29 +4,29 @@ import ( "context" "errors" "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal/test/mock" "net/http" ) func GetClient(throws bool) api.ClientWithResponsesInterface { - res, _ := NewTestClient(throws) - return res + return NewClient(throws) } -type TestClient struct { +type Client struct { api.ClientWithResponsesInterface Errors bool } -func NewTestClient(throws bool) (api.ClientWithResponsesInterface, error) { - client := new(TestClient) +func NewClient(throws bool) api.ClientWithResponsesInterface { + client := new(Client) if throws { client.Errors = true } - return client, nil + return client } -func (c *TestClient) GetParticipationKeysWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.GetParticipationKeysResponse, error) { +func (c *Client) GetParticipationKeysWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.GetParticipationKeysResponse, error) { httpResponse := http.Response{StatusCode: 200} - clone := Keys + clone := mock.Keys res := api.GetParticipationKeysResponse{ Body: nil, HTTPResponse: &httpResponse, @@ -42,7 +42,7 @@ func (c *TestClient) GetParticipationKeysWithResponse(ctx context.Context, reqEd return &res, nil } -func (c *TestClient) DeleteParticipationKeyByIDWithResponse(ctx context.Context, participationId string, reqEditors ...api.RequestEditorFn) (*api.DeleteParticipationKeyByIDResponse, error) { +func (c *Client) DeleteParticipationKeyByIDWithResponse(ctx context.Context, participationId string, reqEditors ...api.RequestEditorFn) (*api.DeleteParticipationKeyByIDResponse, error) { httpResponse := http.Response{StatusCode: 200} res := api.DeleteParticipationKeyByIDResponse{ Body: nil, @@ -59,8 +59,8 @@ func (c *TestClient) DeleteParticipationKeyByIDWithResponse(ctx context.Context, return &res, nil } -func (c *TestClient) GenerateParticipationKeysWithResponse(ctx context.Context, address string, params *api.GenerateParticipationKeysParams, reqEditors ...api.RequestEditorFn) (*api.GenerateParticipationKeysResponse, error) { - Keys = append(Keys, api.ParticipationKey{ +func (c *Client) GenerateParticipationKeysWithResponse(ctx context.Context, address string, params *api.GenerateParticipationKeysParams, reqEditors ...api.RequestEditorFn) (*api.GenerateParticipationKeysResponse, error) { + mock.Keys = append(mock.Keys, api.ParticipationKey{ Address: "", EffectiveFirstValid: nil, EffectiveLastValid: nil, diff --git a/test/mock/clock.go b/internal/test/mock/clock.go similarity index 100% rename from test/mock/clock.go rename to internal/test/mock/clock.go diff --git a/internal/test/mock/fixtures.go b/internal/test/mock/fixtures.go new file mode 100644 index 00000000..41bd53ae --- /dev/null +++ b/internal/test/mock/fixtures.go @@ -0,0 +1,45 @@ +package mock + +import ( + "github.com/algorandfoundation/hack-tui/api" +) + +var VoteKey = []byte("TESTKEY") +var SelectionKey = []byte("TESTKEY") +var StateProofKey = []byte("TESTKEY") +var Keys = []api.ParticipationKey{ + { + Address: "ABC", + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "123", + Key: api.AccountParticipation{ + SelectionParticipationKey: SelectionKey, + StateProofKey: &StateProofKey, + VoteFirstValid: 0, + VoteKeyDilution: 100, + VoteLastValid: 30000, + VoteParticipationKey: VoteKey, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }, + { + Address: "ABC", + EffectiveFirstValid: nil, + EffectiveLastValid: nil, + Id: "1234", + Key: api.AccountParticipation{ + SelectionParticipationKey: nil, + StateProofKey: nil, + VoteFirstValid: 0, + VoteKeyDilution: 100, + VoteLastValid: 30000, + VoteParticipationKey: nil, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, + }, +} diff --git a/ui/app/app_test.go b/ui/app/app_test.go index b04da474..b7310225 100644 --- a/ui/app/app_test.go +++ b/ui/app/app_test.go @@ -2,7 +2,8 @@ package app import ( "context" - test2 "github.com/algorandfoundation/hack-tui/test" + "github.com/algorandfoundation/hack-tui/internal/test" + uitest "github.com/algorandfoundation/hack-tui/ui/internal/test" "net/http" "testing" "time" @@ -14,8 +15,8 @@ func Intercept(ctx context.Context, req *http.Request) error { } func Test_GenerateCmd(t *testing.T) { - client := test2.GetClient(false) - fn := GenerateCmd("ABC", time.Second*60, test2.GetState(client)) + client := test.GetClient(false) + fn := GenerateCmd("ABC", time.Second*60, uitest.GetState(client)) res := fn() evt, ok := res.(ModalEvent) if !ok { @@ -25,8 +26,8 @@ func Test_GenerateCmd(t *testing.T) { t.Error("Expected InfoModal") } - client = test2.GetClient(true) - fn = GenerateCmd("ABC", time.Second*60, test2.GetState(client)) + client = test.GetClient(true) + fn = GenerateCmd("ABC", time.Second*60, uitest.GetState(client)) res = fn() evt, ok = res.(ModalEvent) if !ok { @@ -39,7 +40,7 @@ func Test_GenerateCmd(t *testing.T) { } func Test_EmitDeleteKey(t *testing.T) { - client := test2.GetClient(false) + client := test.GetClient(false) fn := EmitDeleteKey(context.Background(), client, "ABC") res := fn() evt, ok := res.(DeleteFinished) @@ -53,7 +54,7 @@ func Test_EmitDeleteKey(t *testing.T) { t.Error("Expected no errors") } - client = test2.GetClient(true) + client = test.GetClient(true) fn = EmitDeleteKey(context.Background(), client, "ABC") res = fn() evt, ok = res.(DeleteFinished) diff --git a/test/fixtures.go b/ui/internal/test/state.go similarity index 51% rename from test/fixtures.go rename to ui/internal/test/state.go index c827cc0f..8d9904ec 100644 --- a/test/fixtures.go +++ b/ui/internal/test/state.go @@ -4,50 +4,10 @@ import ( "context" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" - "github.com/algorandfoundation/hack-tui/test/mock" + mock2 "github.com/algorandfoundation/hack-tui/internal/test/mock" "time" ) -var VoteKey = []byte("TESTKEY") -var SelectionKey = []byte("TESTKEY") -var StateProofKey = []byte("TESTKEY") -var Keys = []api.ParticipationKey{ - { - Address: "ABC", - EffectiveFirstValid: nil, - EffectiveLastValid: nil, - Id: "123", - Key: api.AccountParticipation{ - SelectionParticipationKey: SelectionKey, - StateProofKey: &StateProofKey, - VoteFirstValid: 0, - VoteKeyDilution: 100, - VoteLastValid: 30000, - VoteParticipationKey: VoteKey, - }, - LastBlockProposal: nil, - LastStateProof: nil, - LastVote: nil, - }, - { - Address: "ABC", - EffectiveFirstValid: nil, - EffectiveLastValid: nil, - Id: "1234", - Key: api.AccountParticipation{ - SelectionParticipationKey: nil, - StateProofKey: nil, - VoteFirstValid: 0, - VoteKeyDilution: 100, - VoteLastValid: 30000, - VoteParticipationKey: nil, - }, - LastBlockProposal: nil, - LastStateProof: nil, - LastVote: nil, - }, -} - func GetState(client api.ClientWithResponsesInterface) *internal.StateModel { sm := &internal.StateModel{ Status: internal.StatusModel{ @@ -70,14 +30,14 @@ func GetState(client api.ClientWithResponsesInterface) *internal.StateModel { LastTX: 0, }, Accounts: nil, - ParticipationKeys: &Keys, + ParticipationKeys: &mock2.Keys, Admin: false, Watching: false, Client: client, Context: context.Background(), } values := make(map[string]internal.Account) - clock := new(mock.Clock) + clock := new(mock2.Clock) for _, key := range *sm.ParticipationKeys { val, ok := values[key.Address] if !ok { diff --git a/ui/modal/modal_test.go b/ui/modal/modal_test.go index a8c56ed5..30def3e6 100644 --- a/ui/modal/modal_test.go +++ b/ui/modal/modal_test.go @@ -3,8 +3,9 @@ package modal import ( "bytes" "errors" - "github.com/algorandfoundation/hack-tui/test" + "github.com/algorandfoundation/hack-tui/internal/test/mock" "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/internal/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" @@ -24,7 +25,7 @@ func Test_Snapshot(t *testing.T) { }) t.Run("InfoModal", func(t *testing.T) { model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) - model.SetKey(&test.Keys[0]) + model.SetKey(&mock.Keys[0]) model.SetType(app.InfoModal) model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) got := ansi.Strip(model.View()) @@ -32,7 +33,7 @@ func Test_Snapshot(t *testing.T) { }) t.Run("ConfirmModal", func(t *testing.T) { model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) - model.SetKey(&test.Keys[0]) + model.SetKey(&mock.Keys[0]) model.SetType(app.ConfirmModal) model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) got := ansi.Strip(model.View()) @@ -40,7 +41,7 @@ func Test_Snapshot(t *testing.T) { }) t.Run("ExceptionModal", func(t *testing.T) { model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) - model.SetKey(&test.Keys[0]) + model.SetKey(&mock.Keys[0]) model.SetType(app.ExceptionModal) model, _ = model.HandleMessage(errors.New("test error")) got := ansi.Strip(model.View()) @@ -48,7 +49,7 @@ func Test_Snapshot(t *testing.T) { }) t.Run("GenerateModal", func(t *testing.T) { model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) - model.SetKey(&test.Keys[0]) + model.SetKey(&mock.Keys[0]) model.SetAddress("ABC") model.SetType(app.GenerateModal) model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) @@ -58,7 +59,7 @@ func Test_Snapshot(t *testing.T) { t.Run("TransactionModal", func(t *testing.T) { model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) - model.SetKey(&test.Keys[0]) + model.SetKey(&mock.Keys[0]) model.SetActive(true) model.SetType(app.TransactionModal) model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) @@ -69,7 +70,7 @@ func Test_Snapshot(t *testing.T) { func Test_Messages(t *testing.T) { model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) - model.SetKey(&test.Keys[0]) + model.SetKey(&mock.Keys[0]) model.SetAddress("ABC") model.SetType(app.InfoModal) tm := teatest.NewTestModel( @@ -113,7 +114,7 @@ func Test_Messages(t *testing.T) { tm.Send(app.DeleteFinished{ Err: nil, - Id: test.Keys[0].Id, + Id: mock.Keys[0].Id, }) delError := errors.New("Something went wrong") diff --git a/ui/modals/confirm/confirm_test.go b/ui/modals/confirm/confirm_test.go index 91bc6440..a045ab30 100644 --- a/ui/modals/confirm/confirm_test.go +++ b/ui/modals/confirm/confirm_test.go @@ -2,7 +2,8 @@ package confirm import ( "bytes" - "github.com/algorandfoundation/hack-tui/test" + "github.com/algorandfoundation/hack-tui/internal/test/mock" + "github.com/algorandfoundation/hack-tui/ui/internal/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -16,7 +17,7 @@ func Test_New(t *testing.T) { if m.ActiveKey != nil { t.Errorf("expected ActiveKey to be nil") } - m.ActiveKey = &test.Keys[0] + m.ActiveKey = &mock.Keys[0] // Handle Delete m, cmd := m.HandleMessage(tea.KeyMsg{ Type: tea.KeyRunes, @@ -35,7 +36,7 @@ func Test_Snapshot(t *testing.T) { }) t.Run("Visible", func(t *testing.T) { model := New(test.GetState(nil)) - model.ActiveKey = &test.Keys[0] + model.ActiveKey = &mock.Keys[0] model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) @@ -45,7 +46,7 @@ func Test_Snapshot(t *testing.T) { func Test_Messages(t *testing.T) { // Create the Model m := New(test.GetState(nil)) - m.ActiveKey = &test.Keys[0] + m.ActiveKey = &mock.Keys[0] tm := teatest.NewTestModel( t, m, teatest.WithInitialTermSize(80, 40), diff --git a/ui/modals/generate/generate_test.go b/ui/modals/generate/generate_test.go index 062be3d0..42d0bae5 100644 --- a/ui/modals/generate/generate_test.go +++ b/ui/modals/generate/generate_test.go @@ -2,7 +2,7 @@ package generate import ( "bytes" - "github.com/algorandfoundation/hack-tui/test" + "github.com/algorandfoundation/hack-tui/ui/internal/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" diff --git a/ui/modals/info/info_test.go b/ui/modals/info/info_test.go index 21c429bb..fa6a63e8 100644 --- a/ui/modals/info/info_test.go +++ b/ui/modals/info/info_test.go @@ -2,7 +2,8 @@ package info import ( "bytes" - "github.com/algorandfoundation/hack-tui/test" + "github.com/algorandfoundation/hack-tui/internal/test/mock" + "github.com/algorandfoundation/hack-tui/ui/internal/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -16,10 +17,10 @@ func Test_New(t *testing.T) { if m == nil { t.Fatal("New returned nil") } - m.Participation = &test.Keys[0] - account := m.State.Accounts[test.Keys[0].Address] + m.Participation = &mock.Keys[0] + account := m.State.Accounts[mock.Keys[0].Address] account.Status = "Online" - m.State.Accounts[test.Keys[0].Address] = account + m.State.Accounts[mock.Keys[0].Address] = account m.Active = true m.UpdateState() if m.BorderColor != "1" { @@ -32,7 +33,7 @@ func Test_New(t *testing.T) { func Test_Snapshot(t *testing.T) { t.Run("Visible", func(t *testing.T) { model := New(test.GetState(nil)) - model.Participation = &test.Keys[0] + model.Participation = &mock.Keys[0] got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) @@ -46,7 +47,7 @@ func Test_Snapshot(t *testing.T) { func Test_Messages(t *testing.T) { // Create the Model m := New(test.GetState(nil)) - m.Participation = &test.Keys[0] + m.Participation = &mock.Keys[0] tm := teatest.NewTestModel( t, m, diff --git a/ui/modals/transaction/transaction_test.go b/ui/modals/transaction/transaction_test.go index d29705a0..948b91ff 100644 --- a/ui/modals/transaction/transaction_test.go +++ b/ui/modals/transaction/transaction_test.go @@ -2,7 +2,8 @@ package transaction import ( "bytes" - "github.com/algorandfoundation/hack-tui/test" + "github.com/algorandfoundation/hack-tui/internal/test/mock" + "github.com/algorandfoundation/hack-tui/ui/internal/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -13,7 +14,7 @@ import ( func Test_New(t *testing.T) { model := New(test.GetState(nil)) - model.Participation = &test.Keys[0] + model.Participation = &mock.Keys[0] model.Participation.Address = "ALGO123456789" addr := model.FormatedAddress() if addr != "ALGO...6789" { @@ -24,14 +25,14 @@ func Test_New(t *testing.T) { func Test_Snapshot(t *testing.T) { t.Run("NotVisible", func(t *testing.T) { model := New(test.GetState(nil)) - model.Participation = &test.Keys[0] + model.Participation = &mock.Keys[0] model.UpdateState() got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) t.Run("Offline", func(t *testing.T) { model := New(test.GetState(nil)) - model.Participation = &test.Keys[0] + model.Participation = &mock.Keys[0] model, _ = model.HandleMessage(tea.WindowSizeMsg{ Height: 40, Width: 80, @@ -43,7 +44,7 @@ func Test_Snapshot(t *testing.T) { }) t.Run("Online", func(t *testing.T) { model := New(test.GetState(nil)) - model.Participation = &test.Keys[0] + model.Participation = &mock.Keys[0] model, _ = model.HandleMessage(tea.WindowSizeMsg{ Height: 40, Width: 80, @@ -55,7 +56,7 @@ func Test_Snapshot(t *testing.T) { t.Run("Loading", func(t *testing.T) { model := New(test.GetState(nil)) - model.Participation = &test.Keys[0] + model.Participation = &mock.Keys[0] got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) @@ -69,7 +70,7 @@ func Test_Snapshot(t *testing.T) { func Test_Messages(t *testing.T) { // Create the Model m := New(test.GetState(nil)) - m.Participation = &test.Keys[0] + m.Participation = &mock.Keys[0] tm := teatest.NewTestModel( t, m, diff --git a/ui/pages/accounts/accounts_test.go b/ui/pages/accounts/accounts_test.go index 15de15fc..23849e41 100644 --- a/ui/pages/accounts/accounts_test.go +++ b/ui/pages/accounts/accounts_test.go @@ -3,7 +3,7 @@ package accounts import ( "bytes" "github.com/algorandfoundation/hack-tui/internal" - "github.com/algorandfoundation/hack-tui/test" + "github.com/algorandfoundation/hack-tui/ui/internal/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" diff --git a/ui/pages/keys/keys_test.go b/ui/pages/keys/keys_test.go index c1e3b13e..eb961bec 100644 --- a/ui/pages/keys/keys_test.go +++ b/ui/pages/keys/keys_test.go @@ -3,8 +3,9 @@ package keys import ( "bytes" "github.com/algorandfoundation/hack-tui/api" - "github.com/algorandfoundation/hack-tui/test" + "github.com/algorandfoundation/hack-tui/internal/test/mock" "github.com/algorandfoundation/hack-tui/ui/app" + "github.com/algorandfoundation/hack-tui/ui/internal/test" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" @@ -29,14 +30,14 @@ func Test_New(t *testing.T) { if cmd != nil { t.Errorf("Expected no commands") } - m.Data = &test.Keys + m.Data = &mock.Keys m, _ = m.HandleMessage(app.AccountSelected{Address: "ABC", Participation: &api.AccountParticipation{ SelectionParticipationKey: nil, StateProofKey: nil, VoteFirstValid: 0, VoteKeyDilution: 0, VoteLastValid: 0, - VoteParticipationKey: test.VoteKey, + VoteParticipationKey: mock.VoteKey, }}) d, active = m.SelectedKey() if !active { @@ -53,7 +54,7 @@ func Test_New(t *testing.T) { func Test_Snapshot(t *testing.T) { t.Run("Visible", func(t *testing.T) { - model := New("ABC", &test.Keys) + model := New("ABC", &mock.Keys) model, _ = model.HandleMessage(tea.WindowSizeMsg{Width: 80, Height: 40}) got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) @@ -63,7 +64,7 @@ func Test_Snapshot(t *testing.T) { func Test_Messages(t *testing.T) { // Create the Model - m := New("ABC", &test.Keys) + m := New("ABC", &mock.Keys) //m, _ = m.Address = "ABC" tm := teatest.NewTestModel( t, m, diff --git a/ui/viewport_test.go b/ui/viewport_test.go index 1ec01860..fcd4a5ba 100644 --- a/ui/viewport_test.go +++ b/ui/viewport_test.go @@ -2,8 +2,9 @@ package ui import ( "bytes" - test2 "github.com/algorandfoundation/hack-tui/test" + "github.com/algorandfoundation/hack-tui/internal/test" "github.com/algorandfoundation/hack-tui/ui/app" + uitest "github.com/algorandfoundation/hack-tui/ui/internal/test" "testing" "time" @@ -12,8 +13,8 @@ import ( ) func Test_ViewportViewRender(t *testing.T) { - client := test2.GetClient(false) - state := test2.GetState(client) + client := test.GetClient(false) + state := uitest.GetState(client) // Create the Model m, err := NewViewportViewModel(state, client) if err != nil { From e2a3af57c2d60e657fbaba247279840db7b623aa Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 20 Nov 2024 15:04:27 -0500 Subject: [PATCH 26/55] test(internal): status test --- internal/status_test.go | 32 ++++++++++++++++++++ internal/test/client.go | 65 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/internal/status_test.go b/internal/status_test.go index cd165e9e..f7c1985c 100644 --- a/internal/status_test.go +++ b/internal/status_test.go @@ -1,6 +1,8 @@ package internal import ( + "context" + "github.com/algorandfoundation/hack-tui/internal/test" "strings" "testing" ) @@ -10,4 +12,34 @@ func Test_StatusModel(t *testing.T) { if !strings.Contains(m.String(), "LastRound: 0") { t.Fatal("expected \"LastRound: 0\", got ", m.String()) } + + stale := true + m.Update(5, 10, &stale) + + if m.LastRound != 5 { + t.Errorf("expected LastRound: 5, got %d", m.LastRound) + } + if m.State != SyncingState { + t.Errorf("expected State: %s, got %s", SyncingState, m.State) + } + + m.Update(10, 0, &stale) + if m.LastRound != 10 { + t.Errorf("expected LastRound: 10, got %d", m.LastRound) + } + if m.State != StableState { + t.Errorf("expected State: %s, got %s", StableState, m.State) + } + +} + +func Test_StatusFetch(t *testing.T) { + m := StatusModel{LastRound: 0} + err := m.Fetch(context.Background(), test.GetClient(false)) + if err != nil { + t.Error(err) + } + if m.LastRound == 0 { + t.Error("expected LastRound to be non-zero") + } } diff --git a/internal/test/client.go b/internal/test/client.go index af63b40c..e2e963bd 100644 --- a/internal/test/client.go +++ b/internal/test/client.go @@ -82,3 +82,68 @@ func (c *Client) GenerateParticipationKeysWithResponse(ctx context.Context, addr return &res, nil } + +func (c *Client) GetVersionWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.GetVersionResponse, error) { + httpResponse := http.Response{StatusCode: 200} + version := api.Version{ + Build: api.BuildVersion{ + Branch: "test", + BuildNumber: 1, + Channel: "beta", + CommitHash: "abc", + Major: 0, + Minor: 0, + }, + GenesisHashB64: nil, + GenesisId: "tui-net", + Versions: nil, + } + res := api.GetVersionResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: &version, + } + + return &res, nil +} +func (c *Client) GetStatusWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.GetStatusResponse, error) { + httpResponse := http.Response{StatusCode: 200} + data := new(struct { + Catchpoint *string `json:"catchpoint,omitempty"` + CatchpointAcquiredBlocks *int `json:"catchpoint-acquired-blocks,omitempty"` + CatchpointProcessedAccounts *int `json:"catchpoint-processed-accounts,omitempty"` + CatchpointProcessedKvs *int `json:"catchpoint-processed-kvs,omitempty"` + CatchpointTotalAccounts *int `json:"catchpoint-total-accounts,omitempty"` + CatchpointTotalBlocks *int `json:"catchpoint-total-blocks,omitempty"` + CatchpointTotalKvs *int `json:"catchpoint-total-kvs,omitempty"` + CatchpointVerifiedAccounts *int `json:"catchpoint-verified-accounts,omitempty"` + CatchpointVerifiedKvs *int `json:"catchpoint-verified-kvs,omitempty"` + CatchupTime int `json:"catchup-time"` + LastCatchpoint *string `json:"last-catchpoint,omitempty"` + LastRound int `json:"last-round"` + LastVersion string `json:"last-version"` + NextVersion string `json:"next-version"` + NextVersionRound int `json:"next-version-round"` + NextVersionSupported bool `json:"next-version-supported"` + StoppedAtUnsupportedRound bool `json:"stopped-at-unsupported-round"` + TimeSinceLastRound int `json:"time-since-last-round"` + UpgradeDelay *int `json:"upgrade-delay,omitempty"` + UpgradeNextProtocolVoteBefore *int `json:"upgrade-next-protocol-vote-before,omitempty"` + UpgradeNoVotes *int `json:"upgrade-no-votes,omitempty"` + UpgradeNodeVote *bool `json:"upgrade-node-vote,omitempty"` + UpgradeVoteRounds *int `json:"upgrade-vote-rounds,omitempty"` + UpgradeVotes *int `json:"upgrade-votes,omitempty"` + UpgradeVotesRequired *int `json:"upgrade-votes-required,omitempty"` + UpgradeYesVotes *int `json:"upgrade-yes-votes,omitempty"` + }) + data.LastRound = 10 + res := api.GetStatusResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: data, + JSON401: nil, + JSON500: nil, + } + + return &res, nil +} From af577a7f6e251d6baf4995af99045026d2c487ba Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 07:49:51 -0500 Subject: [PATCH 27/55] test(internal): github tests 100%, adds HttpPkg interface --- internal/github.go | 6 +-- internal/github_test.go | 82 +++++++++++++++++++++++++++++++++++++++++ internal/http.go | 17 +++++++++ internal/status.go | 2 +- 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 internal/github_test.go create mode 100644 internal/http.go diff --git a/internal/github.go b/internal/github.go index b92f27b3..87abc699 100644 --- a/internal/github.go +++ b/internal/github.go @@ -2,12 +2,10 @@ package internal import ( "encoding/json" - "log" - "net/http" "strings" ) -func GetGoAlgorandRelease(channel string) (*string, error) { +func GetGoAlgorandRelease(channel string, http HttpPkgInterface) (*string, error) { resp, err := http.Get("https://api.github.com/repos/algorand/go-algorand/releases") if err != nil { return nil, err @@ -16,7 +14,7 @@ func GetGoAlgorandRelease(channel string) (*string, error) { defer resp.Body.Close() var versions []map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil { - log.Fatal("ooopsss! an error occurred, please try again") + return nil, err } var versionResponse *string for i := range versions { diff --git a/internal/github_test.go b/internal/github_test.go new file mode 100644 index 00000000..8b0b1d40 --- /dev/null +++ b/internal/github_test.go @@ -0,0 +1,82 @@ +package internal + +import ( + "bytes" + "errors" + "io" + "net/http" + "testing" +) + +type testDecoder struct { + HttpPkgInterface +} + +func (testDecoder) Get(url string) (resp *http.Response, err error) { + return &http.Response{ + Status: "", + StatusCode: 0, + Proto: "", + ProtoMajor: 0, + ProtoMinor: 0, + Header: nil, + Body: http.NoBody, + ContentLength: 0, + TransferEncoding: nil, + Close: false, + Uncompressed: false, + Trailer: nil, + Request: nil, + TLS: nil, + }, nil +} + +type testResponse struct { + HttpPkgInterface +} + +var jsonStr = `[{ + "tag_name": "v3.26.0-beta" + }]` + +func (testResponse) Get(url string) (resp *http.Response, err error) { + + responseBody := io.NopCloser(bytes.NewReader([]byte(jsonStr))) + return &http.Response{ + StatusCode: 200, + Body: responseBody, + }, nil +} + +type testError struct { + HttpPkgInterface +} + +func (testError) Get(url string) (resp *http.Response, err error) { + return &http.Response{ + StatusCode: 404, + }, errors.New("not found") +} + +func Test_Github(t *testing.T) { + _, err := GetGoAlgorandRelease("beta", new(testDecoder)) + if err == nil { + t.Error("should fail to decode") + } + + r, err := GetGoAlgorandRelease("beta", new(testResponse)) + if err != nil { + t.Error(err) + } + if r == nil { + t.Error("should not be nil") + } + if *r != "v3.26.0-beta" { + t.Error("should return v3.26.0-beta") + } + + _, err = GetGoAlgorandRelease("beta", new(testError)) + if err == nil { + t.Error("should fail to get") + } +} diff --git a/internal/http.go b/internal/http.go new file mode 100644 index 00000000..9f2fc57f --- /dev/null +++ b/internal/http.go @@ -0,0 +1,17 @@ +package internal + +import "net/http" + +type HttpPkg struct { + HttpPkgInterface +} + +func (HttpPkg) Get(url string) (resp *http.Response, err error) { + return http.Get(url) +} + +var Http HttpPkg + +type HttpPkgInterface interface { + Get(url string) (resp *http.Response, err error) +} diff --git a/internal/status.go b/internal/status.go index 6f73a8cf..9e10eeea 100644 --- a/internal/status.go +++ b/internal/status.go @@ -52,7 +52,7 @@ func (m *StatusModel) Fetch(ctx context.Context, client api.ClientWithResponsesI } m.Network = v.JSON200.GenesisId m.Version = fmt.Sprintf("v%d.%d.%d-%s", v.JSON200.Build.Major, v.JSON200.Build.Minor, v.JSON200.Build.BuildNumber, v.JSON200.Build.Channel) - currentRelease, err := GetGoAlgorandRelease(v.JSON200.Build.Channel) + currentRelease, err := GetGoAlgorandRelease(v.JSON200.Build.Channel, new(HttpPkg)) if err != nil { return err } From ab6e066eda965b0a386aac613e08a124fbc2fc56 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 08:13:42 -0500 Subject: [PATCH 28/55] test(internal): metrics tests 100% coverage --- internal/metrics_test.go | 23 ++++----- internal/test/client.go | 109 +++++++++++++++++++++++++++++++-------- 2 files changed, 99 insertions(+), 33 deletions(-) diff --git a/internal/metrics_test.go b/internal/metrics_test.go index 2d5ff5ad..ecdd2ba4 100644 --- a/internal/metrics_test.go +++ b/internal/metrics_test.go @@ -2,27 +2,20 @@ package internal import ( "context" + "github.com/algorandfoundation/hack-tui/internal/test" "strconv" "testing" - - "github.com/algorandfoundation/hack-tui/api" - "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" ) func Test_GetMetrics(t *testing.T) { - // Setup elevated client - apiToken, err := securityprovider.NewSecurityProviderApiKey("header", "X-Algo-API-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - if err != nil { - t.Fatal(err) - } - client, err := api.NewClientWithResponses("http://localhost:8080", api.WithRequestEditorFn(apiToken.Intercept)) + client := test.GetClient(true) metrics, err := GetMetrics(context.Background(), client) - if err != nil { - t.Fatal(err) + if err == nil { + t.Error("error expected") } - // TODO: ensure localnet is running before tests + client = test.GetClient(false) metrics, err = GetMetrics(context.Background(), client) if err != nil { t.Fatal(err) @@ -31,6 +24,12 @@ func Test_GetMetrics(t *testing.T) { if metrics["algod_agreement_dropped"] != 0 { t.Fatal(strconv.Itoa(metrics["algod_agreement_dropped"]) + " is not zero") } + + client = test.NewClient(false, true) + metrics, err = GetMetrics(context.Background(), client) + if err == nil { + t.Error("expected error") + } } func Test_parseMetrics(t *testing.T) { diff --git a/internal/test/client.go b/internal/test/client.go index e2e963bd..bb3d522b 100644 --- a/internal/test/client.go +++ b/internal/test/client.go @@ -9,33 +9,91 @@ import ( ) func GetClient(throws bool) api.ClientWithResponsesInterface { - return NewClient(throws) + return NewClient(throws, false) } type Client struct { api.ClientWithResponsesInterface - Errors bool + Errors bool + Invalid bool } -func NewClient(throws bool) api.ClientWithResponsesInterface { +func NewClient(throws bool, invalid bool) api.ClientWithResponsesInterface { client := new(Client) if throws { client.Errors = true } + if invalid { + client.Invalid = true + } return client } + +func (c *Client) MetricsWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.MetricsResponse, error) { + var res api.MetricsResponse + body := `# HELP algod_telemetry_drops_total telemetry messages dropped due to full queues +# TYPE algod_telemetry_drops_total counter +algod_telemetry_drops_total 0 +# HELP algod_telemetry_errs_total telemetry messages dropped due to server error +# TYPE algod_telemetry_errs_total counter +algod_telemetry_errs_total 0 +# HELP algod_ram_usage number of bytes runtime.ReadMemStats().HeapInuse +# TYPE algod_ram_usage gauge +algod_ram_usage 0 +# HELP algod_crypto_vrf_generate_total Total number of calls to GenerateVRFSecrets +# TYPE algod_crypto_vrf_generate_total counter +algod_crypto_vrf_generate_total 0 +# HELP algod_crypto_vrf_prove_total Total number of calls to VRFSecrets.Prove +# TYPE algod_crypto_vrf_prove_total counter +algod_crypto_vrf_prove_total 0 +# HELP algod_crypto_vrf_hash_total Total number of calls to VRFProof.Hash +# TYPE algod_crypto_vrf_hash_total counter +algod_crypto_vrf_hash_total 0` + if !c.Invalid { + httpResponse := http.Response{StatusCode: 200} + res = api.MetricsResponse{ + Body: []byte(body), + HTTPResponse: &httpResponse, + } + } else { + httpResponse := http.Response{StatusCode: 404} + res = api.MetricsResponse{ + Body: []byte(body), + HTTPResponse: &httpResponse, + } + } + if c.Errors { + return &res, errors.New("test error") + } + return &res, nil +} func (c *Client) GetParticipationKeysWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.GetParticipationKeysResponse, error) { - httpResponse := http.Response{StatusCode: 200} + var res api.GetParticipationKeysResponse clone := mock.Keys - res := api.GetParticipationKeysResponse{ - Body: nil, - HTTPResponse: &httpResponse, - JSON200: &clone, - JSON400: nil, - JSON401: nil, - JSON404: nil, - JSON500: nil, + if !c.Invalid { + httpResponse := http.Response{StatusCode: 200} + res = api.GetParticipationKeysResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: &clone, + JSON400: nil, + JSON401: nil, + JSON404: nil, + JSON500: nil, + } + } else { + httpResponse := http.Response{StatusCode: 404} + res = api.GetParticipationKeysResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: &clone, + JSON400: nil, + JSON401: nil, + JSON404: nil, + JSON500: nil, + } } + if c.Errors { return nil, errors.New("test error") } @@ -43,18 +101,27 @@ func (c *Client) GetParticipationKeysWithResponse(ctx context.Context, reqEditor } func (c *Client) DeleteParticipationKeyByIDWithResponse(ctx context.Context, participationId string, reqEditors ...api.RequestEditorFn) (*api.DeleteParticipationKeyByIDResponse, error) { - httpResponse := http.Response{StatusCode: 200} - res := api.DeleteParticipationKeyByIDResponse{ - Body: nil, - HTTPResponse: &httpResponse, - JSON400: nil, - JSON401: nil, - JSON404: nil, - JSON500: nil, + var res api.DeleteParticipationKeyByIDResponse + if !c.Invalid { + httpResponse := http.Response{StatusCode: 200} + res = api.DeleteParticipationKeyByIDResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON400: nil, + JSON401: nil, + JSON404: nil, + JSON500: nil, + } + } else { + httpResponse := http.Response{StatusCode: 404} + res = api.DeleteParticipationKeyByIDResponse{ + Body: nil, + HTTPResponse: &httpResponse, + } } if c.Errors { - return nil, errors.New("test error") + return &res, errors.New("test error") } return &res, nil } From dff0cc797e9da0bbd6f8fcd2acec16d8f41303dd Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 12:35:22 -0500 Subject: [PATCH 29/55] test(internal): status coverage --- cmd/root.go | 2 +- cmd/status.go | 2 +- internal/state.go | 2 +- internal/status.go | 4 ++-- internal/status_test.go | 17 ++++++++++++++++- internal/test/client.go | 25 +++++++++++++++++++------ 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 014680ac..76fed420 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -83,7 +83,7 @@ var ( state.Accounts = internal.AccountsFromState(&state, new(internal.Clock), client) // Fetch current state - err = state.Status.Fetch(ctx, client) + err = state.Status.Fetch(ctx, client, new(internal.HttpPkg)) cobra.CheckErr(err) m, err := ui.NewViewportViewModel(&state, client) diff --git a/cmd/status.go b/cmd/status.go index 5af7f38b..908477fb 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -44,7 +44,7 @@ var statusCmd = &cobra.Command{ }, ParticipationKeys: nil, } - err = state.Status.Fetch(context.Background(), client) + err = state.Status.Fetch(context.Background(), client, new(internal.HttpPkg)) cobra.CheckErr(err) // Create the TUI view := ui.MakeStatusViewModel(&state) diff --git a/internal/state.go b/internal/state.go index 96d1b8de..ca39da4d 100644 --- a/internal/state.go +++ b/internal/state.go @@ -42,7 +42,7 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co s.Metrics.Window = 100 } - err := s.Status.Fetch(ctx, client) + err := s.Status.Fetch(ctx, client, new(HttpPkg)) if err != nil { cb(nil, err) } diff --git a/internal/status.go b/internal/status.go index 9e10eeea..009c4cb3 100644 --- a/internal/status.go +++ b/internal/status.go @@ -41,7 +41,7 @@ func (m *StatusModel) Update(lastRound int, catchupTime int, upgradeNodeVote *bo } // Fetch handles algod.Status -func (m *StatusModel) Fetch(ctx context.Context, client api.ClientWithResponsesInterface) error { +func (m *StatusModel) Fetch(ctx context.Context, client api.ClientWithResponsesInterface, httpPkg HttpPkgInterface) error { if m.Version == "" || m.Version == "NA" { v, err := client.GetVersionWithResponse(ctx) if err != nil { @@ -52,7 +52,7 @@ func (m *StatusModel) Fetch(ctx context.Context, client api.ClientWithResponsesI } m.Network = v.JSON200.GenesisId m.Version = fmt.Sprintf("v%d.%d.%d-%s", v.JSON200.Build.Major, v.JSON200.Build.Minor, v.JSON200.Build.BuildNumber, v.JSON200.Build.Channel) - currentRelease, err := GetGoAlgorandRelease(v.JSON200.Build.Channel, new(HttpPkg)) + currentRelease, err := GetGoAlgorandRelease(v.JSON200.Build.Channel, httpPkg) if err != nil { return err } diff --git a/internal/status_test.go b/internal/status_test.go index f7c1985c..0b21291d 100644 --- a/internal/status_test.go +++ b/internal/status_test.go @@ -34,12 +34,27 @@ func Test_StatusModel(t *testing.T) { } func Test_StatusFetch(t *testing.T) { + client := test.GetClient(true) m := StatusModel{LastRound: 0} - err := m.Fetch(context.Background(), test.GetClient(false)) + pkg := new(HttpPkg) + err := m.Fetch(context.Background(), client, pkg) + if err == nil { + t.Error("expected error, got nil") + } + + client = test.NewClient(false, true) + err = m.Fetch(context.Background(), client, pkg) + if err == nil { + t.Error("expected error, got nil") + } + + client = test.GetClient(false) + err = m.Fetch(context.Background(), client, pkg) if err != nil { t.Error(err) } if m.LastRound == 0 { t.Error("expected LastRound to be non-zero") } + } diff --git a/internal/test/client.go b/internal/test/client.go index bb3d522b..4275c791 100644 --- a/internal/test/client.go +++ b/internal/test/client.go @@ -151,7 +151,7 @@ func (c *Client) GenerateParticipationKeysWithResponse(ctx context.Context, addr } func (c *Client) GetVersionWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.GetVersionResponse, error) { - httpResponse := http.Response{StatusCode: 200} + var res api.GetVersionResponse version := api.Version{ Build: api.BuildVersion{ Branch: "test", @@ -165,12 +165,25 @@ func (c *Client) GetVersionWithResponse(ctx context.Context, reqEditors ...api.R GenesisId: "tui-net", Versions: nil, } - res := api.GetVersionResponse{ - Body: nil, - HTTPResponse: &httpResponse, - JSON200: &version, - } + if !c.Invalid { + httpResponse := http.Response{StatusCode: 200} + res = api.GetVersionResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: &version, + } + } else { + httpResponse := http.Response{StatusCode: 404} + res = api.GetVersionResponse{ + Body: nil, + HTTPResponse: &httpResponse, + JSON200: nil, + } + } + if c.Errors { + return &res, errors.New("test error") + } return &res, nil } func (c *Client) GetStatusWithResponse(ctx context.Context, reqEditors ...api.RequestEditorFn) (*api.GetStatusResponse, error) { From 9a91f8fafef70087baa0e510eb36d71494be7875 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 12:36:07 -0500 Subject: [PATCH 30/55] feat(ui): lora transaction wizard --- internal/participation.go | 35 +++++++++++++++++++++++++++++++++++ ui/modals/transaction/view.go | 18 ++++++++++++++---- ui/style/style.go | 4 ++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/internal/participation.go b/internal/participation.go index 199583ed..ba0a2843 100644 --- a/internal/participation.go +++ b/internal/participation.go @@ -2,7 +2,11 @@ package internal import ( "context" + "encoding/base64" "errors" + "fmt" + "net/url" + "strings" "time" "github.com/algorandfoundation/hack-tui/api" @@ -141,3 +145,34 @@ func FindParticipationIdForVoteKey(slice *[]api.ParticipationKey, votekey []byte } return nil } + +func ToLoraDeepLink(network string, offline bool, part api.ParticipationKey) (string, error) { + fee := 2000000 + var loraNetwork = strings.Replace(strings.Replace(network, "-v1.0", "", 1), "-v1", "", 1) + if loraNetwork == "dockernet" || loraNetwork == "tuinet" { + loraNetwork = "localnet" + } + + var query = "" + idx := url.QueryEscape("[0]") + if offline { + query = fmt.Sprintf( + "type[0]=keyreg&fee=%d&sender[0]=%s&offline[0]=true", + fee, + part.Address, + ) + } else { + query = fmt.Sprintf( + "type[0]=keyreg&fee[0]=%d&sender[0]=%s&selkey[0]=%s&sprfkey[0]=%s&votekey[0]=%s&votefst[0]=%d&votelst[0]=%d&votekd[0]=%d", + fee, + part.Address, + base64.RawURLEncoding.EncodeToString(part.Key.SelectionParticipationKey), + base64.RawURLEncoding.EncodeToString(*part.Key.StateProofKey), + base64.RawURLEncoding.EncodeToString(part.Key.VoteParticipationKey), + part.Key.VoteFirstValid, + part.Key.VoteLastValid, + part.Key.VoteKeyDilution, + ) + } + return fmt.Sprintf("https://lora.algokit.io/%s/transaction-wizard?%s", loraNetwork, strings.Replace(query, "[0]", idx, -1)), nil +} diff --git a/ui/modals/transaction/view.go b/ui/modals/transaction/view.go index 9c97b6c3..8f05a1f5 100644 --- a/ui/modals/transaction/view.go +++ b/ui/modals/transaction/view.go @@ -1,6 +1,7 @@ package transaction import ( + "github.com/algorandfoundation/hack-tui/internal" "github.com/algorandfoundation/hack-tui/ui/style" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" @@ -18,15 +19,24 @@ func (m ViewModel) View() string { if err != nil { return "Something went wrong" } - - render := qrStyle.Render(txn) + link, _ := internal.ToLoraDeepLink(m.State.Status.Network, m.Active, *m.Participation) + render := lipgloss.JoinVertical( + lipgloss.Center, + "Scan the QR code with your wallet", + qrStyle.Render(txn), + style.WithHyperlink("click here to open in Lora", link), + ) width := lipgloss.Width(render) height := lipgloss.Height(render) if width > m.Width || height > m.Height { - return style.Red.Render(ansi.Wordwrap("QR Code too large to display... Please adjust terminal dimensions or font.", m.Width, " ")) - + return lipgloss.JoinVertical( + lipgloss.Center, + style.Red.Render(ansi.Wordwrap("QR Code too large to display... Please adjust terminal dimensions or font size.", m.Width, " ")), + "", + style.WithHyperlink("or click here to open in Lora", link), + ) } return render diff --git a/ui/style/style.go b/ui/style/style.go index 2fa30a31..bce9a3e4 100644 --- a/ui/style/style.go +++ b/ui/style/style.go @@ -1,6 +1,7 @@ package style import ( + "fmt" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "regexp" @@ -37,6 +38,9 @@ var ( Render ) +func WithHyperlink(text string, url string) string { + return fmt.Sprintf("\033]8;;%s\a%s\033]8;;\a", url, text) +} func WithTitle(title string, view string) string { r := []rune(view) if lipgloss.Width(view) >= len(title)+4 { From ceb0e76f3083a9c5a2b4783c5392082db6d68c5d Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 12:44:14 -0500 Subject: [PATCH 31/55] test(ui): update snapshots --- ui/modal/testdata/Test_Snapshot/TransactionModal.golden | 4 ++-- .../transaction/testdata/Test_Snapshot/NotVisible.golden | 4 +++- ui/modals/transaction/testdata/Test_Snapshot/Offline.golden | 4 +++- ui/modals/transaction/testdata/Test_Snapshot/Online.golden | 4 +++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/ui/modal/testdata/Test_Snapshot/TransactionModal.golden b/ui/modal/testdata/Test_Snapshot/TransactionModal.golden index 8cd8aa09..0876d9f5 100644 --- a/ui/modal/testdata/Test_Snapshot/TransactionModal.golden +++ b/ui/modal/testdata/Test_Snapshot/TransactionModal.golden @@ -26,10 +26,10 @@ - ╭──Offline Transaction──────────────╮ + │ Scan the QR code with your wallet │ │ █████████████████████████████████ │ │ ██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ │ │ ██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ │ @@ -47,6 +47,7 @@ │ ██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ │ │ ██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ │ │ │ + │ click here to open in Lora │ ╰────────────────────────( esc )────╯ @@ -74,7 +75,6 @@ - \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden b/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden index d5be7b34..eef3926c 100644 --- a/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden +++ b/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden @@ -1 +1,3 @@ -QR Code too large to display... Please adjust terminal dimensions or font. \ No newline at end of file +QR Code too large to display... Please adjust terminal dimensions or font size. + + or click here to open in Lora \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden b/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden index 83505f8a..c0c94c8b 100644 --- a/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden +++ b/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden @@ -1,3 +1,4 @@ +Scan the QR code with your wallet █████████████████████████████████ ██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ ██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ @@ -14,4 +15,5 @@ ██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ ██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ ██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ - \ No newline at end of file + + click here to open in Lora \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Online.golden b/ui/modals/transaction/testdata/Test_Snapshot/Online.golden index 7d356f9e..c352dffc 100644 --- a/ui/modals/transaction/testdata/Test_Snapshot/Online.golden +++ b/ui/modals/transaction/testdata/Test_Snapshot/Online.golden @@ -1,3 +1,4 @@ + Scan the QR code with your wallet █████████████████████████████████████████████████████ ██ ▄▄▄▄▄ █▀ █▄▀█▀ ▀▄ ▄ █▄ ▄█▄ ▄█▄ ▄▄▀▀ █ ▄▄▄▄▄ ██ ██ █ █ ███▀ ▀▄█▄▀█▀▄▄ ▄▄▄█▄▀▄██ █▀█ ▄█ █ █ ██ @@ -24,4 +25,5 @@ ██ █ █ █▄▀▀ ██ █▀█ ▀ ▄ ▄▄▄█▄██ ▄██ ▄▄▄ ▄▀█ ██ ██ █▄▄▄█ █▄▀▀▄ ▄▀▀█ █▀ ▄ ▄ ▄▄▀ █▄▀▀▄█▀█ ██▄ █▄ ▄██ ██▄▄▄▄▄▄▄█▄▄█▄██▄▄███▄▄▄▄██▄▄▄███▄███▄████▄▄█▄█▄█▄▄██ - \ No newline at end of file + + click here to open in Lora \ No newline at end of file From e395e99eecaff112cfde92f323c48efe0c013c47 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 13:29:50 -0500 Subject: [PATCH 32/55] fix(ui): empty account generate key --- ui/viewport.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/viewport.go b/ui/viewport.go index 417461df..9d22ff9b 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -73,9 +73,14 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "g": // Only open modal when it is closed and not syncing if !m.modal.Open && m.Data.Status.State != internal.SyncingState && m.Data.Metrics.RoundTime > 0 { + address := "" + selected := m.accountsPage.SelectedAccount() + if selected != nil { + address = selected.Address + } return m, app.EmitModalEvent(app.ModalEvent{ Key: nil, - Address: m.accountsPage.SelectedAccount().Address, + Address: address, Type: app.GenerateModal, }) } From a9851e248deedf40ae5353e74c9d297dad9cda09 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 14:26:54 -0500 Subject: [PATCH 33/55] fix(ui): input focus and return to accounts on delete --- internal/participation.go | 15 +++++++++++++-- internal/state.go | 2 +- ui/modal/controller.go | 2 ++ ui/pages/keys/model.go | 3 +++ ui/viewport.go | 5 +++++ 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/internal/participation.go b/internal/participation.go index ba0a2843..7c5219a7 100644 --- a/internal/participation.go +++ b/internal/participation.go @@ -54,8 +54,11 @@ func waitForNewKey( if err != nil { return nil, err } + if keys == nil && currentKeys != nil { + return currentKeys, nil + } // Check the length against known keys - if len(*currentKeys) == len(*keys) { + if currentKeys == nil || len(*currentKeys) == 0 || len(*currentKeys) == len(*keys) { // Sleep then try again time.Sleep(interval) return waitForNewKey(ctx, client, keys, interval, timeout) @@ -81,6 +84,14 @@ func findKeyPair( } } } + // If keys are empty, return the found keys + if originalKeys == nil || len(*originalKeys) == 0 { + keys := *currentKeys + participationKey = keys[0] + } + if participationKey.Id == "" { + return nil, errors.New("key not found") + } return &participationKey, nil } @@ -102,7 +113,7 @@ func GenerateKeyPair( return nil, err } if key.StatusCode() != 200 { - return nil, errors.New(key.Status()) + return nil, errors.New("something went wrong") } // Wait for the api to have a new key diff --git a/internal/state.go b/internal/state.go index ca39da4d..60665017 100644 --- a/internal/state.go +++ b/internal/state.go @@ -77,7 +77,7 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co continue } // Run Round Averages and RX/TX every 5 rounds - if s.Status.LastRound%5 == 0 { + if s.Status.LastRound%5 == 0 || (s.Status.LastRound > 100 && s.Metrics.RoundTime.Seconds() == 0) { bm, err := GetBlockMetrics(ctx, client, s.Status.LastRound, s.Metrics.Window) s.waitAfterError(err, cb) if err != nil { diff --git a/ui/modal/controller.go b/ui/modal/controller.go index cbcc77da..296f3344 100644 --- a/ui/modal/controller.go +++ b/ui/modal/controller.go @@ -34,6 +34,7 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { // On closing events if msg.Type == app.CloseModal { m.Open = false + m.generateModal.Input.Focus() } else { m.Open = true } @@ -46,6 +47,7 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { m.Open = false m.SetType(app.InfoModal) m.generateModal.SetStep(generate.AddressStep) + m.generateModal.Input.Focus() case app.TransactionModal: m.SetType(app.InfoModal) case app.ExceptionModal: diff --git a/ui/pages/keys/model.go b/ui/pages/keys/model.go index be296c85..1c5aa7ad 100644 --- a/ui/pages/keys/model.go +++ b/ui/pages/keys/model.go @@ -81,6 +81,9 @@ func New(address string, keys *[]api.ParticipationKey) ViewModel { return m } +func (m *ViewModel) Rows() []table.Row { + return m.table.Rows() +} // SelectedKey returns the currently selected participation key from the ViewModel's data set, or nil if no key is selected. func (m ViewModel) SelectedKey() (*api.ParticipationKey, bool) { diff --git a/ui/viewport.go b/ui/viewport.go index 9d22ff9b..c4982d03 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -68,6 +68,11 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keysPage, cmd = m.keysPage.HandleMessage(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) + case app.DeleteFinished: + if len(m.keysPage.Rows()) <= 1 { + cmd = app.EmitShowPage(app.AccountsPage) + cmds = append(cmds, cmd) + } case tea.KeyMsg: switch msg.String() { case "g": From a2f484ca2f1d45c1e1f0792bdc830c62ad0da14d Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 14:33:25 -0500 Subject: [PATCH 34/55] feat(ui): display network and hide qrcode --- ui/modal/modal_test.go | 1 + .../Test_Snapshot/TransactionModal.golden | 46 +++++++++---------- .../testdata/Test_Snapshot/Offline.golden | 40 ++++++++-------- .../testdata/Test_Snapshot/Online.golden | 2 + .../testdata/Test_Snapshot/Unsupported.golden | 4 ++ ui/modals/transaction/transaction_test.go | 16 ++++++- ui/modals/transaction/view.go | 16 ++++++- 7 files changed, 79 insertions(+), 46 deletions(-) create mode 100644 ui/modals/transaction/testdata/Test_Snapshot/Unsupported.golden diff --git a/ui/modal/modal_test.go b/ui/modal/modal_test.go index 30def3e6..f7af976b 100644 --- a/ui/modal/modal_test.go +++ b/ui/modal/modal_test.go @@ -59,6 +59,7 @@ func Test_Snapshot(t *testing.T) { t.Run("TransactionModal", func(t *testing.T) { model := New(lipgloss.NewStyle().Width(80).Height(80).Render(""), true, test.GetState(nil)) + model.State.Status.Network = "testnet-v1.0" model.SetKey(&mock.Keys[0]) model.SetActive(true) model.SetType(app.TransactionModal) diff --git a/ui/modal/testdata/Test_Snapshot/TransactionModal.golden b/ui/modal/testdata/Test_Snapshot/TransactionModal.golden index 0876d9f5..75362669 100644 --- a/ui/modal/testdata/Test_Snapshot/TransactionModal.golden +++ b/ui/modal/testdata/Test_Snapshot/TransactionModal.golden @@ -27,29 +27,29 @@ - - ╭──Offline Transaction──────────────╮ - │ Scan the QR code with your wallet │ - │ █████████████████████████████████ │ - │ ██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ │ - │ ██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ │ - │ ██ █▄▄▄█ █▄ ▄▀▄▄▀▀ █ ▄█ █▄▄▄█ ██ │ - │ ██▄▄▄▄▄▄▄█▄▀▄▀ █▄█ ▀▄▀▄█▄▄▄▄▄▄▄██ │ - │ ██ ▀ █ ▀▄██▀█ ▀▀██ ▀██▀██▄█▀ ▀██ │ - │ ██▄ ▀▄██▄ ▀▄ ▄▀▀ ▀▀▀█▀█▀ ▄▄█▄ ██ │ - │ ██ ▄▀▄ █▄ ██▀▄█ █ ▄▀██ ▄█▄ █ ██ │ - │ ██▀▄▀▀█▀▄▀▀▀ ██▄██▄▀ ▀▀█ ▄▀██ ▄██ │ - │ ███▀██▀▄▄ ▄▀▀▀▄▀██▀ ▀▄██ ▀█▀█ ▀██ │ - │ ██▄▀ ▀▄▄▄ ▄ █▀▀ ▀█▀▄▀▀▄▀▄▀▄▄▄▄██ │ - │ ██▄█▄▄██▄█▀█▀███ ▄▀ █▀ ▄▄▄ █▀▀ ██ │ - │ ██ ▄▄▄▄▄ ██▄█▄█▄█▀▀▀▀▄ █▄█ ██▄ ██ │ - │ ██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ │ - │ ██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ │ - │ ██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ │ - │ │ - │ click here to open in Lora │ - ╰────────────────────────( esc )────╯ - + ╭──Offline Transaction───────────────────────────╮ + │ Scan the QR code with your wallet │ + │ ( make sure you use the testnet-v1.0 network ) │ + │ │ + │ █████████████████████████████████ │ + │ ██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ │ + │ ██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ │ + │ ██ █▄▄▄█ █▄ ▄▀▄▄▀▀ █ ▄█ █▄▄▄█ ██ │ + │ ██▄▄▄▄▄▄▄█▄▀▄▀ █▄█ ▀▄▀▄█▄▄▄▄▄▄▄██ │ + │ ██ ▀ █ ▀▄██▀█ ▀▀██ ▀██▀██▄█▀ ▀██ │ + │ ██▄ ▀▄██▄ ▀▄ ▄▀▀ ▀▀▀█▀█▀ ▄▄█▄ ██ │ + │ ██ ▄▀▄ █▄ ██▀▄█ █ ▄▀██ ▄█▄ █ ██ │ + │ ██▀▄▀▀█▀▄▀▀▀ ██▄██▄▀ ▀▀█ ▄▀██ ▄██ │ + │ ███▀██▀▄▄ ▄▀▀▀▄▀██▀ ▀▄██ ▀█▀█ ▀██ │ + │ ██▄▀ ▀▄▄▄ ▄ █▀▀ ▀█▀▄▀▀▄▀▄▀▄▄▄▄██ │ + │ ██▄█▄▄██▄█▀█▀███ ▄▀ █▀ ▄▄▄ █▀▀ ██ │ + │ ██ ▄▄▄▄▄ ██▄█▄█▄█▀▀▀▀▄ █▄█ ██▄ ██ │ + │ ██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ │ + │ ██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ │ + │ ██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ │ + │ │ + │ click here to open in Lora │ + ╰─────────────────────────────────────( esc )────╯ diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden b/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden index c0c94c8b..edd2616a 100644 --- a/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden +++ b/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden @@ -1,19 +1,21 @@ -Scan the QR code with your wallet -█████████████████████████████████ -██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ -██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ -██ █▄▄▄█ █▄ ▄▀▄▄▀▀ █ ▄█ █▄▄▄█ ██ -██▄▄▄▄▄▄▄█▄▀▄▀ █▄█ ▀▄▀▄█▄▄▄▄▄▄▄██ -██ ▀ █ ▀▄██▀█ ▀▀██ ▀██▀██▄█▀ ▀██ -██▄ ▀▄██▄ ▀▄ ▄▀▀ ▀▀▀█▀█▀ ▄▄█▄ ██ -██ ▄▀▄ █▄ ██▀▄█ █ ▄▀██ ▄█▄ █ ██ -██▀▄▀▀█▀▄▀▀▀ ██▄██▄▀ ▀▀█ ▄▀██ ▄██ -███▀██▀▄▄ ▄▀▀▀▄▀██▀ ▀▄██ ▀█▀█ ▀██ -██▄▀ ▀▄▄▄ ▄ █▀▀ ▀█▀▄▀▀▄▀▄▀▄▄▄▄██ -██▄█▄▄██▄█▀█▀███ ▄▀ █▀ ▄▄▄ █▀▀ ██ -██ ▄▄▄▄▄ ██▄█▄█▄█▀▀▀▀▄ █▄█ ██▄ ██ -██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ -██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ -██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ - - click here to open in Lora \ No newline at end of file + Scan the QR code with your wallet +( make sure you use the testnet-v1.0 network ) + + █████████████████████████████████ + ██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ + ██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ + ██ █▄▄▄█ █▄ ▄▀▄▄▀▀ █ ▄█ █▄▄▄█ ██ + ██▄▄▄▄▄▄▄█▄▀▄▀ █▄█ ▀▄▀▄█▄▄▄▄▄▄▄██ + ██ ▀ █ ▀▄██▀█ ▀▀██ ▀██▀██▄█▀ ▀██ + ██▄ ▀▄██▄ ▀▄ ▄▀▀ ▀▀▀█▀█▀ ▄▄█▄ ██ + ██ ▄▀▄ █▄ ██▀▄█ █ ▄▀██ ▄█▄ █ ██ + ██▀▄▀▀█▀▄▀▀▀ ██▄██▄▀ ▀▀█ ▄▀██ ▄██ + ███▀██▀▄▄ ▄▀▀▀▄▀██▀ ▀▄██ ▀█▀█ ▀██ + ██▄▀ ▀▄▄▄ ▄ █▀▀ ▀█▀▄▀▀▄▀▄▀▄▄▄▄██ + ██▄█▄▄██▄█▀█▀███ ▄▀ █▀ ▄▄▄ █▀▀ ██ + ██ ▄▄▄▄▄ ██▄█▄█▄█▀▀▀▀▄ █▄█ ██▄ ██ + ██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ + ██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ + ██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ + + click here to open in Lora \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Online.golden b/ui/modals/transaction/testdata/Test_Snapshot/Online.golden index c352dffc..3e24b77d 100644 --- a/ui/modals/transaction/testdata/Test_Snapshot/Online.golden +++ b/ui/modals/transaction/testdata/Test_Snapshot/Online.golden @@ -1,4 +1,6 @@ Scan the QR code with your wallet + ( make sure you use the testnet-v1.0 network ) + █████████████████████████████████████████████████████ ██ ▄▄▄▄▄ █▀ █▄▀█▀ ▀▄ ▄ █▄ ▄█▄ ▄█▄ ▄▄▀▀ █ ▄▄▄▄▄ ██ ██ █ █ ███▀ ▀▄█▄▀█▀▄▄ ▄▄▄█▄▀▄██ █▀█ ▄█ █ █ ██ diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Unsupported.golden b/ui/modals/transaction/testdata/Test_Snapshot/Unsupported.golden new file mode 100644 index 00000000..6dd69c16 --- /dev/null +++ b/ui/modals/transaction/testdata/Test_Snapshot/Unsupported.golden @@ -0,0 +1,4 @@ + +v-test-network network does not support QRCodes + + click here to open in Lora \ No newline at end of file diff --git a/ui/modals/transaction/transaction_test.go b/ui/modals/transaction/transaction_test.go index 948b91ff..95c82621 100644 --- a/ui/modals/transaction/transaction_test.go +++ b/ui/modals/transaction/transaction_test.go @@ -33,6 +33,7 @@ func Test_Snapshot(t *testing.T) { t.Run("Offline", func(t *testing.T) { model := New(test.GetState(nil)) model.Participation = &mock.Keys[0] + model.State.Status.Network = "testnet-v1.0" model, _ = model.HandleMessage(tea.WindowSizeMsg{ Height: 40, Width: 80, @@ -43,6 +44,18 @@ func Test_Snapshot(t *testing.T) { golden.RequireEqual(t, []byte(got)) }) t.Run("Online", func(t *testing.T) { + model := New(test.GetState(nil)) + model.Participation = &mock.Keys[0] + model.State.Status.Network = "testnet-v1.0" + model, _ = model.HandleMessage(tea.WindowSizeMsg{ + Height: 40, + Width: 80, + }) + model.UpdateState() + got := ansi.Strip(model.View()) + golden.RequireEqual(t, []byte(got)) + }) + t.Run("Unsupported", func(t *testing.T) { model := New(test.GetState(nil)) model.Participation = &mock.Keys[0] model, _ = model.HandleMessage(tea.WindowSizeMsg{ @@ -53,7 +66,6 @@ func Test_Snapshot(t *testing.T) { got := ansi.Strip(model.View()) golden.RequireEqual(t, []byte(got)) }) - t.Run("Loading", func(t *testing.T) { model := New(test.GetState(nil)) model.Participation = &mock.Keys[0] @@ -71,7 +83,7 @@ func Test_Messages(t *testing.T) { // Create the Model m := New(test.GetState(nil)) m.Participation = &mock.Keys[0] - + m.State.Status.Network = "testnet-v1.0" tm := teatest.NewTestModel( t, m, teatest.WithInitialTermSize(80, 40), diff --git a/ui/modals/transaction/view.go b/ui/modals/transaction/view.go index 8f05a1f5..88eefdbb 100644 --- a/ui/modals/transaction/view.go +++ b/ui/modals/transaction/view.go @@ -19,11 +19,23 @@ func (m ViewModel) View() string { if err != nil { return "Something went wrong" } + + var qrCode string + if m.State.Status.Network == "testnet-v1.0" || m.State.Status.Network == "mainnet-v1.0" { + qrCode = lipgloss.JoinVertical( + lipgloss.Center, + "Scan the QR code with your wallet", + style.Yellow.Render("( make sure you use the "+m.State.Status.Network+" network )"), + "", + qrStyle.Render(txn), + ) + } else { + qrCode = style.Red.Render("\n" + m.State.Status.Network + " network does not support QRCodes\n") + } link, _ := internal.ToLoraDeepLink(m.State.Status.Network, m.Active, *m.Participation) render := lipgloss.JoinVertical( lipgloss.Center, - "Scan the QR code with your wallet", - qrStyle.Render(txn), + qrCode, style.WithHyperlink("click here to open in Lora", link), ) From 8b61cf03932fcb569566bff980893480546161c3 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 14:39:56 -0500 Subject: [PATCH 35/55] chore(ui): remove extra parameters --- internal/participation.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/participation.go b/internal/participation.go index 7c5219a7..116d81bd 100644 --- a/internal/participation.go +++ b/internal/participation.go @@ -168,8 +168,7 @@ func ToLoraDeepLink(network string, offline bool, part api.ParticipationKey) (st idx := url.QueryEscape("[0]") if offline { query = fmt.Sprintf( - "type[0]=keyreg&fee=%d&sender[0]=%s&offline[0]=true", - fee, + "type[0]=keyreg&sender[0]=%s", part.Address, ) } else { From db34376b71700e159dcb00233ecf10718afba68e Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 17:54:57 -0500 Subject: [PATCH 36/55] refactor(internal): participation watcher --- internal/participation.go | 91 +++++++--------------------------- internal/participation_test.go | 23 --------- internal/test/client.go | 17 +++++-- 3 files changed, 31 insertions(+), 100 deletions(-) diff --git a/internal/participation.go b/internal/participation.go index 116d81bd..50072e8e 100644 --- a/internal/participation.go +++ b/internal/participation.go @@ -36,65 +36,6 @@ func ReadPartKey(ctx context.Context, client api.ClientWithResponsesInterface, p return key.JSON200, err } -// waitForNewKey await the new key based on known existing keys -// We should try to update the API endpoint -func waitForNewKey( - ctx context.Context, - client api.ClientWithResponsesInterface, - keys *[]api.ParticipationKey, - interval time.Duration, - timeout time.Duration, -) (*[]api.ParticipationKey, error) { - if timeout <= 0*time.Second { - return nil, errors.New("timeout occurred waiting for new key") - } - timeout = timeout - interval - // Fetch the latest keys - currentKeys, err := GetPartKeys(ctx, client) - if err != nil { - return nil, err - } - if keys == nil && currentKeys != nil { - return currentKeys, nil - } - // Check the length against known keys - if currentKeys == nil || len(*currentKeys) == 0 || len(*currentKeys) == len(*keys) { - // Sleep then try again - time.Sleep(interval) - return waitForNewKey(ctx, client, keys, interval, timeout) - } - return currentKeys, nil -} - -// findKeyPair look for a new key based on address between two key lists -// this is not robust, and we should try to update the API endpoint to wait for -// the key creation and return its metadata to the caller -func findKeyPair( - originalKeys *[]api.ParticipationKey, - currentKeys *[]api.ParticipationKey, - address string, -) (*api.ParticipationKey, error) { - var participationKey api.ParticipationKey - for _, key := range *currentKeys { - if key.Address == address { - for _, oKey := range *originalKeys { - if oKey.Id != key.Id { - participationKey = key - } - } - } - } - // If keys are empty, return the found keys - if originalKeys == nil || len(*originalKeys) == 0 { - keys := *currentKeys - participationKey = keys[0] - } - if participationKey.Id == "" { - return nil, errors.New("key not found") - } - return &participationKey, nil -} - // GenerateKeyPair creates a keypair and finds the result func GenerateKeyPair( ctx context.Context, @@ -102,11 +43,6 @@ func GenerateKeyPair( address string, params *api.GenerateParticipationKeysParams, ) (*api.ParticipationKey, error) { - // The api response is an empty body, we need to fetch known keys first - originalKeys, err := GetPartKeys(ctx, client) - if err != nil { - return nil, err - } // Generate a new keypair key, err := client.GenerateParticipationKeysWithResponse(ctx, address, params) if err != nil { @@ -115,15 +51,26 @@ func GenerateKeyPair( if key.StatusCode() != 200 { return nil, errors.New("something went wrong") } - - // Wait for the api to have a new key - keys, err := waitForNewKey(ctx, client, originalKeys, 2*time.Second, 20*time.Minute) - if err != nil { - return nil, err + for { + select { + case <-ctx.Done(): + return nil, context.Canceled + case <-time.After(2 * time.Second): + partKeys, err := GetPartKeys(ctx, client) + if partKeys == nil || err != nil { + return nil, errors.New("failed to get participation keys") + } + for _, k := range *partKeys { + if k.Address == address && + k.Key.VoteFirstValid == params.First && + k.Key.VoteLastValid == params.Last { + return &k, nil + } + } + case <-time.After(20 * time.Minute): + return nil, errors.New("timeout waiting for key to be created") + } } - - // Find the new keypair in the results - return findKeyPair(originalKeys, keys, address) } // DeletePartKey remove a key from the node diff --git a/internal/participation_test.go b/internal/participation_test.go index f0a401f6..f8e870a1 100644 --- a/internal/participation_test.go +++ b/internal/participation_test.go @@ -6,7 +6,6 @@ import ( "github.com/algorandfoundation/hack-tui/api" "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" "testing" - "time" ) func Test_ListParticipationKeys(t *testing.T) { @@ -201,25 +200,3 @@ func Test_RemovePartKeyByID(t *testing.T) { } }) } - -func Test_Timeout(t *testing.T) { - ctx := context.Background() - // Setup elevated client - apiToken, err := securityprovider.NewSecurityProviderApiKey("header", "X-Algo-API-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - if err != nil { - t.Fatal(err) - } - client, err := api.NewClientWithResponses("http://localhost:8080", api.WithRequestEditorFn(apiToken.Intercept)) - if err != nil { - t.Fatal(err) - } - - keys, err := GetPartKeys(ctx, client) - if err != nil { - t.Fatal(err) - } - _, err = waitForNewKey(ctx, client, keys, 100*time.Millisecond, 1*time.Second) - if err == nil { - t.Fatal("Did not error") - } -} diff --git a/internal/test/client.go b/internal/test/client.go index 4275c791..cb23591a 100644 --- a/internal/test/client.go +++ b/internal/test/client.go @@ -128,14 +128,21 @@ func (c *Client) DeleteParticipationKeyByIDWithResponse(ctx context.Context, par func (c *Client) GenerateParticipationKeysWithResponse(ctx context.Context, address string, params *api.GenerateParticipationKeysParams, reqEditors ...api.RequestEditorFn) (*api.GenerateParticipationKeysResponse, error) { mock.Keys = append(mock.Keys, api.ParticipationKey{ - Address: "", + Address: "ABC", EffectiveFirstValid: nil, EffectiveLastValid: nil, Id: "", - Key: api.AccountParticipation{}, - LastBlockProposal: nil, - LastStateProof: nil, - LastVote: nil, + Key: api.AccountParticipation{ + SelectionParticipationKey: nil, + StateProofKey: nil, + VoteFirstValid: 0, + VoteKeyDilution: 0, + VoteLastValid: 30, + VoteParticipationKey: nil, + }, + LastBlockProposal: nil, + LastStateProof: nil, + LastVote: nil, }) httpResponse := http.Response{StatusCode: 200} res := api.GenerateParticipationKeysResponse{ From c66b33000c91c3e4331819565ce21357d7609d13 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 17:55:25 -0500 Subject: [PATCH 37/55] fix(ui): state updates for online/offline --- ui/app/app_test.go | 6 ------ ui/modal/controller.go | 14 ++++++++++++++ ui/pages/keys/controller.go | 7 ++++--- ui/pages/keys/model.go | 8 ++++---- ui/viewport.go | 2 ++ 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/ui/app/app_test.go b/ui/app/app_test.go index b7310225..da9e6d0e 100644 --- a/ui/app/app_test.go +++ b/ui/app/app_test.go @@ -4,16 +4,10 @@ import ( "context" "github.com/algorandfoundation/hack-tui/internal/test" uitest "github.com/algorandfoundation/hack-tui/ui/internal/test" - "net/http" "testing" "time" ) -func Intercept(ctx context.Context, req *http.Request) error { - req.Response = &http.Response{} - return nil -} - func Test_GenerateCmd(t *testing.T) { client := test.GetClient(false) fn := GenerateCmd("ABC", time.Second*60, uitest.GetState(client)) diff --git a/ui/modal/controller.go b/ui/modal/controller.go index 296f3344..760254c0 100644 --- a/ui/modal/controller.go +++ b/ui/modal/controller.go @@ -1,6 +1,7 @@ package modal import ( + "github.com/algorandfoundation/hack-tui/internal" "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/modals/generate" "github.com/algorandfoundation/hack-tui/ui/style" @@ -27,6 +28,19 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { m.Open = true m.exceptionModal.Message = msg.Error() m.SetType(app.ExceptionModal) + case internal.StateModel: + m.State = &msg + m.transactionModal.State = &msg + m.infoModal.State = &msg + + if m.Type == app.TransactionModal && !m.transactionModal.Active { + if msg.Accounts[m.Address].Participation.VoteFirstValid == m.transactionModal.Participation.Key.VoteFirstValid { + m.SetActive(true) + m.infoModal.Active = true + m.SetType(app.InfoModal) + } + } + case app.ModalEvent: if msg.Type == app.InfoModal { m.generateModal.SetStep(generate.AddressStep) diff --git a/ui/pages/keys/controller.go b/ui/pages/keys/controller.go index cd1d198f..dd86e424 100644 --- a/ui/pages/keys/controller.go +++ b/ui/pages/keys/controller.go @@ -21,16 +21,17 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (ViewModel, tea.Cmd) { // When the State changes case internal.StateModel: m.Data = msg.ParticipationKeys - m.table.SetRows(m.makeRows(m.Data)) + m.table.SetRows(*m.makeRows(m.Data)) + m.Participation = msg.Accounts[m.Address].Participation // When the Account is Selected case app.AccountSelected: m.Address = msg.Address m.Participation = msg.Participation - m.table.SetRows(m.makeRows(m.Data)) + m.table.SetRows(*m.makeRows(m.Data)) // When a confirmation Modal is finished deleting case app.DeleteFinished: internal.RemovePartKeyByID(m.Data, msg.Id) - m.table.SetRows(m.makeRows(m.Data)) + m.table.SetRows(*m.makeRows(m.Data)) // When the user interacts with the render case tea.KeyMsg: switch msg.String() { diff --git a/ui/pages/keys/model.go b/ui/pages/keys/model.go index 1c5aa7ad..c4049043 100644 --- a/ui/pages/keys/model.go +++ b/ui/pages/keys/model.go @@ -60,7 +60,7 @@ func New(address string, keys *[]api.ParticipationKey) ViewModel { // Create Table m.table = table.New( table.WithColumns(m.makeColumns(80)), - table.WithRows(m.makeRows(keys)), + table.WithRows(*m.makeRows(keys)), table.WithFocused(true), table.WithHeight(m.Height), table.WithWidth(m.Width), @@ -119,10 +119,10 @@ func (m ViewModel) makeColumns(width int) []table.Column { // makeRows processes a slice of ParticipationKeys and returns a sorted slice of table rows // filtered by the ViewModel's address. -func (m ViewModel) makeRows(keys *[]api.ParticipationKey) []table.Row { +func (m ViewModel) makeRows(keys *[]api.ParticipationKey) *[]table.Row { rows := make([]table.Row, 0) if keys == nil || m.Address == "" { - return rows + return &rows } var activeId *string @@ -147,5 +147,5 @@ func (m ViewModel) makeRows(keys *[]api.ParticipationKey) []table.Row { sort.SliceStable(rows, func(i, j int) bool { return rows[i][0] < rows[j][0] }) - return rows + return &rows } diff --git a/ui/viewport.go b/ui/viewport.go index c4982d03..c89eb410 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -67,6 +67,8 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.keysPage, cmd = m.keysPage.HandleMessage(msg) cmds = append(cmds, cmd) + m.modal, cmd = m.modal.HandleMessage(msg) + cmds = append(cmds, cmd) return m, tea.Batch(cmds...) case app.DeleteFinished: if len(m.keysPage.Rows()) <= 1 { From 194c3af3244c923a6240a972bc0960b7f988dc83 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 18:18:50 -0500 Subject: [PATCH 38/55] chore(build): fund TUI account --- .docker/start_dev.sh | 2 ++ Dockerfile | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.docker/start_dev.sh b/.docker/start_dev.sh index fd10e768..d66bd0d4 100755 --- a/.docker/start_dev.sh +++ b/.docker/start_dev.sh @@ -40,6 +40,8 @@ if [ -d "$ALGORAND_DATA" ]; then # Import wallet goal account import -m "artefact exist coil life turtle edge edge inside punch glance recycle teach melody diet method pause slam dumb race interest amused side learn able heavy" + goal clerk send -a 100000000000 -t TUIDKH2C7MUHZDD77MAMUREJRKNK25SYXB7OAFA6JFBB24PEL5UX4S4GUU -f $(cat $ALGORAND_DATA/genesis.json | jq --raw-output '.alloc[] | select(.comment=="Wallet1").addr') + algod -o -d "$ALGORAND_DATA" -l "0.0.0.0:8080" fi diff --git a/Dockerfile b/Dockerfile index 6104377d..3bb381e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,8 @@ ADD .docker/start_fast_catchup.sh /node/run/start_fast_catchup.sh COPY --from=BUILDER /app/bin/algorun /bin/algorun +RUN apt-get update && apt-get install jq -y + ENTRYPOINT /node/run/start_dev.sh CMD [] From b8e3149aadad1d1f7246054b9a9d981ca3a6b076 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 18:19:52 -0500 Subject: [PATCH 39/55] fix(ui): catch invalid states --- ui/modal/controller.go | 26 +++++++++++++++---- .../testdata/Test_Snapshot/Visible.golden | 2 +- ui/pages/accounts/view.go | 6 ++++- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/ui/modal/controller.go b/ui/modal/controller.go index 760254c0..6e54278f 100644 --- a/ui/modal/controller.go +++ b/ui/modal/controller.go @@ -33,12 +33,28 @@ func (m ViewModel) HandleMessage(msg tea.Msg) (*ViewModel, tea.Cmd) { m.transactionModal.State = &msg m.infoModal.State = &msg - if m.Type == app.TransactionModal && !m.transactionModal.Active { - if msg.Accounts[m.Address].Participation.VoteFirstValid == m.transactionModal.Participation.Key.VoteFirstValid { - m.SetActive(true) - m.infoModal.Active = true - m.SetType(app.InfoModal) + // When the state changes, and we are displaying a valid QR Code/Transaction Modal + if m.Type == app.TransactionModal && m.transactionModal.Participation != nil { + acct, ok := msg.Accounts[m.Address] + // If the previous state is not active + if ok { + if !m.transactionModal.Active { + if acct.Participation != nil && + acct.Participation.VoteFirstValid == m.transactionModal.Participation.Key.VoteFirstValid { + m.SetActive(true) + m.infoModal.Active = true + m.SetType(app.InfoModal) + } + } else { + if acct.Participation == nil { + m.SetActive(false) + m.infoModal.Active = false + m.transactionModal.Active = false + m.SetType(app.InfoModal) + } + } } + } case app.ModalEvent: diff --git a/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden b/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden index 98a06efd..624afa2e 100644 --- a/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden +++ b/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden @@ -37,4 +37,4 @@ │ │ │ │ │ │ -╰────( (g)enerate )─────────────────────────────────────| accounts | keys |────╯ \ No newline at end of file +╰────( Insufficient Data )──────────────────────────────| accounts | keys |────╯ \ No newline at end of file diff --git a/ui/pages/accounts/view.go b/ui/pages/accounts/view.go index a3c0461e..52ba4ba4 100644 --- a/ui/pages/accounts/view.go +++ b/ui/pages/accounts/view.go @@ -6,10 +6,14 @@ import ( func (m ViewModel) View() string { table := style.ApplyBorder(m.Width, m.Height, m.BorderColor).Render(m.table.View()) + ctls := m.Controls + if m.Data.Status.LastRound < uint64(m.Data.Metrics.Window) { + ctls = "( Insufficient Data )" + } return style.WithNavigation( m.Navigation, style.WithControls( - m.Controls, + ctls, style.WithTitle( m.Title, table, From c172119a1692bdc2c7175c219a6c2730fc49001b Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 19:32:46 -0500 Subject: [PATCH 40/55] build(docker): change order of clerk call --- .docker/start_dev.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.docker/start_dev.sh b/.docker/start_dev.sh index d66bd0d4..ba8f40e7 100755 --- a/.docker/start_dev.sh +++ b/.docker/start_dev.sh @@ -40,9 +40,8 @@ if [ -d "$ALGORAND_DATA" ]; then # Import wallet goal account import -m "artefact exist coil life turtle edge edge inside punch glance recycle teach melody diet method pause slam dumb race interest amused side learn able heavy" + algod -o -d "$ALGORAND_DATA" -l "0.0.0.0:8080" & goal clerk send -a 100000000000 -t TUIDKH2C7MUHZDD77MAMUREJRKNK25SYXB7OAFA6JFBB24PEL5UX4S4GUU -f $(cat $ALGORAND_DATA/genesis.json | jq --raw-output '.alloc[] | select(.comment=="Wallet1").addr') - - algod -o -d "$ALGORAND_DATA" -l "0.0.0.0:8080" fi else From 297d4f1d7aedb7add73097221909d5f7ea029e0e Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Thu, 21 Nov 2024 19:37:14 -0500 Subject: [PATCH 41/55] build(docker): remove funding for now --- .docker/start_dev.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.docker/start_dev.sh b/.docker/start_dev.sh index ba8f40e7..fd10e768 100755 --- a/.docker/start_dev.sh +++ b/.docker/start_dev.sh @@ -40,8 +40,7 @@ if [ -d "$ALGORAND_DATA" ]; then # Import wallet goal account import -m "artefact exist coil life turtle edge edge inside punch glance recycle teach melody diet method pause slam dumb race interest amused side learn able heavy" - algod -o -d "$ALGORAND_DATA" -l "0.0.0.0:8080" & - goal clerk send -a 100000000000 -t TUIDKH2C7MUHZDD77MAMUREJRKNK25SYXB7OAFA6JFBB24PEL5UX4S4GUU -f $(cat $ALGORAND_DATA/genesis.json | jq --raw-output '.alloc[] | select(.comment=="Wallet1").addr') + algod -o -d "$ALGORAND_DATA" -l "0.0.0.0:8080" fi else From b89bb067762bd66b23a191d3c93a9877106947ec Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 26 Nov 2024 08:17:30 -0500 Subject: [PATCH 42/55] chore(internal): update generate status and rename idx --- internal/participation.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/participation.go b/internal/participation.go index 50072e8e..8b718519 100644 --- a/internal/participation.go +++ b/internal/participation.go @@ -49,6 +49,10 @@ func GenerateKeyPair( return nil, err } if key.StatusCode() != 200 { + status := key.Status() + if status != "" { + return nil, errors.New(status) + } return nil, errors.New("something went wrong") } for { @@ -112,7 +116,7 @@ func ToLoraDeepLink(network string, offline bool, part api.ParticipationKey) (st } var query = "" - idx := url.QueryEscape("[0]") + encodedIndex := url.QueryEscape("[0]") if offline { query = fmt.Sprintf( "type[0]=keyreg&sender[0]=%s", @@ -131,5 +135,5 @@ func ToLoraDeepLink(network string, offline bool, part api.ParticipationKey) (st part.Key.VoteKeyDilution, ) } - return fmt.Sprintf("https://lora.algokit.io/%s/transaction-wizard?%s", loraNetwork, strings.Replace(query, "[0]", idx, -1)), nil + return fmt.Sprintf("https://lora.algokit.io/%s/transaction-wizard?%s", loraNetwork, strings.Replace(query, "[0]", encodedIndex, -1)), nil } From d6b8708dbc4eded15820a1d098437b78b3f5d985 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 26 Nov 2024 14:49:57 -0500 Subject: [PATCH 43/55] chore(internal): remove offset --- cmd/root.go | 1 - internal/state.go | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 76fed420..891773ef 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -60,7 +60,6 @@ var ( err) } state := internal.StateModel{ - Offset: viper.GetInt("offset"), Status: internal.StatusModel{ State: "INITIALIZING", Version: "NA", diff --git a/internal/state.go b/internal/state.go index 60665017..8a8ab483 100644 --- a/internal/state.go +++ b/internal/state.go @@ -16,8 +16,7 @@ type StateModel struct { ParticipationKeys *[]api.ParticipationKey // Application State - Admin bool - Offset int + Admin bool // TODO: handle contexts instead of adding it to state Watching bool From f9352b99607c005450c17c28380035ddf731a153 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Tue, 26 Nov 2024 15:01:00 -0500 Subject: [PATCH 44/55] refactor(internal): move GetAddressFromGenesis to test package --- internal/accounts.go | 65 ---------------------------------- internal/accounts_test.go | 4 ++- internal/test/utils.go | 73 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 66 deletions(-) create mode 100644 internal/test/utils.go diff --git a/internal/accounts.go b/internal/accounts.go index d7043d57..07e00bd9 100644 --- a/internal/accounts.go +++ b/internal/accounts.go @@ -2,10 +2,8 @@ package internal import ( "context" - "encoding/json" "errors" "fmt" - "io" "time" "github.com/algorandfoundation/hack-tui/api" @@ -28,69 +26,6 @@ type Account struct { Expires time.Time } -// Gets the list of addresses created at genesis from the genesis file -func getAddressesFromGenesis(client api.ClientInterface) ([]string, string, string, error) { - resp, err := client.GetGenesis(context.Background()) - if err != nil { - return []string{}, "", "", err - } - - if resp.StatusCode != 200 { - return []string{}, "", "", errors.New(fmt.Sprintf("Failed to get genesis file. Received error code: %d", resp.StatusCode)) - } - - defer resp.Body.Close() - - // Read the response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return []string{}, "", "", err - } - - // Unmarshal the JSON response into a map - var jsonResponse map[string]interface{} - err = json.Unmarshal(body, &jsonResponse) - if err != nil { - return []string{}, "", "", err - } - - // Two special addresses - rewardsPool := "7777777777777777777777777777777777777777777777777774MSJUVU" - feeSink := "A7NMWS3NT3IUDMLVO26ULGXGIIOUQ3ND2TXSER6EBGRZNOBOUIQXHIBGDE" - rewardsPoolIncluded := false - feeSinkIncluded := false - - // Loop over each entry in the "alloc" list and collect the "addr" values - var addresses []string - if allocList, ok := jsonResponse["alloc"].([]interface{}); ok { - for _, entry := range allocList { - if entryMap, ok := entry.(map[string]interface{}); ok { - if addr, ok := entryMap["addr"].(string); ok { - if addr == rewardsPool { - rewardsPoolIncluded = true - } else if addr == feeSink { - feeSinkIncluded = true - } else { - addresses = append(addresses, addr) - } - } else { - return []string{}, "", "", fmt.Errorf("In genesis.json no addr string found in list element entry: %+v", entry) - } - } else { - return []string{}, "", "", fmt.Errorf("In genesis.json list element of alloc-field is not a map: %+v", entry) - } - } - } else { - return []string{}, "", "", errors.New("alloc is not a list") - } - - if !rewardsPoolIncluded || !feeSinkIncluded { - return []string{}, "", "", errors.New("Expected RewardsPool and/or FeeSink addresses NOT found in genesis file") - } - - return addresses, rewardsPool, feeSink, nil -} - // Get Online Status of Account func GetAccount(client api.ClientWithResponsesInterface, address string) (api.Account, error) { var format api.AccountInformationParamsFormat = "json" diff --git a/internal/accounts_test.go b/internal/accounts_test.go index 583d365c..188ce8eb 100644 --- a/internal/accounts_test.go +++ b/internal/accounts_test.go @@ -1,7 +1,9 @@ package internal import ( + "context" "github.com/algorandfoundation/hack-tui/api" + "github.com/algorandfoundation/hack-tui/internal/test" "github.com/algorandfoundation/hack-tui/internal/test/mock" "github.com/oapi-codegen/oapi-codegen/v2/pkg/securityprovider" "github.com/stretchr/testify/assert" @@ -18,7 +20,7 @@ func Test_AccountsFromState(t *testing.T) { } client, err := api.NewClientWithResponses("http://localhost:8080", api.WithRequestEditorFn(apiToken.Intercept)) - addresses, rewardsPool, feeSink, err := getAddressesFromGenesis(client) + addresses, rewardsPool, feeSink, err := test.GetAddressesFromGenesis(context.Background(), client) if err != nil { t.Fatal(err) diff --git a/internal/test/utils.go b/internal/test/utils.go new file mode 100644 index 00000000..25ea5ad9 --- /dev/null +++ b/internal/test/utils.go @@ -0,0 +1,73 @@ +package test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/algorandfoundation/hack-tui/api" + "io" +) + +// GetAddressesFromGenesis gets the list of addresses created at genesis from the genesis file +func GetAddressesFromGenesis(ctx context.Context, client api.ClientInterface) ([]string, string, string, error) { + resp, err := client.GetGenesis(ctx) + if err != nil { + return []string{}, "", "", err + } + + if resp.StatusCode != 200 { + return []string{}, "", "", errors.New(fmt.Sprintf("Failed to get genesis file. Received error code: %d", resp.StatusCode)) + } + + defer resp.Body.Close() + + // Read the response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return []string{}, "", "", err + } + + // Unmarshal the JSON response into a map + var jsonResponse map[string]interface{} + err = json.Unmarshal(body, &jsonResponse) + if err != nil { + return []string{}, "", "", err + } + + // Two special addresses + rewardsPool := "7777777777777777777777777777777777777777777777777774MSJUVU" + feeSink := "A7NMWS3NT3IUDMLVO26ULGXGIIOUQ3ND2TXSER6EBGRZNOBOUIQXHIBGDE" + rewardsPoolIncluded := false + feeSinkIncluded := false + + // Loop over each entry in the "alloc" list and collect the "addr" values + var addresses []string + if allocList, ok := jsonResponse["alloc"].([]interface{}); ok { + for _, entry := range allocList { + if entryMap, ok := entry.(map[string]interface{}); ok { + if addr, ok := entryMap["addr"].(string); ok { + if addr == rewardsPool { + rewardsPoolIncluded = true + } else if addr == feeSink { + feeSinkIncluded = true + } else { + addresses = append(addresses, addr) + } + } else { + return []string{}, "", "", fmt.Errorf("In genesis.json no addr string found in list element entry: %+v", entry) + } + } else { + return []string{}, "", "", fmt.Errorf("In genesis.json list element of alloc-field is not a map: %+v", entry) + } + } + } else { + return []string{}, "", "", errors.New("alloc is not a list") + } + + if !rewardsPoolIncluded || !feeSinkIncluded { + return []string{}, "", "", errors.New("Expected RewardsPool and/or FeeSink addresses NOT found in genesis file") + } + + return addresses, rewardsPool, feeSink, nil +} From 844f2868cc28878eab562e4ad20886cf059c24bf Mon Sep 17 00:00:00 2001 From: Michael J Feher Date: Wed, 27 Nov 2024 07:46:41 -0500 Subject: [PATCH 45/55] chore(ui): update verbiage Co-authored-by: Tasos Bitsios --- ui/modals/generate/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/modals/generate/model.go b/ui/modals/generate/model.go index 3fa28edf..da1f3927 100644 --- a/ui/modals/generate/model.go +++ b/ui/modals/generate/model.go @@ -49,7 +49,7 @@ func (m ViewModel) SetAddress(address string) { } var DefaultControls = "( esc to cancel )" -var DefaultTitle = "Generate Participation Keys" +var DefaultTitle = "Generate Consensus Participation Keys" var DefaultBorderColor = "2" func New(address string, state *internal.StateModel) *ViewModel { From 1069d6e37d61c3c0bf5506a293ca2f196e9ca4db Mon Sep 17 00:00:00 2001 From: Tasos Bitsios Date: Wed, 27 Nov 2024 13:47:01 +0100 Subject: [PATCH 46/55] change build: no CGO dependency --- Dockerfile | 4 ++-- Makefile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3bb381e8..994f12c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app ADD . . -RUN go build -o ./bin/algorun *.go +RUN CGO_ENABLED=0 go build -o ./bin/algorun *.go FROM algorand/algod:latest @@ -28,4 +28,4 @@ CMD [] EXPOSE 8080 EXPOSE 8081 -EXPOSE 8082 \ No newline at end of file +EXPOSE 8082 diff --git a/Makefile b/Makefile index 40bbaa98..fc3c4d38 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ build: - go build -o bin/algorun *.go + CGO_ENABLED=0 go build -o bin/algorun *.go test: go test -coverpkg=./... -covermode=atomic ./... generate: From f90ea9f1c4303116b177b0b321de9fa5d1b35568 Mon Sep 17 00:00:00 2001 From: Tasos Bitsios Date: Wed, 27 Nov 2024 13:55:04 +0100 Subject: [PATCH 47/55] Dockerfile lint/warnings --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 994f12c3..e6cda073 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-bookworm as BUILDER +FROM golang:1.23-bookworm AS builder WORKDIR /app @@ -19,7 +19,7 @@ ADD .docker/start_dev.sh /node/run/start_dev.sh ADD .docker/start_empty.sh /node/run/start_empty.sh ADD .docker/start_fast_catchup.sh /node/run/start_fast_catchup.sh -COPY --from=BUILDER /app/bin/algorun /bin/algorun +COPY --from=builder /app/bin/algorun /bin/algorun RUN apt-get update && apt-get install jq -y From d77d9b4be3251f46e02427f0b63094bd00fb254f Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 27 Nov 2024 08:09:10 -0500 Subject: [PATCH 48/55] test(ui): update modal snapshot --- ui/modal/testdata/Test_Snapshot/GenerateModal.golden | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/modal/testdata/Test_Snapshot/GenerateModal.golden b/ui/modal/testdata/Test_Snapshot/GenerateModal.golden index 5f23d044..11c74911 100644 --- a/ui/modal/testdata/Test_Snapshot/GenerateModal.golden +++ b/ui/modal/testdata/Test_Snapshot/GenerateModal.golden @@ -34,7 +34,7 @@ - ╭──Generate Participation Keys───────────────────────────────────────────╮ + ╭──Generate Consensus Participation Keys─────────────────────────────────╮ │ │ │ Create keys required to participate in Algorand consensus. │ │ │ From 92002bb233145fa96b4b7c2be6a49cde27235466 Mon Sep 17 00:00:00 2001 From: Tasos Bitsios Date: Wed, 27 Nov 2024 17:25:14 +0100 Subject: [PATCH 49/55] text fixes for modals, mostly keyreg --- ui/modals/generate/view.go | 1 + ui/modals/info/info.go | 4 ++ ui/modals/transaction/controller.go | 4 +- ui/modals/transaction/view.go | 59 ++++++++++++++++++++++------- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/ui/modals/generate/view.go b/ui/modals/generate/view.go index 468cf0c0..f53ad905 100644 --- a/ui/modals/generate/view.go +++ b/ui/modals/generate/view.go @@ -29,6 +29,7 @@ func (m ViewModel) View() string { case WaitingStep: render = lipgloss.JoinVertical(lipgloss.Left, "Generating Participation Keys...", + "", "Please wait. This operation can take a few minutes.") } diff --git a/ui/modals/info/info.go b/ui/modals/info/info.go index 012e69fa..16321930 100644 --- a/ui/modals/info/info.go +++ b/ui/modals/info/info.go @@ -93,14 +93,18 @@ func (m ViewModel) View() string { voteKeyDilution := style.Purple("Vote Key Dilution: ") + utils.IntToStr(m.Participation.Key.VoteKeyDilution) return ansi.Hardwrap(lipgloss.JoinVertical(lipgloss.Left, + "", account, id, + "", selection, vote, stateProof, + "", voteFirstValid, voteLastValid, voteKeyDilution, + "", ), m.Width, true) } diff --git a/ui/modals/transaction/controller.go b/ui/modals/transaction/controller.go index 0c1da0a5..eb19fd55 100644 --- a/ui/modals/transaction/controller.go +++ b/ui/modals/transaction/controller.go @@ -11,8 +11,8 @@ import ( type Title string const ( - OnlineTitle Title = "Online Transaction" - OfflineTitle Title = "Offline Transaction" + OnlineTitle Title = "Register Online" + OfflineTitle Title = "Register Offline" ) func (m ViewModel) Init() tea.Cmd { diff --git a/ui/modals/transaction/view.go b/ui/modals/transaction/view.go index 88eefdbb..f051c882 100644 --- a/ui/modals/transaction/view.go +++ b/ui/modals/transaction/view.go @@ -20,24 +20,55 @@ func (m ViewModel) View() string { return "Something went wrong" } - var qrCode string + var verb string + isOffline := m.ATxn.AUrlTxnKeyreg.VotePK == nil + if isOffline { + verb = "deregister" + } else { + verb = "register" + } + intro := "Sign this transaction to "+verb+" your account keys:" + + link, _ := internal.ToLoraDeepLink(m.State.Status.Network, m.Active, *m.Participation) + loraText := lipgloss.JoinHorizontal( + lipgloss.Bottom, + style.WithHyperlink("Click here", link), + " to sign via Lora.", + ) + if isOffline { + loraText = lipgloss.JoinVertical( + lipgloss.Center, + loraText, + "", + "Note: this will take effect after 320 rounds (15 mins.)", + "Please keep your node online during this cooldown period.", + ) + } + + var render string if m.State.Status.Network == "testnet-v1.0" || m.State.Status.Network == "mainnet-v1.0" { - qrCode = lipgloss.JoinVertical( + render = lipgloss.JoinVertical( lipgloss.Center, - "Scan the QR code with your wallet", - style.Yellow.Render("( make sure you use the "+m.State.Status.Network+" network )"), + intro, + "", + "Scan the QR code with Pera or Defly", + style.Yellow.Render("(make sure you use the "+m.State.Status.Network+" network)"), "", qrStyle.Render(txn), + "-or-", + "", + loraText, ) } else { - qrCode = style.Red.Render("\n" + m.State.Status.Network + " network does not support QRCodes\n") + render = lipgloss.JoinVertical( + lipgloss.Center, + "", + intro, + "", + loraText, + "", + ) } - link, _ := internal.ToLoraDeepLink(m.State.Status.Network, m.Active, *m.Participation) - render := lipgloss.JoinVertical( - lipgloss.Center, - qrCode, - style.WithHyperlink("click here to open in Lora", link), - ) width := lipgloss.Width(render) height := lipgloss.Height(render) @@ -45,9 +76,11 @@ func (m ViewModel) View() string { if width > m.Width || height > m.Height { return lipgloss.JoinVertical( lipgloss.Center, - style.Red.Render(ansi.Wordwrap("QR Code too large to display... Please adjust terminal dimensions or font size.", m.Width, " ")), + intro, "", - style.WithHyperlink("or click here to open in Lora", link), + style.Red.Render(ansi.Wordwrap("QR Code too large to display. Please adjust terminal dimensions or font size.", m.Width, " ")), + "-or-", + loraText, ) } From 53c297c461ec1b918e4053e5ef3e4d00c09db6ed Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 27 Nov 2024 11:46:16 -0500 Subject: [PATCH 50/55] test(ui): update snapshots --- .../testdata/Test_Snapshot/InfoModal.golden | 8 +-- .../Test_Snapshot/TransactionModal.golden | 60 +++++++++---------- .../testdata/Test_Snapshot/Waiting.golden | 1 + .../testdata/Test_Snapshot/Visible.golden | 6 +- .../testdata/Test_Snapshot/NotVisible.golden | 8 ++- .../testdata/Test_Snapshot/Offline.golden | 49 ++++++++------- .../testdata/Test_Snapshot/Online.golden | 10 +++- .../testdata/Test_Snapshot/Unsupported.golden | 9 +-- 8 files changed, 85 insertions(+), 66 deletions(-) diff --git a/ui/modal/testdata/Test_Snapshot/InfoModal.golden b/ui/modal/testdata/Test_Snapshot/InfoModal.golden index 1fa9902e..d35f7740 100644 --- a/ui/modal/testdata/Test_Snapshot/InfoModal.golden +++ b/ui/modal/testdata/Test_Snapshot/InfoModal.golden @@ -28,20 +28,22 @@ - - ╭──Key Information────────────╮ + │ │ │ Account: ABC │ │ Participation ID: 123 │ + │ │ │ Selection Key: VEVTVEtFWQ │ │ Vote Key: VEVTVEtFWQ │ │ State Proof Key: VEVTVEtFWQ │ + │ │ │ Vote First Valid: 0 │ │ Vote Last Valid: 30000 │ │ Vote Key Dilution: 100 │ + │ │ ╰──( (d)elete | (o)nline )────╯ @@ -72,8 +74,6 @@ - - diff --git a/ui/modal/testdata/Test_Snapshot/TransactionModal.golden b/ui/modal/testdata/Test_Snapshot/TransactionModal.golden index 75362669..164440e1 100644 --- a/ui/modal/testdata/Test_Snapshot/TransactionModal.golden +++ b/ui/modal/testdata/Test_Snapshot/TransactionModal.golden @@ -23,36 +23,36 @@ - - - - - ╭──Offline Transaction───────────────────────────╮ - │ Scan the QR code with your wallet │ - │ ( make sure you use the testnet-v1.0 network ) │ - │ │ - │ █████████████████████████████████ │ - │ ██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ │ - │ ██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ │ - │ ██ █▄▄▄█ █▄ ▄▀▄▄▀▀ █ ▄█ █▄▄▄█ ██ │ - │ ██▄▄▄▄▄▄▄█▄▀▄▀ █▄█ ▀▄▀▄█▄▄▄▄▄▄▄██ │ - │ ██ ▀ █ ▀▄██▀█ ▀▀██ ▀██▀██▄█▀ ▀██ │ - │ ██▄ ▀▄██▄ ▀▄ ▄▀▀ ▀▀▀█▀█▀ ▄▄█▄ ██ │ - │ ██ ▄▀▄ █▄ ██▀▄█ █ ▄▀██ ▄█▄ █ ██ │ - │ ██▀▄▀▀█▀▄▀▀▀ ██▄██▄▀ ▀▀█ ▄▀██ ▄██ │ - │ ███▀██▀▄▄ ▄▀▀▀▄▀██▀ ▀▄██ ▀█▀█ ▀██ │ - │ ██▄▀ ▀▄▄▄ ▄ █▀▀ ▀█▀▄▀▀▄▀▄▀▄▄▄▄██ │ - │ ██▄█▄▄██▄█▀█▀███ ▄▀ █▀ ▄▄▄ █▀▀ ██ │ - │ ██ ▄▄▄▄▄ ██▄█▄█▄█▀▀▀▀▄ █▄█ ██▄ ██ │ - │ ██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ │ - │ ██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ │ - │ ██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ │ - │ │ - │ click here to open in Lora │ - ╰─────────────────────────────────────( esc )────╯ - - - + ╭──Register Offline─────────────────────────────────────────╮ + │ Sign this transaction to deregister your account keys: │ + │ │ + │ Scan the QR code with Pera or Defly │ + │ (make sure you use the testnet-v1.0 network) │ + │ │ + │ █████████████████████████████████ │ + │ ██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ │ + │ ██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ │ + │ ██ █▄▄▄█ █▄ ▄▀▄▄▀▀ █ ▄█ █▄▄▄█ ██ │ + │ ██▄▄▄▄▄▄▄█▄▀▄▀ █▄█ ▀▄▀▄█▄▄▄▄▄▄▄██ │ + │ ██ ▀ █ ▀▄██▀█ ▀▀██ ▀██▀██▄█▀ ▀██ │ + │ ██▄ ▀▄██▄ ▀▄ ▄▀▀ ▀▀▀█▀█▀ ▄▄█▄ ██ │ + │ ██ ▄▀▄ █▄ ██▀▄█ █ ▄▀██ ▄█▄ █ ██ │ + │ ██▀▄▀▀█▀▄▀▀▀ ██▄██▄▀ ▀▀█ ▄▀██ ▄██ │ + │ ███▀██▀▄▄ ▄▀▀▀▄▀██▀ ▀▄██ ▀█▀█ ▀██ │ + │ ██▄▀ ▀▄▄▄ ▄ █▀▀ ▀█▀▄▀▀▄▀▄▀▄▄▄▄██ │ + │ ██▄█▄▄██▄█▀█▀███ ▄▀ █▀ ▄▄▄ █▀▀ ██ │ + │ ██ ▄▄▄▄▄ ██▄█▄█▄█▀▀▀▀▄ █▄█ ██▄ ██ │ + │ ██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ │ + │ ██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ │ + │ ██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ │ + │ │ + │ -or- │ + │ │ + │ Click here to sign via Lora. │ + │ │ + │ Note: this will take effect after 320 rounds (15 mins.) │ + │ Please keep your node online during this cooldown period. │ + ╰────────────────────────────────────────────────( esc )────╯ diff --git a/ui/modals/generate/testdata/Test_Snapshot/Waiting.golden b/ui/modals/generate/testdata/Test_Snapshot/Waiting.golden index 344691eb..96b7e302 100644 --- a/ui/modals/generate/testdata/Test_Snapshot/Waiting.golden +++ b/ui/modals/generate/testdata/Test_Snapshot/Waiting.golden @@ -1,2 +1,3 @@ Generating Participation Keys... + Please wait. This operation can take a few minutes. \ No newline at end of file diff --git a/ui/modals/info/testdata/Test_Snapshot/Visible.golden b/ui/modals/info/testdata/Test_Snapshot/Visible.golden index b1199d3d..d258004b 100644 --- a/ui/modals/info/testdata/Test_Snapshot/Visible.golden +++ b/ui/modals/info/testdata/Test_Snapshot/Visible.golden @@ -1,8 +1,12 @@ + Account: ABC Participation ID: 123 + Selection Key: VEVTVEtFWQ Vote Key: VEVTVEtFWQ State Proof Key: VEVTVEtFWQ + Vote First Valid: 0 Vote Last Valid: 30000 -Vote Key Dilution: 100 \ No newline at end of file +Vote Key Dilution: 100 + \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden b/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden index eef3926c..46dac261 100644 --- a/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden +++ b/ui/modals/transaction/testdata/Test_Snapshot/NotVisible.golden @@ -1,3 +1,5 @@ -QR Code too large to display... Please adjust terminal dimensions or font size. - - or click here to open in Lora \ No newline at end of file + Sign this transaction to register your account keys: + +QR Code too large to display. Please adjust terminal dimensions or font size. + -or- + Click here to sign via Lora. \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden b/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden index edd2616a..b13027ae 100644 --- a/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden +++ b/ui/modals/transaction/testdata/Test_Snapshot/Offline.golden @@ -1,21 +1,28 @@ - Scan the QR code with your wallet -( make sure you use the testnet-v1.0 network ) - - █████████████████████████████████ - ██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ - ██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ - ██ █▄▄▄█ █▄ ▄▀▄▄▀▀ █ ▄█ █▄▄▄█ ██ - ██▄▄▄▄▄▄▄█▄▀▄▀ █▄█ ▀▄▀▄█▄▄▄▄▄▄▄██ - ██ ▀ █ ▀▄██▀█ ▀▀██ ▀██▀██▄█▀ ▀██ - ██▄ ▀▄██▄ ▀▄ ▄▀▀ ▀▀▀█▀█▀ ▄▄█▄ ██ - ██ ▄▀▄ █▄ ██▀▄█ █ ▄▀██ ▄█▄ █ ██ - ██▀▄▀▀█▀▄▀▀▀ ██▄██▄▀ ▀▀█ ▄▀██ ▄██ - ███▀██▀▄▄ ▄▀▀▀▄▀██▀ ▀▄██ ▀█▀█ ▀██ - ██▄▀ ▀▄▄▄ ▄ █▀▀ ▀█▀▄▀▀▄▀▄▀▄▄▄▄██ - ██▄█▄▄██▄█▀█▀███ ▄▀ █▀ ▄▄▄ █▀▀ ██ - ██ ▄▄▄▄▄ ██▄█▄█▄█▀▀▀▀▄ █▄█ ██▄ ██ - ██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ - ██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ - ██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ - - click here to open in Lora \ No newline at end of file + Sign this transaction to deregister your account keys: + + Scan the QR code with Pera or Defly + (make sure you use the testnet-v1.0 network) + + █████████████████████████████████ + ██ ▄▄▄▄▄ █▀▀ ▄█▀▄██▀█ ▄█ ▄▄▄▄▄ ██ + ██ █ █ ██▄█ █▀█▄██▄▀ █ █ █ ██ + ██ █▄▄▄█ █▄ ▄▀▄▄▀▀ █ ▄█ █▄▄▄█ ██ + ██▄▄▄▄▄▄▄█▄▀▄▀ █▄█ ▀▄▀▄█▄▄▄▄▄▄▄██ + ██ ▀ █ ▀▄██▀█ ▀▀██ ▀██▀██▄█▀ ▀██ + ██▄ ▀▄██▄ ▀▄ ▄▀▀ ▀▀▀█▀█▀ ▄▄█▄ ██ + ██ ▄▀▄ █▄ ██▀▄█ █ ▄▀██ ▄█▄ █ ██ + ██▀▄▀▀█▀▄▀▀▀ ██▄██▄▀ ▀▀█ ▄▀██ ▄██ + ███▀██▀▄▄ ▄▀▀▀▄▀██▀ ▀▄██ ▀█▀█ ▀██ + ██▄▀ ▀▄▄▄ ▄ █▀▀ ▀█▀▄▀▀▄▀▄▀▄▄▄▄██ + ██▄█▄▄██▄█▀█▀███ ▄▀ █▀ ▄▄▄ █▀▀ ██ + ██ ▄▄▄▄▄ ██▄█▄█▄█▀▀▀▀▄ █▄█ ██▄ ██ + ██ █ █ █▄▄▄█▄▀██ ▄ █▄▄▄ ██▀ ██ + ██ █▄▄▄█ █▄ ▄█▀ ██▀▀ ██▀ ▀▄▀▄██ + ██▄▄▄▄▄▄▄█▄█▄███▄▄▄▄█▄▄████▄█▄▄██ + + -or- + + Click here to sign via Lora. + + Note: this will take effect after 320 rounds (15 mins.) +Please keep your node online during this cooldown period. \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Online.golden b/ui/modals/transaction/testdata/Test_Snapshot/Online.golden index 3e24b77d..3ea2e625 100644 --- a/ui/modals/transaction/testdata/Test_Snapshot/Online.golden +++ b/ui/modals/transaction/testdata/Test_Snapshot/Online.golden @@ -1,5 +1,7 @@ - Scan the QR code with your wallet - ( make sure you use the testnet-v1.0 network ) + Sign this transaction to register your account keys: + + Scan the QR code with Pera or Defly + (make sure you use the testnet-v1.0 network) █████████████████████████████████████████████████████ ██ ▄▄▄▄▄ █▀ █▄▀█▀ ▀▄ ▄ █▄ ▄█▄ ▄█▄ ▄▄▀▀ █ ▄▄▄▄▄ ██ @@ -28,4 +30,6 @@ ██ █▄▄▄█ █▄▀▀▄ ▄▀▀█ █▀ ▄ ▄ ▄▄▀ █▄▀▀▄█▀█ ██▄ █▄ ▄██ ██▄▄▄▄▄▄▄█▄▄█▄██▄▄███▄▄▄▄██▄▄▄███▄███▄████▄▄█▄█▄█▄▄██ - click here to open in Lora \ No newline at end of file + -or- + + Click here to sign via Lora. \ No newline at end of file diff --git a/ui/modals/transaction/testdata/Test_Snapshot/Unsupported.golden b/ui/modals/transaction/testdata/Test_Snapshot/Unsupported.golden index 6dd69c16..dad0a92b 100644 --- a/ui/modals/transaction/testdata/Test_Snapshot/Unsupported.golden +++ b/ui/modals/transaction/testdata/Test_Snapshot/Unsupported.golden @@ -1,4 +1,5 @@ - -v-test-network network does not support QRCodes - - click here to open in Lora \ No newline at end of file + +Sign this transaction to register your account keys: + + Click here to sign via Lora. + \ No newline at end of file From f88b5c4ac16cd308580d6daa03bee8a0ce07ed14 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 27 Nov 2024 11:51:34 -0500 Subject: [PATCH 51/55] test(ui): fix lint error --- ui/modals/transaction/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/modals/transaction/view.go b/ui/modals/transaction/view.go index f051c882..ee00c40f 100644 --- a/ui/modals/transaction/view.go +++ b/ui/modals/transaction/view.go @@ -27,7 +27,7 @@ func (m ViewModel) View() string { } else { verb = "register" } - intro := "Sign this transaction to "+verb+" your account keys:" + intro := "Sign this transaction to " + verb + " your account keys:" link, _ := internal.ToLoraDeepLink(m.State.Status.Network, m.Active, *m.Participation) loraText := lipgloss.JoinHorizontal( From 4d9c36c632eff86d1424e8251c48f8b4710fdbb3 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 27 Nov 2024 12:50:35 -0500 Subject: [PATCH 52/55] chore(ui): add message while syncing for generate --- ui/viewport.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/ui/viewport.go b/ui/viewport.go index c89eb410..135ed669 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -1,12 +1,12 @@ package ui import ( + "errors" "fmt" "github.com/algorandfoundation/hack-tui/api" "github.com/algorandfoundation/hack-tui/internal" "github.com/algorandfoundation/hack-tui/ui/app" "github.com/algorandfoundation/hack-tui/ui/modal" - "github.com/algorandfoundation/hack-tui/ui/modals/exception" "github.com/algorandfoundation/hack-tui/ui/pages/accounts" "github.com/algorandfoundation/hack-tui/ui/pages/keys" tea "github.com/charmbracelet/bubbletea" @@ -31,10 +31,6 @@ type ViewportViewModel struct { modal *modal.ViewModel page app.Page client api.ClientWithResponsesInterface - - // Error Handler - errorMsg *string - errorPage *exception.ViewModel } // Init is a no-op @@ -90,6 +86,11 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Address: address, Type: app.GenerateModal, }) + } else if m.Data.Status.State == internal.SyncingState || m.Data.Metrics.RoundTime == 0 { + genErr := errors.New("Please wait for more data to sync before generating a key") + m.modal, cmd = m.modal.HandleMessage(genErr) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) } case "left": @@ -225,8 +226,6 @@ func NewViewportViewModel(state *internal.StateModel, client api.ClientWithRespo page: app.AccountsPage, // RPC client client: client, - - errorPage: exception.New(""), } return &m, nil From 0b578993be6ed8ff166a6e32c97326bc779c5bb3 Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 27 Nov 2024 13:12:16 -0500 Subject: [PATCH 53/55] chore(ui): consistent NA values --- cmd/root.go | 4 ++-- cmd/status.go | 4 ++-- internal/status.go | 2 +- ui/pages/accounts/model.go | 2 +- ui/pages/accounts/testdata/Test_Snapshot/Visible.golden | 2 +- ui/pages/keys/model.go | 2 +- ui/pages/keys/testdata/Test_Snapshot/Visible.golden | 4 ++-- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 891773ef..439b380b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -62,8 +62,8 @@ var ( state := internal.StateModel{ Status: internal.StatusModel{ State: "INITIALIZING", - Version: "NA", - Network: "NA", + Version: "N/A", + Network: "N/A", Voting: false, NeedsUpdate: true, LastRound: 0, diff --git a/cmd/status.go b/cmd/status.go index 908477fb..7f4caf46 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -30,8 +30,8 @@ var statusCmd = &cobra.Command{ state := internal.StateModel{ Status: internal.StatusModel{ State: "SYNCING", - Version: "NA", - Network: "NA", + Version: "N/A", + Network: "N/A", Voting: false, NeedsUpdate: true, LastRound: 0, diff --git a/internal/status.go b/internal/status.go index 009c4cb3..ee96ca8f 100644 --- a/internal/status.go +++ b/internal/status.go @@ -42,7 +42,7 @@ func (m *StatusModel) Update(lastRound int, catchupTime int, upgradeNodeVote *bo // Fetch handles algod.Status func (m *StatusModel) Fetch(ctx context.Context, client api.ClientWithResponsesInterface, httpPkg HttpPkgInterface) error { - if m.Version == "" || m.Version == "NA" { + if m.Version == "" || m.Version == "N/A" { v, err := client.GetVersionWithResponse(ctx) if err != nil { return err diff --git a/ui/pages/accounts/model.go b/ui/pages/accounts/model.go index 4b71aab7..e4a94e3d 100644 --- a/ui/pages/accounts/model.go +++ b/ui/pages/accounts/model.go @@ -83,7 +83,7 @@ func (m ViewModel) makeRows() *[]table.Row { expires = "SYNCING" } if !m.Data.Accounts[key].Expires.After(time.Now().Add(-(time.Hour * 24 * 365 * 50))) { - expires = "NA" + expires = "N/A" } rows = append(rows, table.Row{ m.Data.Accounts[key].Address, diff --git a/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden b/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden index 624afa2e..3e338d4d 100644 --- a/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden +++ b/ui/pages/accounts/testdata/Test_Snapshot/Visible.golden @@ -1,7 +1,7 @@ ╭──Accounts────────────────────────────────────────────────────────────────────╮ │ Account Keys Status Expires Balance │ │─────────────────────────────────────────────────────────────────────────── │ -│ ABC 2 Offline NA 0 │ +│ ABC 2 Offline N/A 0 │ │ │ │ │ │ │ diff --git a/ui/pages/keys/model.go b/ui/pages/keys/model.go index c4049043..10b8167c 100644 --- a/ui/pages/keys/model.go +++ b/ui/pages/keys/model.go @@ -131,7 +131,7 @@ func (m ViewModel) makeRows(keys *[]api.ParticipationKey) *[]table.Row { } for _, key := range *keys { if key.Address == m.Address { - isActive := "NA" + isActive := "N/A" if activeId != nil && *activeId == key.Id { isActive = "YES" } diff --git a/ui/pages/keys/testdata/Test_Snapshot/Visible.golden b/ui/pages/keys/testdata/Test_Snapshot/Visible.golden index a515ebbd..3072c80d 100644 --- a/ui/pages/keys/testdata/Test_Snapshot/Visible.golden +++ b/ui/pages/keys/testdata/Test_Snapshot/Visible.golden @@ -1,8 +1,8 @@ ╭──Keys────────────────────────────────────────────────────────────────────────╮ │ ID Address Active Last Vote Last Block P… │ │─────────────────────────────────────────────────────────────────────────── │ -│ 123 ABC NA N/A N/A │ -│ 1234 ABC NA N/A N/A │ +│ 123 ABC N/A N/A N/A │ +│ 1234 ABC N/A N/A N/A │ │ │ │ │ │ │ From fc8b3f36a7f4c70da006dc312e0ac24a7a00cabc Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 27 Nov 2024 13:43:27 -0500 Subject: [PATCH 54/55] feat(ui): handle fast catchup --- internal/state.go | 12 +++++++++++- internal/status.go | 15 ++++++++++----- internal/status_test.go | 4 ++-- ui/pages/accounts/model.go | 2 +- ui/status.go | 12 +++++++++--- ui/viewport.go | 8 ++++---- 6 files changed, 37 insertions(+), 16 deletions(-) diff --git a/internal/state.go b/internal/state.go index 8a8ab483..1eae0c33 100644 --- a/internal/state.go +++ b/internal/state.go @@ -52,6 +52,16 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co if !s.Watching { break } + + if s.Status.State == FastCatchupState { + time.Sleep(time.Second * 10) + err := s.Status.Fetch(ctx, client, new(HttpPkg)) + if err != nil { + cb(nil, err) + } + continue + } + status, err := client.WaitForBlockWithResponse(ctx, int(lastRound)) s.waitAfterError(err, cb) if err != nil { @@ -65,7 +75,7 @@ func (s *StateModel) Watch(cb func(model *StateModel, err error), ctx context.Co s.Status.State = "Unknown" // Update Status - s.Status.Update(status.JSON200.LastRound, status.JSON200.CatchupTime, status.JSON200.UpgradeNodeVote) + s.Status.Update(status.JSON200.LastRound, status.JSON200.CatchupTime, status.JSON200.CatchpointAcquiredBlocks, status.JSON200.UpgradeNodeVote) // Fetch Keys s.UpdateKeys() diff --git a/internal/status.go b/internal/status.go index ee96ca8f..7ed0ba4e 100644 --- a/internal/status.go +++ b/internal/status.go @@ -10,8 +10,9 @@ import ( type State string const ( - SyncingState State = "SYNCING" - StableState State = "RUNNING" + FastCatchupState State = "FAST-CATCHUP" + SyncingState State = "SYNCING" + StableState State = "RUNNING" ) // StatusModel represents a status response from algod.Status @@ -28,10 +29,14 @@ type StatusModel struct { func (m *StatusModel) String() string { return fmt.Sprintf("\nLastRound: %d\n", m.LastRound) } -func (m *StatusModel) Update(lastRound int, catchupTime int, upgradeNodeVote *bool) { +func (m *StatusModel) Update(lastRound int, catchupTime int, aquiredBlocks *int, upgradeNodeVote *bool) { m.LastRound = uint64(lastRound) if catchupTime > 0 { - m.State = SyncingState + if aquiredBlocks != nil { + m.State = FastCatchupState + } else { + m.State = SyncingState + } } else { m.State = StableState } @@ -72,6 +77,6 @@ func (m *StatusModel) Fetch(ctx context.Context, client api.ClientWithResponsesI return fmt.Errorf("Status code %d: %s", s.StatusCode(), s.Status()) } - m.Update(s.JSON200.LastRound, s.JSON200.CatchupTime, s.JSON200.UpgradeNodeVote) + m.Update(s.JSON200.LastRound, s.JSON200.CatchupTime, s.JSON200.CatchpointAcquiredBlocks, s.JSON200.UpgradeNodeVote) return nil } diff --git a/internal/status_test.go b/internal/status_test.go index 0b21291d..04e984d3 100644 --- a/internal/status_test.go +++ b/internal/status_test.go @@ -14,7 +14,7 @@ func Test_StatusModel(t *testing.T) { } stale := true - m.Update(5, 10, &stale) + m.Update(5, 10, nil, &stale) if m.LastRound != 5 { t.Errorf("expected LastRound: 5, got %d", m.LastRound) @@ -23,7 +23,7 @@ func Test_StatusModel(t *testing.T) { t.Errorf("expected State: %s, got %s", SyncingState, m.State) } - m.Update(10, 0, &stale) + m.Update(10, 0, nil, &stale) if m.LastRound != 10 { t.Errorf("expected LastRound: 10, got %d", m.LastRound) } diff --git a/ui/pages/accounts/model.go b/ui/pages/accounts/model.go index e4a94e3d..c1922816 100644 --- a/ui/pages/accounts/model.go +++ b/ui/pages/accounts/model.go @@ -79,7 +79,7 @@ func (m ViewModel) makeRows() *[]table.Row { for key := range m.Data.Accounts { expires := m.Data.Accounts[key].Expires.String() - if m.Data.Status.State == internal.SyncingState { + if m.Data.Status.State != internal.StableState { expires = "SYNCING" } if !m.Data.Accounts[key].Expires.After(time.Now().Add(-(time.Hour * 24 * 365 * 50))) { diff --git a/ui/status.go b/ui/status.go index 6dd1e955..e092a0f0 100644 --- a/ui/status.go +++ b/ui/status.go @@ -81,14 +81,20 @@ func (m StatusViewModel) View() string { } beginning := style.Blue.Render(" Latest Round: ") + strconv.Itoa(int(m.Data.Status.LastRound)) - end := style.Yellow.Render(strings.ToUpper(string(m.Data.Status.State))) + " " + var end string + switch m.Data.Status.State { + case internal.StableState: + end = style.Green.Render(strings.ToUpper(string(m.Data.Status.State))) + " " + default: + end = style.Yellow.Render(strings.ToUpper(string(m.Data.Status.State))) + " " + } middle := strings.Repeat(" ", max(0, size-(lipgloss.Width(beginning)+lipgloss.Width(end)+2))) // Last Round row1 := lipgloss.JoinHorizontal(lipgloss.Left, beginning, middle, end) roundTime := fmt.Sprintf("%.2fs", float64(m.Data.Metrics.RoundTime)/float64(time.Second)) - if m.Data.Status.State == internal.SyncingState { + if m.Data.Status.State != internal.StableState { roundTime = "--" } beginning = style.Blue.Render(" Round time: ") + roundTime @@ -98,7 +104,7 @@ func (m StatusViewModel) View() string { row2 := lipgloss.JoinHorizontal(lipgloss.Left, beginning, middle, end) tps := fmt.Sprintf("%.2f", m.Data.Metrics.TPS) - if m.Data.Status.State == internal.SyncingState { + if m.Data.Status.State != internal.StableState { tps = "--" } beginning = style.Blue.Render(" TPS: ") + tps diff --git a/ui/viewport.go b/ui/viewport.go index 135ed669..4df61370 100644 --- a/ui/viewport.go +++ b/ui/viewport.go @@ -75,7 +75,7 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "g": // Only open modal when it is closed and not syncing - if !m.modal.Open && m.Data.Status.State != internal.SyncingState && m.Data.Metrics.RoundTime > 0 { + if !m.modal.Open && m.Data.Status.State == internal.StableState && m.Data.Metrics.RoundTime > 0 { address := "" selected := m.accountsPage.SelectedAccount() if selected != nil { @@ -86,7 +86,7 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Address: address, Type: app.GenerateModal, }) - } else if m.Data.Status.State == internal.SyncingState || m.Data.Metrics.RoundTime == 0 { + } else if m.Data.Status.State != internal.StableState || m.Data.Metrics.RoundTime == 0 { genErr := errors.New("Please wait for more data to sync before generating a key") m.modal, cmd = m.modal.HandleMessage(genErr) cmds = append(cmds, cmd) @@ -124,10 +124,10 @@ func (m ViewportViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.TerminalWidth = msg.Width m.TerminalHeight = msg.Height m.PageWidth = msg.Width - m.PageHeight = max(0, msg.Height-lipgloss.Height(m.headerView())-1) + m.PageHeight = max(0, msg.Height-lipgloss.Height(m.headerView())) modalMsg := tea.WindowSizeMsg{ - Width: m.PageWidth - 2, + Width: m.PageWidth, Height: m.PageHeight, } From 58d3e8d2e8fbc57849358a45debdbc552f8d784b Mon Sep 17 00:00:00 2001 From: Michael Feher Date: Wed, 27 Nov 2024 14:12:42 -0500 Subject: [PATCH 55/55] chore(ui): format date value --- ui/pages/accounts/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/pages/accounts/model.go b/ui/pages/accounts/model.go index c1922816..ce594d37 100644 --- a/ui/pages/accounts/model.go +++ b/ui/pages/accounts/model.go @@ -78,7 +78,7 @@ func (m ViewModel) makeRows() *[]table.Row { rows := make([]table.Row, 0) for key := range m.Data.Accounts { - expires := m.Data.Accounts[key].Expires.String() + expires := m.Data.Accounts[key].Expires.Format(time.RFC822) if m.Data.Status.State != internal.StableState { expires = "SYNCING" }