diff --git a/.claude/skills/build/SKILL.md b/.claude/skills/build/SKILL.md index 1f2698a..a314afa 100644 --- a/.claude/skills/build/SKILL.md +++ b/.claude/skills/build/SKILL.md @@ -116,6 +116,12 @@ Keep commits atomic — one logical change per commit. ### 7. Final quality check +> **CRITICAL — this step is mandatory and non-negotiable:** +> +> You MUST run all checks and they MUST all be green before this step is complete. +> Do NOT proceed to step 8 until every check passes. Do NOT report success while +> any check is failing. There are no exceptions. + Once all steps are implemented: ```bash @@ -123,8 +129,19 @@ make check # fmt + lint + test + vuln — ALL must pass make build # binary must compile cleanly ``` -If anything fails, fix it before proceeding. Do not declare the build complete with -failing checks. +**If anything fails:** +1. Read the failure output carefully. +2. Fix the root cause — do not suppress warnings, skip linters, or use `//nolint` to paper over issues. +3. Re-run `make check` from scratch. +4. Repeat until every check is green. + +**Red flags — if you are thinking any of these, stop:** +- "The lint warning is minor, I'll proceed anyway" +- "Tests pass locally, the lint failure is just style" +- "I'll note the failure and move on" +- "This check is flaky, I'll skip it" + +The only valid exit from step 7 is `make check` and `make build` both exiting with status 0, no failures, no skipped checks. ### 8. Update roadmap and issue diff --git a/.claude/skills/fix/SKILL.md b/.claude/skills/fix/SKILL.md new file mode 100644 index 0000000..0918f34 --- /dev/null +++ b/.claude/skills/fix/SKILL.md @@ -0,0 +1,163 @@ +--- +name: fix +description: Use when open QA findings need to be resolved after /qa has run on the current IN_REVIEW slice +--- + +# Fix — Resolve QA Findings + +## Overview + +Work through all OPEN findings in the current review document, highest severity first. +Each finding is fixed, verified with `make check`, then committed alongside the updated +review document. The revision is closed when all findings are FIXED. + +**Announce at start:** "Using the fix skill to resolve open findings." + +## Workflow + +```dot +digraph fix { + "Load context" -> "Find review document"; + "Find review document" -> "Parse OPEN findings"; + "Parse OPEN findings" -> "None?" [label="check"]; + "None?" -> "Report: nothing to fix" [label="yes"]; + "None?" -> "Sort by severity" [label="no"]; + "Sort by severity" -> "Create todo per finding"; + "Create todo per finding" -> "Take next finding"; + "Take next finding" -> "Implement fix"; + "Implement fix" -> "run make check"; + "run make check" -> "Green?" [label="result"]; + "Green?" -> "Mark FIXED in review doc" [label="yes"]; + "Green?" -> "Stop and report failure" [label="no"]; + "Mark FIXED in review doc" -> "Last finding?" [label="check"]; + "Last finding?" -> "Mark revision fixed" [label="yes"]; + "Last finding?" -> "Commit and continue" [label="no"]; + "Mark revision fixed" -> "Commit and continue"; + "Commit and continue" -> "Take next finding"; +} +``` + +## Steps + +### 1. Load context + +Read `docs/roadmap.md` to find the slice with `STATUS: IN_REVIEW`. Note the slice number +and name. + +Find the active revision file: +- List all files matching `docs/reviews/NNN-kebab-case-name/revision-*.md` +- Read the frontmatter of each (they are small — this is cheap) +- Find the file with `status: in_progress` — that is the revision to fix + +**Stop conditions:** +- No slice is IN_REVIEW → tell the user to run `/build` first +- Review directory does not exist → tell the user to run `/qa` first +- No revision file has `status: in_progress` → report "No active revision to fix. Run /qa + to create a new revision." + +Read the `in_progress` revision file in full. + +### 2. Parse OPEN findings + +Scan every table in the document for rows where the `Status` column is `OPEN`. +Ignore rows with `FIXED` or `DISCARDED`. + +For each OPEN finding collect: +- **ID** (e.g. `F1`, `I2`) +- **Severity** (`BLOCKER`, `HIGH`, `MED`, `LOW`) +- **Summary** (the table row's one-line description) +- **Detail** (the `**ID** path:line` paragraph below the table) + +If no OPEN findings exist, report "Nothing to fix — all findings are already FIXED or +DISCARDED." and stop. + +### 3. Sort and plan + +Order by severity: `BLOCKER` → `HIGH` → `MED` → `LOW`. +Within the same severity, preserve document order. + +Create a TodoWrite todo for each finding in sorted order. + +### 4. Fix each finding + +Repeat for each finding in order: + +#### a. Understand the change + +Read the detail paragraph and every file it references. Understand the exact change +required before touching any code. + +#### b. Implement the fix + +Make the minimal change that resolves the finding. Do **not**: +- Refactor surrounding code +- Fix other unrelated issues (even obvious ones — log them separately) +- Add features or cleanups beyond the finding's scope + +#### c. Run `make check` + +```bash +make check +``` + +**If `make check` fails:** Stop immediately. Do not mark the finding FIXED. Report: + +``` +Fix for [ID] failed make check. Stopping. + + + +Fix the issue manually, then run /fix again to continue. +``` + +Do not attempt the remaining findings. + +#### d. Mark the finding FIXED in the review document + +Update the finding's table row — change `OPEN` to `FIXED`: + +``` +| I2 | MED | OPEN | Duplicate testCtx vars in test package | +``` +→ +``` +| I2 | MED | FIXED | Duplicate testCtx vars in test package | +``` + +Do not change any other part of the document. + +#### e. If this is the last OPEN finding, update the revision status + +Update `status` in the revision file's frontmatter: +```yaml +status: done +``` + +#### f. Commit + +Stage only the modified source files and the revision file. Use this commit message +format: + +``` +fix(slice-NNN): resolve [ID] — + +Closes finding [ID] in docs/reviews/NNN-kebab-case-name/revision-R.md +``` + +Mark the todo for this finding complete. + +### 5. Report to user + +``` +Fix complete for slice NNN (revision R). + +Fixed N findings: +• [F1] HIGH — +• [I2] MED — + +make check passes. Review status: fixed. +Next step: run /qa again — a clean pass will close the slice. +``` + +If any finding was skipped because `make check` failed, say so clearly and do not claim +the review is fixed. diff --git a/.claude/skills/qa/SKILL.md b/.claude/skills/qa/SKILL.md index c4faac4..ef177dc 100644 --- a/.claude/skills/qa/SKILL.md +++ b/.claude/skills/qa/SKILL.md @@ -19,14 +19,13 @@ structured review document and report findings to the user. ```dot digraph qa { "Load context" -> "Find IN_REVIEW slice"; - "Find IN_REVIEW slice" -> "Create review document"; - "Create review document" -> "Phase 1: Smoke test + completeness audit"; + "Find IN_REVIEW slice" -> "Phase 1: Smoke test + completeness audit"; "Phase 1: Smoke test + completeness audit" -> "Phase 2: Implementation review"; "Phase 2: Implementation review" -> "Findings?" [label="assess"]; - "Findings?" -> "Write findings + update status" [label="yes"]; - "Findings?" -> "Write PASS + update status" [label="no"]; - "Write findings + update status" -> "Report to user"; - "Write PASS + update status" -> "Report to user"; + "Findings?" -> "Create revision file + write findings" [label="yes"]; + "Findings?" -> "Update roadmap to DONE" [label="no"]; + "Create revision file + write findings" -> "Report to user"; + "Update roadmap to DONE" -> "Report to user"; } ``` @@ -46,43 +45,7 @@ If no slice is `IN_REVIEW`, stop and tell the user to run `/build` first. Note the slice number, name, branch name. Open `docs/issues/NNN-kebab-case-name.md` and read the entire file — scope, file plan, implementation order, and verification commands. -### 3. Create or update the review document - -Path: `docs/reviews/NNN-kebab-case-name.md` - -**If the file does not exist**, create it with this structure: - -```markdown ---- -branch: feat/NNN-kebab-case-name -status: in_progress -revision: 1 ---- - -# Slice NNN — Name - -## Smoke test + completeness audit - -## Implementation review -``` - -**If the file already exists**, it is a re-review after fixes. Do not overwrite it: -1. Increment `revision: N` → `revision: N+1` in the frontmatter -2. Set `status: in_progress` -3. Append a new revision section at the end of the file: - -```markdown ---- - -## Revision N+1 -``` - -All findings from this run go under that heading, using the same table + detail -format. Previous revision sections are left untouched — they are the audit trail. - ---- - -### 4. Phase 1 — Smoke test + completeness audit +### 3. Phase 1 — Smoke test + completeness audit **Goal:** Verify the slice scope is fully implemented and that tests actually cover the specified behaviour. Look for gaps only — do not report style or code quality here. @@ -129,7 +92,7 @@ commands satisfied. --- -### 5. Phase 2 — Implementation review +### 4. Phase 2 — Implementation review **Goal:** Find bugs, anti-patterns, architecture deviations, and bad practices in the diff. @@ -171,10 +134,35 @@ No findings. Implementation follows architecture conventions and issue plan. --- -### 6. Write findings +### 5. Write findings (only if findings exist) + +**If there are no findings from either phase:** skip this step entirely. Do not create +any file. Proceed to step 6. + +**If there are findings:** create the revision file now. + +**Determine the next revision number:** +- If `docs/reviews/NNN-kebab-case-name/` does not exist, create it. Next revision is 1. +- If it exists, count the `revision-*.md` files inside. Next revision is count + 1. + +Create `docs/reviews/NNN-kebab-case-name/revision-N.md`: + +```markdown +--- +branch: feat/NNN-kebab-case-name +revision: N +status: in_progress +--- + +# Slice NNN — Name (Revision N) + +## Smoke test + completeness audit + +## Implementation review +``` -Use the **table index + detail paragraph** format. Keep detail paragraphs minimal — enough -for the implementation agent to act without ambiguity. +Then write findings using the **table index + detail paragraph** format. Keep detail +paragraphs minimal — enough for the implementation agent to act without ambiguity. **Severities:** `BLOCKER` · `HIGH` · `MED` · `LOW` **Statuses:** `OPEN` · `FIXED` · `DISCARDED` @@ -206,18 +194,18 @@ overwritten and `nil` is returned. Callers cannot detect cancellation. formatting helpers belong in `internal/format`. ``` -### 7. Update the review document +### 6. Update roadmap and report -Set `status: open` if there are any `OPEN` findings, otherwise `status: passed`. +**If findings exist:** do not touch the roadmap. The revision file already has +`status: in_progress`. Hand off to the user to run `/fix`. -Update the roadmap **only** if there are no open findings: +**If no findings:** update the roadmap: - Change `STATUS: IN_REVIEW` → `STATUS: DONE` - Change the issue frontmatter `status: in_review` → `status: done` -If findings exist, **do not touch the roadmap**. The build agent will re-run `/qa` after -fixes are applied and the review document will be updated. +No revision file was created — the last `done` revision stands as the final audit record. -### 8. Report to user +### 7. Report to user ``` QA review complete for slice NNN (revision N). @@ -230,8 +218,8 @@ Open findings (N total): • [F1] HIGH — • [I1] HIGH — -See docs/reviews/NNN-kebab-case-name.md for detail. -Next step: fix open findings, then run /qa again. +See docs/reviews/NNN-kebab-case-name/revision-N.md for detail. +Next step: run /fix to resolve findings, then run /qa again. All checks passed. Roadmap updated to DONE. Ready to open a PR. diff --git a/docs/issues/005-tab-bar-empty-portfolio-tab.md b/docs/issues/005-tab-bar-empty-portfolio-tab.md index ab57e18..a03046e 100644 --- a/docs/issues/005-tab-bar-empty-portfolio-tab.md +++ b/docs/issues/005-tab-bar-empty-portfolio-tab.md @@ -1,3 +1,371 @@ --- -status: pending +status: done +branch: feat/005-tab-bar-empty-portfolio-tab --- + +# Slice 5 — Tab bar + empty Portfolio tab + +## Context + +Slices 1–4 built a fully functional Markets tab: 100 coins from CoinGecko, scrollable table with cursor navigation, auto-refresh every 60 s via a 5 s ticker, error propagation through a typed `errMsg`, and a status bar with `Synced`/`Stale`/`Refreshing`/`error:` states. All of this currently lives in a single `internal/ui/app.go` (`AppModel`). + +Slice 5 introduces the shell needed to host two tabs. That means: +- Turning `AppModel` into a true root/router model (tab bar, tab switching, global quit, input suppression) +- Extracting all markets logic into a new `MarketsModel` in `markets.go` +- Creating a minimal `PortfolioModel` in `portfolio.go` (empty state only — full content comes in Slices 6–8) +- Moving test helpers and markets tests out of `app_test.go` and into dedicated files + +## Scope + +From the roadmap: +- Two tabs rendered at the top: `[ Markets ] [ Portfolio ]` +- `Tab` / `Shift+Tab` / `1` / `2` switch tabs +- Portfolio tab shows empty state: `"no portfolios — press n to create one"` +- Tab switching suppressed when `InputActive()` returns true (for future dialog use) +- **TDD:** tab switching logic, input suppression + +## Data model + +No schema changes. No new DB tables. + +--- + +## Files to create + +### `internal/ui/markets.go` + +All market-specific state, commands, messages, and rendering extracted from the current `app.go`. + +**Key types and signatures:** + +```go +// MarketsModel manages the Markets tab: coin list, auto-refresh, cursor, status bar. +type MarketsModel struct { + width int + height int + ctx context.Context + store store.Store + client api.CoinGeckoClient + coins []store.Coin + lastErr string + refreshing bool + lastRefreshed time.Time + cursor int + offset int +} + +// Messages (moved from app.go): +type coinsLoadedMsg struct{ coins []store.Coin } +type errMsg struct{ err error } +type pricesUpdatedMsg struct{ coins []store.Coin } +type tickMsg time.Time + +const staleThreshold = 5 * time.Minute + +func NewMarketsModel(ctx context.Context, s store.Store, c api.CoinGeckoClient) MarketsModel + +// Init returns the batched load + tick commands. +func (m MarketsModel) Init() tea.Cmd + +// update handles all markets messages. Returns typed MarketsModel, not tea.Model. +// Does NOT handle 'q' or Ctrl+C — those belong to AppModel. +func (m MarketsModel) update(msg tea.Msg) (MarketsModel, tea.Cmd) + +// InputActive always returns false — Markets has no text inputs. +func (m MarketsModel) InputActive() bool + +// View renders the coin table + status bar. Assumes height set via WindowSizeMsg. +func (m MarketsModel) View() string + +// Internal helpers (moved from app.go): +func (m MarketsModel) cmdLoad() tea.Cmd +func (m MarketsModel) cmdRefresh() tea.Cmd +func (m MarketsModel) statusRight() string +func (m MarketsModel) renderStatusBar() string +func (m *MarketsModel) moveCursor(delta int) +func (m *MarketsModel) adjustViewport() +func (m MarketsModel) tableHeight() int +``` + +Key points: +- `update` handles: `tea.WindowSizeMsg`, `tea.KeyMsg` (j/k/g/G/r/arrows), `tickMsg`, `coinsLoadedMsg`, `pricesUpdatedMsg`, `errMsg` +- `update` does **not** handle `q` or `Ctrl+C` — AppModel owns quit +- `View` does **not** check terminal size — AppModel guards that +- `tableHeight()` uses `m.height - 2` (header row + status bar row), same as today + +### `internal/ui/portfolio.go` + +```go +// PortfolioModel is the Portfolio tab. Slice 5: empty state only. +type PortfolioModel struct { + width int + height int +} + +func NewPortfolioModel() PortfolioModel + +// update handles tea.WindowSizeMsg; ignores all other messages. +func (m PortfolioModel) update(msg tea.Msg) (PortfolioModel, tea.Cmd) + +// InputActive always returns false in this slice (no dialogs yet). +func (m PortfolioModel) InputActive() bool + +// View renders the empty-state message. +// → "no portfolios — press n to create one" +func (m PortfolioModel) View() string +``` + +### `internal/ui/testhelpers_test.go` + +Shared test fixtures used by both `app_test.go` and `markets_test.go`. Extracted from the current `app_test.go` to avoid duplicate declarations: + +```go +// StubStore, StubAPI, makeCoins(), threeCoins(), setupMarketsModel() +``` + +`setupMarketsModel` replaces the current `setupCursorModel`, operating on `MarketsModel` directly. + +### `internal/ui/markets_test.go` + +All markets-specific tests from `app_test.go`, adapted to call `MarketsModel` directly: + +| Test | What it verifies | +|------|-----------------| +| `TestMarketsInit` | `Init()` returns a non-nil batched cmd | +| `TestMarketsCoinsLoadedMsg` | `coinsLoadedMsg` populates coins, sets `lastRefreshed`, renders coin names | +| `TestMarketsErrMsg` | `errMsg` sets `lastErr`, clears `refreshing`, view contains error text | +| `TestMarketsViewRendersLoading` | Empty coins → view contains `"loading"` | +| `TestMarketsViewRendersColumnHeaders` | View contains `#`, `Name`, `Ticker`, `Price (USD)`, `24h` | +| `TestMarketsViewRendersHintLine` | View contains `"j/k"` hint | +| `TestMarketsRefreshKey` | `r` with loaded coins → `refreshing=true`, non-nil cmd | +| `TestMarketsRefreshKeyIgnoredWhenAlreadyRefreshing` | `r` while `refreshing=true` → nil cmd | +| `TestMarketsRefreshKeyIgnoredWhenNoCoins` | `r` with no coins → nil cmd | +| `TestMarketsPricesUpdatedMsg` | Updates coins, clears `refreshing`, sets `lastRefreshed` | +| `TestMarketsViewShowsRefreshHint` | View contains `"r refresh"` | +| `TestMarketsCursorMovesDownOnJ` | `j` → cursor +1 | +| `TestMarketsCursorMovesUpOnK` | `k` → cursor -1 | +| `TestMarketsCursorClampsAtBottom` | `j` at last item → stays | +| `TestMarketsCursorClampsAtTop` | `k` at first item → stays | +| `TestMarketsCursorJumpsToTopOnG` | `g` → cursor = 0 | +| `TestMarketsCursorJumpsToBottomOnCapG` | `G` → cursor = last | +| `TestMarketsCursorMovesDownOnDownArrow` | `KeyDown` → cursor +1 | +| `TestMarketsCursorMovesUpOnUpArrow` | `KeyUp` → cursor -1 | +| `TestMarketsMoveCursorNoPanicOnEmptyCoins` | j/k with empty slice → no panic, cursor stays 0 | +| `TestMarketsCursorClampedAfterCoinsLoaded` | cursor beyond slice length → clamped | +| `TestMarketsTickMsgAlwaysReissuesTicker` | `tickMsg` always returns a cmd | +| `TestMarketsTickMsgBelow60sDoesNotRefresh` | 30s elapsed → `refreshing` stays false | +| `TestMarketsTickMsgAbove60sFiresRefresh` | 61s elapsed → `refreshing=true`, cmd returned | +| `TestMarketsTickMsgWhenAlreadyRefreshing` | Already refreshing → no second refresh | +| `TestMarketsTickMsgWhenNoCoins` | No coins → no refresh | +| `TestMarketsCoinsLoadedSetsLastRefreshed` | `lastRefreshed` set after load | +| `TestMarketsPricesUpdatedSetsLastRefreshed` | `lastRefreshed` set after price update | +| `TestMarketsStatusBarShowsLoading` | `statusRight()` returns `"loading..."` before first load | +| `TestMarketsStatusBarShowsRefreshing` | `statusRight()` returns `"Refreshing"` | +| `TestMarketsStatusBarShowsError` | `statusRight()` returns `"error: …"` | +| `TestMarketsStatusBarShowsSynced` | `statusRight()` returns `"Synced"` | +| `TestMarketsStatusBarShowsStale` | `statusRight()` returns `"Stale"` after 5+ minutes | +| `TestMarketsTableRendersWhileError` | Error state still shows coin rows | +| `TestMarketsStatusBarHasHintsOnLeft` | View contains `"j/k navigate"` | +| `TestMarketsInitFetchesHundredCoinsOnFirstLaunch` | Empty DB → `FetchMarkets(100)` called | +| `TestMarketsInitLoadsFromDBOnSubsequentLaunch` | 100 coins in DB → no API call | +| `TestMarketsInitRefetchesWhenDBPartiallySeeded` | <100 coins in DB → `FetchMarkets` called | +| `TestMarketsIgnoresOtherKeys` | Keys a/b/c/x/z/1/2/' ' → nil cmd (tab switching is AppModel's job) | +| `TestMarketsInputActiveFalse` | `InputActive()` always returns false | + +### `internal/ui/portfolio_test.go` + +| Test | What it verifies | +|------|-----------------| +| `TestNewPortfolioModel` | `NewPortfolioModel()` returns zero-value model | +| `TestPortfolioViewShowsEmptyState` | `View()` contains `"no portfolios"` | +| `TestPortfolioViewShowsCreateHint` | `View()` contains `"press n to create"` | +| `TestPortfolioInputActiveFalse` | `InputActive()` returns false | +| `TestPortfolioHandlesWindowSizeMsg` | `update(WindowSizeMsg{120, 39})` → `width=120, height=39` | +| `TestPortfolioUpdateIgnoresOtherMessages` | Arbitrary key msg → nil cmd, model unchanged | + +--- + +## Files to modify + +### `internal/ui/app.go` + +Becomes a lean root/router model: + +```go +type tab int + +const ( + tabMarkets tab = iota + tabPortfolio +) + +const tabCount = 2 + +// AppModel is the root Bubble Tea model. Owns tab bar, tab routing, global quit. +type AppModel struct { + width int + height int + activeTab tab + markets MarketsModel + portfolio PortfolioModel +} + +func NewAppModel(ctx context.Context, s store.Store, c api.CoinGeckoClient) AppModel { + return AppModel{ + activeTab: tabMarkets, + markets: NewMarketsModel(ctx, s, c), + portfolio: NewPortfolioModel(), + } +} + +// Init delegates to the Markets model's Init (only tab that does I/O on startup). +func (m AppModel) Init() tea.Cmd + +// Update handles: WindowSizeMsg (propagated to both children with height-1), +// tab switching keys (Tab/Shift+Tab/1/2), global quit (q/Ctrl+C), +// and delegates all other messages to the active child model. +// Tab switching is suppressed when the active child's InputActive() returns true. +// Ctrl+C always quits regardless of InputActive(). +func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) + +// View renders: terminal-too-small guard, then tab bar + active child view. +func (m AppModel) View() string + +// renderTabBar renders "[ Markets ] [ Portfolio ]" with active tab highlighted. +func (m AppModel) renderTabBar() string + +// activeInputActive returns whether the currently active child has a text input focused. +func (m AppModel) activeInputActive() bool +``` + +**`Update` logic:** + +```go +func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // WindowSizeMsg: store full size, forward adjusted size to both children + if ws, ok := msg.(tea.WindowSizeMsg); ok { + m.width = ws.Width + m.height = ws.Height + childMsg := tea.WindowSizeMsg{Width: ws.Width, Height: ws.Height - 1} + var cmd1, cmd2 tea.Cmd + m.markets, cmd1 = m.markets.update(childMsg) + m.portfolio, cmd2 = m.portfolio.update(childMsg) + return m, tea.Batch(cmd1, cmd2) + } + + if key, ok := msg.(tea.KeyMsg); ok { + // Ctrl+C always quits + if key.Type == tea.KeyCtrlC { + return m, tea.Quit + } + // Global keys only active when no text input is focused + if !m.activeInputActive() { + switch key.Type { + case tea.KeyTab: + m.activeTab = tab((int(m.activeTab) + 1) % tabCount) + return m, nil + case tea.KeyShiftTab: + m.activeTab = tab((int(m.activeTab) - 1 + tabCount) % tabCount) + return m, nil + case tea.KeyRunes: + switch string(key.Runes) { + case "q": + return m, tea.Quit + case "1": + m.activeTab = tabMarkets + return m, nil + case "2": + m.activeTab = tabPortfolio + return m, nil + } + } + } + } + + // Delegate all other messages to the active child + switch m.activeTab { + case tabMarkets: + var cmd tea.Cmd + m.markets, cmd = m.markets.update(msg) + return m, cmd + case tabPortfolio: + var cmd tea.Cmd + m.portfolio, cmd = m.portfolio.update(msg) + return m, cmd + } + return m, nil +} +``` + +**`View` logic:** + +```go +func (m AppModel) View() string { + if m.width < 100 || m.height < 30 { + return "Terminal too small — resize to at least 100×30" + } + tabBar := m.renderTabBar() + switch m.activeTab { + case tabMarkets: + return tabBar + "\n" + m.markets.View() + case tabPortfolio: + return tabBar + "\n" + m.portfolio.View() + } + return tabBar +} +``` + +### `internal/ui/app_test.go` + +Stripped down to only `AppModel`-level concerns. All markets tests removed (moved to `markets_test.go`). New tests: + +| Test | What it verifies | +|------|-----------------| +| `TestNewAppModel` | Creates model without panic, view is non-empty | +| `TestAppModelDefaultTabIsMarkets` | `activeTab == tabMarkets` after construction | +| `TestAppModelViewContainsTabBar` | View contains `"Markets"` and `"Portfolio"` labels | +| `TestQuitOnQ` | `q` → `tea.QuitMsg` | +| `TestQuitOnCtrlC` | `Ctrl+C` → `tea.QuitMsg` | +| `TestTabKeyAdvancesToPortfolio` | `Tab` from Markets → `activeTab == tabPortfolio` | +| `TestTabKeyWrapsToMarkets` | `Tab` from Portfolio → `activeTab == tabMarkets` | +| `TestShiftTabGoesBack` | `Shift+Tab` from Portfolio → `activeTab == tabMarkets` | +| `TestShiftTabWrapsToPortfolio` | `Shift+Tab` from Markets → `activeTab == tabPortfolio` | +| `TestOneKeySelectsMarkets` | `"1"` → `activeTab == tabMarkets` | +| `TestTwoKeySelectsPortfolio` | `"2"` → `activeTab == tabPortfolio` | +| `TestActiveInputActiveFalseByDefault` | `activeInputActive()` returns false when both children idle | +| `TestCtrlCQuitsFromPortfolioTab` | `Ctrl+C` quits from portfolio tab | +| `TestQuitFromPortfolioTab` | `q` quits from portfolio tab | +| `TestWindowSizeMsgSetsRootDimensions` | `WindowSizeMsg` sets `m.width` and `m.height` on `AppModel` | +| `TestWindowSizeMsgPropagatesAdjustedHeightToChildren` | After `WindowSizeMsg{120, 40}`, child markets model has `height == 39` | +| `TestInitReturnsBatchedCmd` | `Init()` returns non-nil cmd | + +Note: Direct input-suppression integration testing (Tab blocked during text input) is deferred to Slice 6 when `PortfolioModel` gains real text inputs that make `InputActive()` return true. + +--- + +## Implementation order + +1. **Create `internal/ui/testhelpers_test.go`** — move `StubStore`, `StubAPI`, `makeCoins`, `threeCoins` from `app_test.go`; add `setupMarketsModel` +2. **Write `internal/ui/portfolio_test.go`** — all portfolio tests (red) +3. **Create `internal/ui/portfolio.go`** — make portfolio tests green +4. **Write `internal/ui/markets_test.go`** — all markets tests adapted from `app_test.go` (red) +5. **Create `internal/ui/markets.go`** — extract markets logic from `app.go`; make markets tests green +6. **Update `internal/ui/app_test.go`** — remove duplicate tests, add new tab switching tests (red) +7. **Refactor `internal/ui/app.go`** — root model with tab bar, switching, delegation; make app tests green +8. **Run `make check`** — all tests pass, lint clean + +## Verification + +```bash +make check +# Expected: gofumpt clean, golangci-lint passes, all tests pass with race detector + +go run ./cmd/crypto-tracker +# Expected: +# - Tab bar visible at top with "[ Markets ] [ Portfolio ]" +# - Active tab visually highlighted +# - Tab / Shift+Tab / 1 / 2 switch between tabs +# - Portfolio tab shows: "no portfolios — press n to create one" +# - Markets tab behaves identically to before (table, auto-refresh, status bar) +# - q and Ctrl+C quit from either tab +``` diff --git a/docs/reviews/002-one-real-coin-full-pipeline.md b/docs/reviews/002-one-real-coin-full-pipeline/revision-1.md similarity index 100% rename from docs/reviews/002-one-real-coin-full-pipeline.md rename to docs/reviews/002-one-real-coin-full-pipeline/revision-1.md diff --git a/docs/reviews/003-markets-table-scrolling-formatting.md b/docs/reviews/003-markets-table-scrolling-formatting/revision-1.md similarity index 100% rename from docs/reviews/003-markets-table-scrolling-formatting.md rename to docs/reviews/003-markets-table-scrolling-formatting/revision-1.md diff --git a/docs/reviews/004-auto-refresh-status-bar.md b/docs/reviews/004-auto-refresh-status-bar/revision-1.md similarity index 100% rename from docs/reviews/004-auto-refresh-status-bar.md rename to docs/reviews/004-auto-refresh-status-bar/revision-1.md diff --git a/docs/reviews/005-tab-bar-empty-portfolio-tab/revision-1.md b/docs/reviews/005-tab-bar-empty-portfolio-tab/revision-1.md new file mode 100644 index 0000000..7cd7287 --- /dev/null +++ b/docs/reviews/005-tab-bar-empty-portfolio-tab/revision-1.md @@ -0,0 +1,47 @@ +--- +branch: feat/005-tab-bar-empty-portfolio-tab +status: done +revision: 1 +--- + +# Slice 5 — Tab bar + empty Portfolio tab + +## Smoke test + completeness audit + +`make check` passes clean: gofumpt, golangci-lint (0 issues), all tests (race detector clean), +govulncheck (no vulnerabilities). Binary builds successfully. + +Smoke test via TTY unavailable on this runner, but `go build` completes without error. + +All scope items from the issue are implemented: + +- Two tabs rendered at the top ✓ — `renderTabBar()` produces styled `Markets` / `Portfolio` labels +- `Tab` / `Shift+Tab` / `1` / `2` switching ✓ — `AppModel.Update` handles all four +- Portfolio empty state ✓ — `PortfolioModel.View()` returns `"no portfolios — press n to create one"` +- `InputActive()` suppression wired ✓ — `activeInputActive()` delegates to active child; integration test deferred to Slice 6 per plan +- All 40 markets tests, 17 app tests, and 6 portfolio tests from the issue plan are present and passing + +No gaps found. + +--- + +## Implementation review + +| ID | Sev | Status | Summary | +|----|-----|--------|---------| +| I1 | LOW | FIXED | `MarketsModel.View()` checks terminal size — plan said not to | +| I2 | LOW | FIXED | Duplicate context.Background() vars in test package | + +**I1** `internal/ui/markets.go:201–203` +`MarketsModel.View()` returns the "Terminal too small" message when dimensions are below +100×30. The issue plan explicitly states: *"View does NOT check terminal size — AppModel +guards that."* `AppModel.View()` already has the guard, so this is redundant. It adds +coupling to the terminal-size constraint in a child model that shouldn't care about it. +Fix: remove the guard from `MarketsModel.View()`. + +**I2** `internal/ui/testhelpers_test.go:87` and `internal/ui/markets_test.go:14` +Two separate `context.Background()` variables exist in the same test package: +`var testCtx` (testhelpers_test.go) used only by `setupMarketsModel`, and +`var marketsTestCtx` (markets_test.go) used throughout markets tests. +They are identical in value. Fix: remove `marketsTestCtx` from markets_test.go and +replace all uses with `testCtx`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8fe4d55..bac6f20 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -46,7 +46,7 @@ STATUS: DONE - **TDD:** tick/refresh state transitions, error display logic, stale detection ## Slice 5 — Tab bar + empty Portfolio tab -STATUS: PENDING +STATUS: DONE - Two tabs rendered at top, `Tab`/`Shift+Tab`/`1`/`2` to switch - Portfolio tab shows empty state: "no portfolios — press n to create one" diff --git a/internal/ui/app.go b/internal/ui/app.go index 77b6310..b8f0cf7 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -2,376 +2,158 @@ package ui import ( "context" - "fmt" - "strings" - "time" - "unicode/utf8" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/fredericomozzato/crypto_tracker/internal/api" - "github.com/fredericomozzato/crypto_tracker/internal/format" "github.com/fredericomozzato/crypto_tracker/internal/store" ) -// AppModel is the root Bubble Tea model for the crypto-tracker TUI. -type AppModel struct { - width int - height int - ctx context.Context - store store.Store - client api.CoinGeckoClient - coins []store.Coin - lastErr string - refreshing bool - lastRefreshed time.Time - cursor int - offset int -} +type tab int -// coinsLoadedMsg is sent when coins are successfully loaded from the API. -type coinsLoadedMsg struct { - coins []store.Coin -} +const ( + tabMarkets tab = iota + tabPortfolio +) -// errMsg is sent when an error occurs during data fetching. -type errMsg struct { - err error -} +const tabCount = 2 -// pricesUpdatedMsg is sent when prices are successfully refreshed. -type pricesUpdatedMsg struct { - coins []store.Coin +func tabBarActiveStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Bold(true). + Background(lipgloss.Color("#4A4A4A")). + Foreground(lipgloss.Color("#FFFFFF")) } -// tickMsg fires every 5 seconds from cmdTick. -type tickMsg time.Time +func tabBarInactiveStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Background(lipgloss.Color("#2A2A2A")). + Foreground(lipgloss.Color("#888888")) +} -// staleThreshold is the duration after which data is considered stale. -const staleThreshold = 5 * time.Minute +// AppModel is the root Bubble Tea model. Owns tab bar, tab routing, global quit. +type AppModel struct { + width int + height int + activeTab tab + markets MarketsModel + portfolio PortfolioModel +} // NewAppModel creates a new AppModel with the given dependencies. func NewAppModel(ctx context.Context, s store.Store, c api.CoinGeckoClient) AppModel { return AppModel{ - ctx: ctx, - store: s, - client: c, + activeTab: tabMarkets, + markets: NewMarketsModel(ctx, s, c), + portfolio: NewPortfolioModel(), } } -// cmdTick returns a command that fires a tickMsg after 5 seconds. -func cmdTick() tea.Cmd { - return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { - return tickMsg(t) - }) -} - -// Init is the Bubble Tea init command. Fetches initial coin data. +// Init delegates to the Markets model's Init (only tab that does I/O on startup). func (m AppModel) Init() tea.Cmd { - return tea.Batch(m.cmdLoad(), cmdTick()) + return m.markets.Init() } -// cmdLoad returns a command that loads coins from the database or fetches from API. -func (m AppModel) cmdLoad() tea.Cmd { - return func() tea.Msg { - existing, err := m.store.GetAllCoins(m.ctx) - if err != nil { - return errMsg{err: fmt.Errorf("loading coins: %w", err)} - } - if len(existing) >= 100 { - return coinsLoadedMsg{coins: existing} - } - - fetched, err := m.client.FetchMarkets(m.ctx, 100) - if err != nil { - return errMsg{err: err} - } - for _, c := range fetched { - if err := m.store.UpsertCoin(m.ctx, c); err != nil { - return errMsg{err: fmt.Errorf("upserting coin %s: %w", c.ApiID, err)} - } - } - stored, err := m.store.GetAllCoins(m.ctx) - if err != nil { - return errMsg{err: fmt.Errorf("loading coins after seed: %w", err)} - } - return coinsLoadedMsg{coins: stored} - } -} - -// Update handles Bubble Tea messages. +// Update handles WindowSizeMsg (propagated to children with height-1), +// tab switching keys (Tab/Shift+Tab/1/2), global quit (q/Ctrl+C), +// and delegates all other messages to the active child model. +// Tab switching is suppressed when the active child's InputActive() returns true. +// Ctrl+C always quits regardless of InputActive(). func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + childMsg := tea.WindowSizeMsg{Width: msg.Width, Height: msg.Height - 1} + var cmd1, cmd2 tea.Cmd + m.markets, cmd1 = m.markets.update(childMsg) + m.portfolio, cmd2 = m.portfolio.update(childMsg) + return m, tea.Batch(cmd1, cmd2) + case tea.KeyMsg: - switch msg.Type { - case tea.KeyCtrlC: + if msg.Type == tea.KeyCtrlC { return m, tea.Quit - case tea.KeyRunes: - for _, r := range msg.Runes { - switch r { - case 'q': - return m, tea.Quit - case 'r': - if !m.refreshing && len(m.coins) > 0 { - m.refreshing = true - return m, m.cmdRefresh() - } - case 'j': - m.moveCursor(+1) - case 'k': - m.moveCursor(-1) - case 'g': - m.cursor = 0 - m.adjustViewport() - case 'G': - if len(m.coins) > 0 { - m.cursor = len(m.coins) - 1 - m.adjustViewport() + } + if !m.activeInputActive() { + switch msg.Type { + case tea.KeyTab: + m.activeTab = tab((int(m.activeTab) + 1) % tabCount) + return m, nil + case tea.KeyShiftTab: + m.activeTab = tab((int(m.activeTab) - 1 + tabCount) % tabCount) + return m, nil + case tea.KeyRunes: + for _, r := range msg.Runes { + switch r { + case 'q': + return m, tea.Quit + case '1': + m.activeTab = tabMarkets + return m, nil + case '2': + m.activeTab = tabPortfolio + return m, nil } } } - case tea.KeyDown: - m.moveCursor(+1) - case tea.KeyUp: - m.moveCursor(-1) - } - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - case tickMsg: - cmds := []tea.Cmd{cmdTick()} - if !m.refreshing && len(m.coins) > 0 && time.Since(m.lastRefreshed) >= 60*time.Second { - m.refreshing = true - cmds = append(cmds, m.cmdRefresh()) - } - return m, tea.Batch(cmds...) - case coinsLoadedMsg: - m.coins = msg.coins - m.lastErr = "" - m.lastRefreshed = time.Now() - if m.cursor >= len(m.coins) && len(m.coins) > 0 { - m.cursor = len(m.coins) - 1 - } - if m.cursor < 0 { - m.cursor = 0 } - case pricesUpdatedMsg: - m.coins = msg.coins - m.refreshing = false - m.lastErr = "" - m.lastRefreshed = time.Now() - case errMsg: - m.lastErr = msg.err.Error() - m.refreshing = false } - return m, nil -} - -// cmdRefresh returns a command that refreshes prices for all loaded coins. -func (m AppModel) cmdRefresh() tea.Cmd { - return func() tea.Msg { - // Build list of API IDs from loaded coins - apiIDs := make([]string, len(m.coins)) - for i, c := range m.coins { - apiIDs[i] = c.ApiID - } - - // Fetch fresh prices - prices, err := m.client.FetchPrices(m.ctx, apiIDs) - if err != nil { - return errMsg{err: err} - } - - // Update prices in store - if err := m.store.UpdatePrices(m.ctx, prices); err != nil { - return errMsg{err: err} - } - - // Read back updated coins - updatedCoins, err := m.store.GetAllCoins(m.ctx) - if err != nil { - return errMsg{err: err} - } - - return pricesUpdatedMsg{coins: updatedCoins} + switch m.activeTab { + case tabMarkets: + var cmd tea.Cmd + m.markets, cmd = m.markets.update(msg) + return m, cmd + case tabPortfolio: + var cmd tea.Cmd + m.portfolio, cmd = m.portfolio.update(msg) + return m, cmd } + return m, nil } -// View renders the current state of the app. +// View renders the terminal-too-small guard, then tab bar + active child view. func (m AppModel) View() string { if m.width < 100 || m.height < 30 { return "Terminal too small — resize to at least 100×30" } - h := m.tableHeight() - end := m.offset + h - if end > len(m.coins) { - end = len(m.coins) - } - - if len(m.coins) == 0 { - return "loading...\n" + m.renderStatusBar() - } - - wRank := 4 - wName := 22 - wTicker := 8 - wPrice := 14 - wChange := 9 - - highlight := lipgloss.NewStyle().Reverse(true) - green := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")) - red := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")) - - header := fmt.Sprintf( - "%*s %-*s %-*s %*s %*s", - wRank, "#", - wName, "Name", - wTicker, "Ticker", - wPrice, "Price (USD)", - wChange, "24h", - ) - - var lines []string - lines = append(lines, header) - - for i := m.offset; i < end; i++ { - c := m.coins[i] - price := format.FmtPrice(c.Rate) - change := format.FmtChange(c.PriceChange) - - if c.PriceChange >= 0 { - change = green.Render(change) - } else { - change = red.Render(change) - } - - line := fmt.Sprintf( - "%*d %-*s %-*s %*s %*s", - wRank, c.MarketRank, - wName, truncate(c.Name, wName-2), - wTicker, c.Ticker, - wPrice, price, - wChange, change, - ) - - if i == m.cursor { - line = highlight.Render(line) - } - - lines = append(lines, line) - } - - for len(lines)-1 < h { - lines = append(lines, "") - } - - return strings.Join(lines, "\n") + "\n" + m.renderStatusBar() -} - -// statusRight returns the right-hand portion of the status bar. -func (m AppModel) statusRight() string { - if m.refreshing { - return "Refreshing" - } - if m.lastErr != "" { - return "error: " + m.lastErr - } - if m.lastRefreshed.IsZero() { - return "loading..." + tabBar := m.renderTabBar() + switch m.activeTab { + case tabMarkets: + return tabBar + "\n" + m.markets.View() + case tabPortfolio: + return tabBar + "\n" + m.portfolio.View() } - if time.Since(m.lastRefreshed) > staleThreshold { - return "Stale" - } - return "Synced" + return tabBar } -// renderStatusBar returns a two-sided status bar with hints on the left and -// sync status on the right. -func (m AppModel) renderStatusBar() string { - leftContent := "j/k navigate • g/G top/bottom • r refresh • q quit" - rightContent := m.statusRight() +// renderTabBar renders "[ Markets ] [ Portfolio ]" with active tab highlighted. +func (m AppModel) renderTabBar() string { + inactiveStyle := tabBarInactiveStyle() + activeStyle := tabBarActiveStyle() - grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) - errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4444")) - greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")) - yellowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")) + marketsLabel := " Markets " + portfolioLabel := " Portfolio " - var rightStyled string - switch rightContent { - case "Synced": - rightStyled = greenStyle.Render(rightContent) - case "Stale": - rightStyled = yellowStyle.Render(rightContent) - case "error: " + m.lastErr: - rightStyled = errStyle.Render(rightContent) - default: - rightStyled = grayStyle.Render(rightContent) + if m.activeTab == tabMarkets { + marketsLabel = activeStyle.Render(marketsLabel) + portfolioLabel = inactiveStyle.Render(portfolioLabel) + } else { + marketsLabel = inactiveStyle.Render(marketsLabel) + portfolioLabel = activeStyle.Render(portfolioLabel) } - leftStyled := grayStyle.Render(leftContent) - padding := m.width - lipgloss.Width(leftContent) - lipgloss.Width(rightContent) - if padding < 1 { - padding = 1 - } - return leftStyled + strings.Repeat(" ", padding) + rightStyled + return marketsLabel + " " + portfolioLabel } -// moveCursor moves the cursor by delta and adjusts the viewport. -func (m *AppModel) moveCursor(delta int) { - if len(m.coins) == 0 { - return - } - m.cursor += delta - if m.cursor < 0 { - m.cursor = 0 - } - if m.cursor >= len(m.coins) { - m.cursor = len(m.coins) - 1 - } - m.adjustViewport() -} - -// adjustViewport updates m.offset so the cursor row stays visible. -func (m *AppModel) adjustViewport() { - h := m.tableHeight() - if m.cursor < m.offset { - m.offset = m.cursor - } - if m.cursor >= m.offset+h { - m.offset = m.cursor - h + 1 - } - maxOff := len(m.coins) - h - if maxOff < 0 { - maxOff = 0 - } - if m.offset > maxOff { - m.offset = maxOff - } - if m.offset < 0 { - m.offset = 0 - } -} - -// tableHeight returns the number of rows available for coin data. -// Reserves 1 row for column headers and 1 row for the hint line. -func (m AppModel) tableHeight() int { - h := m.height - 2 - if h < 1 { - return 1 - } - return h -} - -// truncate returns s truncated to maxLen characters with an ellipsis. -func truncate(s string, maxLen int) string { - if utf8.RuneCountInString(s) <= maxLen { - return s - } - if maxLen <= 1 { - return "…" +// activeInputActive returns whether the currently active child has a text input focused. +func (m AppModel) activeInputActive() bool { + switch m.activeTab { + case tabMarkets: + return m.markets.InputActive() + case tabPortfolio: + return m.portfolio.InputActive() } - runes := []rune(s) - return string(runes[:maxLen-1]) + "…" + return false } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 6ded555..dbfac90 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -2,83 +2,46 @@ package ui import ( "context" - "errors" - "fmt" "strings" "testing" - "time" tea "github.com/charmbracelet/bubbletea" - "github.com/fredericomozzato/crypto_tracker/internal/store" ) -// StubStore implements store.Store for testing -type StubStore struct { - coins []store.Coin - err error -} - -func (s *StubStore) UpsertCoin(ctx context.Context, c store.Coin) error { - if s.err != nil { - return s.err - } - for i, existing := range s.coins { - if existing.ApiID == c.ApiID { - s.coins[i] = c - return nil - } - } - s.coins = append(s.coins, c) - return nil -} +func TestNewAppModel(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewAppModel(context.Background(), stub, api) -func (s *StubStore) GetAllCoins(ctx context.Context) ([]store.Coin, error) { - if s.err != nil { - return nil, s.err + view := m.View() + if view == "" { + t.Error("expected non-empty View from NewAppModel, got empty string") } - return s.coins, nil } -func (s *StubStore) Close() error { - return nil -} - -func (s *StubStore) UpdatePrices(ctx context.Context, prices map[string]float64) error { - return s.err -} - -// StubAPI implements api.CoinGeckoClient for testing -type StubAPI struct { - coins []store.Coin - prices map[string]float64 - err error - fetchMarketsCalls []int // records the limit arg each time FetchMarkets is called -} - -func (a *StubAPI) FetchMarkets(ctx context.Context, limit int) ([]store.Coin, error) { - a.fetchMarketsCalls = append(a.fetchMarketsCalls, limit) - if a.err != nil { - return nil, a.err - } - return a.coins, nil -} +func TestAppModelDefaultTabIsMarkets(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewAppModel(context.Background(), stub, api) -func (a *StubAPI) FetchPrices(ctx context.Context, apiIDs []string) (map[string]float64, error) { - if a.err != nil { - return nil, a.err + if m.activeTab != tabMarkets { + t.Errorf("expected activeTab to be tabMarkets (%d), got %d", tabMarkets, m.activeTab) } - return a.prices, nil } -func TestNewAppModel(t *testing.T) { +func TestAppModelViewContainsTabBar(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) - // Verify the model renders without panicking; a zero-dimension model - // should still produce output (the "too small" message). + m.width = 100 + m.height = 30 + view := m.View() - if view == "" { - t.Error("expected non-empty View from NewAppModel, got empty string") + if !strings.Contains(view, "Markets") { + t.Errorf("expected view to contain 'Markets', got %q", view) + } + if !strings.Contains(view, "Portfolio") { + t.Errorf("expected view to contain 'Portfolio', got %q", view) } } @@ -116,727 +79,230 @@ func TestQuitOnCtrlC(t *testing.T) { } } -func TestWindowSizeMsg(t *testing.T) { - stub := &StubStore{} - api := &StubAPI{} - m := NewAppModel(context.Background(), stub, api) - msg := tea.WindowSizeMsg{Width: 120, Height: 40} - updated, _ := m.Update(msg) - - model, ok := updated.(AppModel) - if !ok { - t.Fatalf("expected AppModel, got %T", updated) - } - - if model.width != 120 { - t.Errorf("expected width 120, got %d", model.width) - } - if model.height != 40 { - t.Errorf("expected height 40, got %d", model.height) - } -} - -func TestIgnoresOtherKeys(t *testing.T) { +func TestTabKeyAdvancesToPortfolio(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) - otherKeys := []rune{'a', 'b', 'c', 'x', 'z', '1', ' '} - for _, key := range otherKeys { - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{key}} - _, cmd := m.Update(msg) - if cmd != nil { - t.Errorf("expected nil cmd for key %q, got non-nil cmd", key) - } - } -} - -func TestInitReturnsBatchedCmd(t *testing.T) { - stub := &StubStore{} - api := &StubAPI{} - m := NewAppModel(context.Background(), stub, api) - cmd := m.Init() - - if cmd == nil { - t.Fatal("expected non-nil cmd from Init, got nil") - } -} - -func TestCoinsLoadedMsg(t *testing.T) { - s := &StubStore{} - api := &StubAPI{} - m := NewAppModel(context.Background(), s, api) m.width = 100 m.height = 30 - coin := store.Coin{ - ApiID: "bitcoin", - Name: "Bitcoin", - Ticker: "BTC", - Rate: 67000.00, - PriceChange: -1.23, - MarketRank: 1, + if m.activeTab != tabMarkets { + t.Fatalf("expected initial tab to be Markets, got %d", m.activeTab) } - coins := []store.Coin{coin} - - msg := coinsLoadedMsg{coins: coins} + msg := tea.KeyMsg{Type: tea.KeyTab} updated, _ := m.Update(msg) + model := updated.(AppModel) - model, ok := updated.(AppModel) - if !ok { - t.Fatalf("expected AppModel, got %T", updated) - } - - view := model.View() - if view == "" { - t.Fatal("expected non-empty view") - } - - if !strings.Contains(view, "Bitcoin") { - t.Errorf("expected view to contain 'Bitcoin', got %q", view) - } - - if !strings.Contains(view, "BTC") { - t.Errorf("expected view to contain 'BTC', got %q", view) - } - - if !strings.Contains(view, "$67,000.00") { - t.Errorf("expected view to contain '$67,000.00', got %q", view) - } - - if !strings.Contains(view, "Name") { - t.Errorf("expected view to contain column header 'Name', got %q", view) - } - if !strings.Contains(view, "Ticker") { - t.Errorf("expected view to contain column header 'Ticker', got %q", view) - } - if !strings.Contains(view, "Price (USD)") { - t.Errorf("expected view to contain column header 'Price (USD)', got %q", view) - } - if !strings.Contains(view, "24h") { - t.Errorf("expected view to contain column header '24h', got %q", view) + if model.activeTab != tabPortfolio { + t.Errorf("expected Tab to switch to Portfolio (%d), got %d", tabPortfolio, model.activeTab) } } -func TestErrMsg(t *testing.T) { +func TestTabKeyWrapsToMarkets(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) + m.activeTab = tabPortfolio m.width = 100 m.height = 30 - testErr := "connection failed" - msg := errMsg{err: errors.New(testErr)} + msg := tea.KeyMsg{Type: tea.KeyTab} updated, _ := m.Update(msg) + model := updated.(AppModel) - model, ok := updated.(AppModel) - if !ok { - t.Fatalf("expected AppModel, got %T", updated) - } - - view := model.View() - if view == "" { - t.Fatal("expected non-empty view") - } - - if !strings.Contains(view, testErr) { - t.Errorf("expected view to contain error %q, got %q", testErr, view) + if model.activeTab != tabMarkets { + t.Errorf("expected Tab to wrap to Markets (%d), got %d", tabMarkets, model.activeTab) } } -func TestViewRendersLoading(t *testing.T) { +func TestShiftTabGoesBackToMarkets(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) + m.activeTab = tabPortfolio m.width = 100 m.height = 30 - view := m.View() - if view == "" { - t.Fatal("expected non-empty view") - } - - if !strings.Contains(view, "loading") { - t.Errorf("expected view to contain 'loading', got %q", view) - } -} - -func TestRefreshKeyReturnsCmdWhenCoinsLoaded(t *testing.T) { - storeStub := &StubStore{coins: []store.Coin{{ApiID: "bitcoin", Name: "Bitcoin", Ticker: "BTC", Rate: 67000.00}}} - api := &StubAPI{prices: map[string]float64{"bitcoin": 68000.00}} - m := NewAppModel(context.Background(), storeStub, api) - m.width = 100 - m.height = 30 - - // First load coins - updated, _ := m.Update(coinsLoadedMsg{coins: storeStub.coins}) - m = updated.(AppModel) - - // Then press 'r' - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}} - updated, cmd := m.Update(msg) - - model, ok := updated.(AppModel) - if !ok { - t.Fatalf("expected AppModel, got %T", updated) - } - - if !model.refreshing { - t.Error("expected refreshing to be true") - } - - if cmd == nil { - t.Fatal("expected non-nil cmd when pressing r with coins loaded") - } -} - -func TestRefreshKeyIgnoredWhenAlreadyRefreshing(t *testing.T) { - stub := &StubStore{coins: []store.Coin{{ApiID: "bitcoin", Name: "Bitcoin", Ticker: "BTC", Rate: 67000.00}}} - api := &StubAPI{} - m := NewAppModel(context.Background(), stub, api) - m.width = 100 - m.height = 30 - m.coins = stub.coins - m.refreshing = true - - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}} - _, cmd := m.Update(msg) + msg := tea.KeyMsg{Type: tea.KeyShiftTab} + updated, _ := m.Update(msg) + model := updated.(AppModel) - if cmd != nil { - t.Error("expected nil cmd when already refreshing") + if model.activeTab != tabMarkets { + t.Errorf("expected Shift+Tab to go back to Markets (%d), got %d", tabMarkets, model.activeTab) } } -func TestRefreshKeyIgnoredWhenNoCoins(t *testing.T) { +func TestShiftTabWrapsToPortfolio(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) + m.activeTab = tabMarkets m.width = 100 m.height = 30 - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}} - _, cmd := m.Update(msg) - - if cmd != nil { - t.Error("expected nil cmd when no coins loaded") - } -} - -func TestPricesUpdatedMsg(t *testing.T) { - storeStub := &StubStore{coins: []store.Coin{{ApiID: "bitcoin", Name: "Bitcoin", Ticker: "BTC", Rate: 68000.00}}} - api := &StubAPI{} - m := NewAppModel(context.Background(), storeStub, api) - m.width = 100 - m.height = 30 - m.coins = []store.Coin{{ApiID: "bitcoin", Name: "Bitcoin", Ticker: "BTC", Rate: 67000.00}} - m.refreshing = true - - msg := pricesUpdatedMsg{coins: storeStub.coins} + msg := tea.KeyMsg{Type: tea.KeyShiftTab} updated, _ := m.Update(msg) - - model, ok := updated.(AppModel) - if !ok { - t.Fatalf("expected AppModel, got %T", updated) - } - - if model.refreshing { - t.Error("expected refreshing to be false after pricesUpdatedMsg") - } - - if len(model.coins) != 1 || model.coins[0].Rate != 68000.00 { - t.Errorf("expected updated coin with Rate 68000.00, got %v", model.coins) - } -} - -func TestViewShowsRefreshHint(t *testing.T) { - stub := &StubStore{coins: []store.Coin{{ApiID: "bitcoin", Name: "Bitcoin", Ticker: "BTC", Rate: 67000.00, MarketRank: 1}}} - api := &StubAPI{} - m := NewAppModel(context.Background(), stub, api) - m.width = 100 - m.height = 30 - m.coins = stub.coins - - view := m.View() - if !strings.Contains(view, "r refresh") { - t.Errorf("expected view to contain 'r refresh', got %q", view) - } -} - -func TestViewRendersColumnHeaders(t *testing.T) { - coins := threeCoins() - m := NewAppModel(context.Background(), &StubStore{coins: coins}, &StubAPI{}) - m.width = 120 - m.height = 40 - updated, _ := m.Update(coinsLoadedMsg{coins: coins}) - model := updated.(AppModel) - - view := model.View() - for _, col := range []string{"#", "Name", "Ticker", "Price (USD)", "24h"} { - if !strings.Contains(view, col) { - t.Errorf("expected view to contain header %q, got %q", col, view) - } - } -} - -func TestViewRendersHintLine(t *testing.T) { - coins := threeCoins() - m := NewAppModel(context.Background(), &StubStore{coins: coins}, &StubAPI{}) - m.width = 120 - m.height = 40 - updated, _ := m.Update(coinsLoadedMsg{coins: coins}) model := updated.(AppModel) - view := model.View() - if !strings.Contains(view, "j/k") { - t.Errorf("expected view to contain 'j/k', got %q", view) - } -} - -func TestInitFetchesHundredCoinsOnFirstLaunch(t *testing.T) { - coins := threeCoins() - api := &StubAPI{coins: coins} - s := &StubStore{} - m := NewAppModel(context.Background(), s, api) - - msg := executeInitBatch(t, m) - - loaded, ok := msg.(coinsLoadedMsg) - if !ok { - t.Fatalf("expected coinsLoadedMsg, got %T: %v", msg, msg) - } - if len(loaded.coins) != 3 { - t.Errorf("expected 3 coins, got %d", len(loaded.coins)) - } - if len(api.fetchMarketsCalls) != 1 || api.fetchMarketsCalls[0] != 100 { - t.Errorf("expected FetchMarkets called with 100, got %v", api.fetchMarketsCalls) - } -} - -func TestInitLoadsFromDBOnSubsequentLaunch(t *testing.T) { - coins := makeCoins(100) - api := &StubAPI{coins: coins} - s := &StubStore{coins: coins} - m := NewAppModel(context.Background(), s, api) - - msg := executeInitBatch(t, m) - - loaded, ok := msg.(coinsLoadedMsg) - if !ok { - t.Fatalf("expected coinsLoadedMsg, got %T: %v", msg, msg) - } - if len(loaded.coins) != 100 { - t.Errorf("expected 100 coins from DB, got %d", len(loaded.coins)) - } - if len(api.fetchMarketsCalls) != 0 { - t.Errorf("expected no API calls, got %v", api.fetchMarketsCalls) - } -} - -func TestInitRefetchesWhenDBPartiallySeeded(t *testing.T) { - partial := makeCoins(10) - full := makeCoins(100) - api := &StubAPI{coins: full} - s := &StubStore{coins: partial} - m := NewAppModel(context.Background(), s, api) - - msg := executeInitBatch(t, m) - - loaded, ok := msg.(coinsLoadedMsg) - if !ok { - t.Fatalf("expected coinsLoadedMsg, got %T: %v", msg, msg) - } - if len(loaded.coins) != 100 { - t.Errorf("expected 100 coins after refetch, got %d", len(loaded.coins)) - } - if len(api.fetchMarketsCalls) != 1 || api.fetchMarketsCalls[0] != 100 { - t.Errorf("expected FetchMarkets called with 100, got %v", api.fetchMarketsCalls) - } -} - -// executeInitBatch runs the batched command from Init() and returns the first -// coinsLoadedMsg or errMsg found. -func executeInitBatch(t *testing.T, m AppModel) tea.Msg { - t.Helper() - cmd := m.Init() - result := cmd() - batch, ok := result.(tea.BatchMsg) - if !ok { - return result - } - for _, c := range batch { - msg := c() - switch msg.(type) { - case coinsLoadedMsg, errMsg: - return msg - } - } - t.Fatal("no coinsLoadedMsg or errMsg in batch") - return nil -} - -func threeCoins() []store.Coin { - return makeCoins(3) -} - -func makeCoins(n int) []store.Coin { - coins := make([]store.Coin, n) - for i := range coins { - coins[i] = store.Coin{ - ApiID: fmt.Sprintf("coin-%d", i+1), - Name: fmt.Sprintf("Coin %d", i+1), - Ticker: fmt.Sprintf("C%d", i+1), - Rate: float64((i + 1) * 100), - MarketRank: i + 1, - } - } - return coins -} - -func setupCursorModel(t *testing.T, coins []store.Coin) AppModel { - t.Helper() - m := NewAppModel(context.Background(), &StubStore{coins: coins}, &StubAPI{}) - m.width = 120 - m.height = 40 - updated, _ := m.Update(coinsLoadedMsg{coins: coins}) - return updated.(AppModel) -} - -func TestCursorMovesDownOnJ(t *testing.T) { - m := setupCursorModel(t, threeCoins()) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) - m = updated.(AppModel) - if m.cursor != 1 { - t.Errorf("expected cursor 1 after 'j', got %d", m.cursor) - } -} - -func TestCursorMovesUpOnK(t *testing.T) { - m := setupCursorModel(t, threeCoins()) - m.cursor = 1 - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) - m = updated.(AppModel) - if m.cursor != 0 { - t.Errorf("expected cursor 0 after 'k', got %d", m.cursor) - } -} - -func TestCursorClampsAtBottom(t *testing.T) { - m := setupCursorModel(t, threeCoins()) - m.cursor = 2 - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) - m = updated.(AppModel) - if m.cursor != 2 { - t.Errorf("expected cursor 2 (clamped), got %d", m.cursor) - } -} - -func TestCursorClampsAtTop(t *testing.T) { - m := setupCursorModel(t, threeCoins()) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) - m = updated.(AppModel) - if m.cursor != 0 { - t.Errorf("expected cursor 0 (clamped), got %d", m.cursor) - } -} - -func TestCursorJumpsToTopOnG(t *testing.T) { - m := setupCursorModel(t, threeCoins()) - m.cursor = 2 - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) - m = updated.(AppModel) - if m.cursor != 0 { - t.Errorf("expected cursor 0 after 'g', got %d", m.cursor) - } -} - -func TestCursorJumpsToBottomOnCapG(t *testing.T) { - m := setupCursorModel(t, threeCoins()) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) - m = updated.(AppModel) - if m.cursor != 2 { - t.Errorf("expected cursor 2 after 'G', got %d", m.cursor) + if model.activeTab != tabPortfolio { + t.Errorf("expected Shift+Tab to wrap to Portfolio (%d), got %d", tabPortfolio, model.activeTab) } } -func TestCursorMovesDownOnDownArrow(t *testing.T) { - m := setupCursorModel(t, threeCoins()) - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(AppModel) - if m.cursor != 1 { - t.Errorf("expected cursor 1 after KeyDown, got %d", m.cursor) - } -} - -func TestCursorMovesUpOnUpArrow(t *testing.T) { - m := setupCursorModel(t, threeCoins()) - m.cursor = 1 - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) - m = updated.(AppModel) - if m.cursor != 0 { - t.Errorf("expected cursor 0 after KeyUp, got %d", m.cursor) - } -} - -func TestMoveCursorNoPanicOnEmptyCoins(t *testing.T) { +func TestOneKeySelectsMarkets(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) + m.activeTab = tabPortfolio m.width = 100 m.height = 30 - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}} + updated, _ := m.Update(msg) model := updated.(AppModel) - if model.cursor != 0 { - t.Errorf("expected cursor 0 on empty coins, got %d", model.cursor) - } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) - model = updated.(AppModel) - if model.cursor != 0 { - t.Errorf("expected cursor 0 on empty coins after 'k', got %d", model.cursor) + if model.activeTab != tabMarkets { + t.Errorf("expected '1' to select Markets (%d), got %d", tabMarkets, model.activeTab) } } -func TestCursorClampedAfterCoinsLoaded(t *testing.T) { +func TestTwoKeySelectsPortfolio(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) m.width = 100 m.height = 30 - m.cursor = 5 - updated, _ := m.Update(coinsLoadedMsg{coins: threeCoins()}) - model := updated.(AppModel) - if model.cursor != 2 { - t.Errorf("expected cursor clamped to 2 (last index), got %d", model.cursor) - } -} - -func TestTickMsgAlwaysReissuesTicker(t *testing.T) { - stub := &StubStore{} - api := &StubAPI{} - m := NewAppModel(context.Background(), stub, api) - - updated, cmd := m.Update(tickMsg(time.Now())) - model := updated.(AppModel) - - if cmd == nil { - t.Error("expected non-nil cmd from tickMsg (ticker should be re-armed)") - } - _ = model -} - -func TestTickMsgBelow60sDoesNotRefresh(t *testing.T) { - stub := &StubStore{} - api := &StubAPI{} - m := NewAppModel(context.Background(), stub, api) - m.coins = threeCoins() - m.lastRefreshed = time.Now().Add(-30 * time.Second) - - updated, _ := m.Update(tickMsg(time.Now())) - model := updated.(AppModel) - - if model.refreshing { - t.Error("expected refreshing to stay false when 60s not elapsed") - } -} - -func TestTickMsgAbove60sFiresRefresh(t *testing.T) { - stub := &StubStore{coins: threeCoins()} - api := &StubAPI{prices: map[string]float64{"coin-1": 100.0}} - m := NewAppModel(context.Background(), stub, api) - m.coins = threeCoins() - m.lastRefreshed = time.Now().Add(-61 * time.Second) - m.refreshing = false - - updated, cmd := m.Update(tickMsg(time.Now())) + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}} + updated, _ := m.Update(msg) model := updated.(AppModel) - if !model.refreshing { - t.Error("expected refreshing to be true when 60+ seconds elapsed") - } - if cmd == nil { - t.Error("expected non-nil cmd when refresh fires") + if model.activeTab != tabPortfolio { + t.Errorf("expected '2' to select Portfolio (%d), got %d", tabPortfolio, model.activeTab) } } -func TestTickMsgWhenAlreadyRefreshing(t *testing.T) { +func TestCtrlCQuitsFromPortfolioTab(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) - m.coins = threeCoins() - m.lastRefreshed = time.Now().Add(-61 * time.Second) - m.refreshing = true + m.activeTab = tabPortfolio + m.width = 100 + m.height = 30 - updated, cmd := m.Update(tickMsg(time.Now())) - model := updated.(AppModel) + msg := tea.KeyMsg{Type: tea.KeyCtrlC} + _, cmd := m.Update(msg) - if !model.refreshing { - t.Error("expected refreshing to remain true") - } if cmd == nil { - t.Error("expected non-nil cmd (ticker re-arm) even when already refreshing") + t.Fatal("expected non-nil cmd when pressing Ctrl+C from Portfolio tab") } -} -func TestTickMsgWhenNoCoins(t *testing.T) { - stub := &StubStore{} - api := &StubAPI{} - m := NewAppModel(context.Background(), stub, api) - m.lastRefreshed = time.Now().Add(-61 * time.Second) - - updated, _ := m.Update(tickMsg(time.Now())) - model := updated.(AppModel) - - if model.refreshing { - t.Error("expected no refresh when no coins loaded") + result := cmd() + if _, ok := result.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", result) } } -func TestCoinsLoadedSetsLastRefreshed(t *testing.T) { +func TestQuitFromPortfolioTab(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) + m.activeTab = tabPortfolio m.width = 100 m.height = 30 - if !m.lastRefreshed.IsZero() { - t.Error("expected lastRefreshed to be zero initially") - } + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}} + _, cmd := m.Update(msg) - updated, _ := m.Update(coinsLoadedMsg{coins: threeCoins()}) - model := updated.(AppModel) + if cmd == nil { + t.Fatal("expected non-nil cmd when pressing 'q' from Portfolio tab") + } - if model.lastRefreshed.IsZero() { - t.Error("expected lastRefreshed to be set after coinsLoadedMsg") + result := cmd() + if _, ok := result.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", result) } } -func TestPricesUpdatedSetsLastRefreshed(t *testing.T) { +func TestWindowSizeMsgSetsRootDimensions(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) - m.width = 100 - m.height = 30 - m.coins = threeCoins() - m.refreshing = true - updated, _ := m.Update(pricesUpdatedMsg{coins: threeCoins()}) + msg := tea.WindowSizeMsg{Width: 120, Height: 40} + updated, _ := m.Update(msg) model := updated.(AppModel) - if model.lastRefreshed.IsZero() { - t.Error("expected lastRefreshed to be set after pricesUpdatedMsg") + if model.width != 120 { + t.Errorf("expected width 120, got %d", model.width) } - if model.refreshing { - t.Error("expected refreshing to be false after pricesUpdatedMsg") + if model.height != 40 { + t.Errorf("expected height 40, got %d", model.height) } } -func TestStatusBarShowsLoading(t *testing.T) { +func TestWindowSizeMsgPropagatesAdjustedHeightToChildren(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) - m.width = 100 - m.height = 30 - - right := m.statusRight() - if right != "loading..." { - t.Errorf("expected statusRight 'loading...', got %q", right) - } -} -func TestStatusBarShowsRefreshing(t *testing.T) { - stub := &StubStore{} - api := &StubAPI{} - m := NewAppModel(context.Background(), stub, api) - m.width = 100 - m.height = 30 - m.coins = threeCoins() - m.refreshing = true - m.lastRefreshed = time.Now() + msg := tea.WindowSizeMsg{Width: 120, Height: 40} + updated, _ := m.Update(msg) + model := updated.(AppModel) - right := m.statusRight() - if right != "Refreshing" { - t.Errorf("expected statusRight 'Refreshing', got %q", right) + if model.markets.height != 39 { + t.Errorf("expected markets.height 39 (40-1 for tab bar), got %d", model.markets.height) } -} - -func TestStatusBarShowsError(t *testing.T) { - stub := &StubStore{} - api := &StubAPI{} - m := NewAppModel(context.Background(), stub, api) - m.width = 100 - m.height = 30 - m.coins = threeCoins() - m.lastErr = "some error" - m.lastRefreshed = time.Now() - - right := m.statusRight() - if right != "error: some error" { - t.Errorf("expected statusRight 'error: some error', got %q", right) + if model.portfolio.height != 39 { + t.Errorf("expected portfolio.height 39 (40-1 for tab bar), got %d", model.portfolio.height) } } -func TestStatusBarShowsSynced(t *testing.T) { +func TestInitReturnsBatchedCmd(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) - m.width = 100 - m.height = 30 - m.coins = threeCoins() - m.lastRefreshed = time.Now() + cmd := m.Init() - right := m.statusRight() - if right != "Synced" { - t.Errorf("expected statusRight 'Synced', got %q", right) + if cmd == nil { + t.Fatal("expected non-nil cmd from Init, got nil") } } -func TestStatusBarShowsStale(t *testing.T) { +func TestActiveInputActiveFalse(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) - m.width = 100 - m.height = 30 - m.coins = threeCoins() - m.lastRefreshed = time.Now().Add(-6 * time.Minute) - right := m.statusRight() - if right != "Stale" { - t.Errorf("expected statusRight 'Stale', got %q", right) + if m.activeInputActive() { + t.Error("expected activeInputActive() to return false by default") } } -func TestTableRendersWhileError(t *testing.T) { +func TestViewShowsTerminalTooSmall(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) - m.width = 100 - m.height = 30 - m.coins = threeCoins() - m.lastRefreshed = time.Now() - m.lastErr = "network failed" + m.width = 50 + m.height = 20 view := m.View() - if !strings.Contains(view, "Coin 1") { - t.Error("expected view to contain coin names even with error") - } - if !strings.Contains(view, "error: network failed") { - t.Error("expected view to contain error text") + if !strings.Contains(view, "Terminal too small") { + t.Errorf("expected view to contain 'Terminal too small', got %q", view) } } -func TestStatusBarHasHintsOnLeft(t *testing.T) { +func TestViewShowsPortfolioEmptyState(t *testing.T) { stub := &StubStore{} api := &StubAPI{} m := NewAppModel(context.Background(), stub, api) + m.activeTab = tabPortfolio m.width = 100 m.height = 30 - m.coins = threeCoins() - m.lastRefreshed = time.Now() view := m.View() - if !strings.Contains(view, "j/k navigate") { - t.Errorf("expected view to contain 'j/k navigate', got %q", view) + if !strings.Contains(view, "no portfolios") { + t.Errorf("expected view to contain 'no portfolios' when on Portfolio tab, got %q", view) } } diff --git a/internal/ui/markets.go b/internal/ui/markets.go new file mode 100644 index 0000000..b28a8a9 --- /dev/null +++ b/internal/ui/markets.go @@ -0,0 +1,371 @@ +package ui + +import ( + "context" + "fmt" + "strings" + "time" + "unicode/utf8" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/fredericomozzato/crypto_tracker/internal/api" + "github.com/fredericomozzato/crypto_tracker/internal/format" + "github.com/fredericomozzato/crypto_tracker/internal/store" +) + +// MarketsModel manages the Markets tab: coin list, auto-refresh, cursor, status bar. +type MarketsModel struct { + width int + height int + ctx context.Context + store store.Store + client api.CoinGeckoClient + coins []store.Coin + lastErr string + refreshing bool + lastRefreshed time.Time + cursor int + offset int +} + +// coinsLoadedMsg is sent when coins are successfully loaded from the API. +type coinsLoadedMsg struct { + coins []store.Coin +} + +// errMsg is sent when an error occurs during data fetching. +type errMsg struct { + err error +} + +// pricesUpdatedMsg is sent when prices are successfully refreshed. +type pricesUpdatedMsg struct { + coins []store.Coin +} + +// tickMsg fires every 5 seconds from cmdTick. +type tickMsg time.Time + +// staleThreshold is the duration after which data is considered stale. +const staleThreshold = 5 * time.Minute + +// NewMarketsModel creates a new MarketsModel with the given dependencies. +func NewMarketsModel(ctx context.Context, s store.Store, c api.CoinGeckoClient) MarketsModel { + return MarketsModel{ + ctx: ctx, + store: s, + client: c, + } +} + +// Init returns the batched load + tick commands. +func (m MarketsModel) Init() tea.Cmd { + return tea.Batch(m.cmdLoad(), cmdTick()) +} + +// cmdTick returns a command that fires a tickMsg after 5 seconds. +func cmdTick() tea.Cmd { + return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +// cmdLoad returns a command that loads coins from the database or fetches from API. +func (m MarketsModel) cmdLoad() tea.Cmd { + return func() tea.Msg { + existing, err := m.store.GetAllCoins(m.ctx) + if err != nil { + return errMsg{err: fmt.Errorf("loading coins: %w", err)} + } + if len(existing) >= 100 { + return coinsLoadedMsg{coins: existing} + } + + fetched, err := m.client.FetchMarkets(m.ctx, 100) + if err != nil { + return errMsg{err: err} + } + for _, c := range fetched { + if err := m.store.UpsertCoin(m.ctx, c); err != nil { + return errMsg{err: fmt.Errorf("upserting coin %s: %w", c.ApiID, err)} + } + } + stored, err := m.store.GetAllCoins(m.ctx) + if err != nil { + return errMsg{err: fmt.Errorf("loading coins after seed: %w", err)} + } + return coinsLoadedMsg{coins: stored} + } +} + +// update handles all markets messages. Returns typed MarketsModel, not tea.Model. +// Does NOT handle 'q' or Ctrl+C — those belong to AppModel. +func (m MarketsModel) update(msg tea.Msg) (MarketsModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyRunes: + for _, r := range msg.Runes { + switch r { + case 'r': + if !m.refreshing && len(m.coins) > 0 { + m.refreshing = true + return m, m.cmdRefresh() + } + case 'j': + m.moveCursor(+1) + case 'k': + m.moveCursor(-1) + case 'g': + m.cursor = 0 + m.adjustViewport() + case 'G': + if len(m.coins) > 0 { + m.cursor = len(m.coins) - 1 + m.adjustViewport() + } + } + } + case tea.KeyDown: + m.moveCursor(+1) + case tea.KeyUp: + m.moveCursor(-1) + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tickMsg: + cmds := []tea.Cmd{cmdTick()} + if !m.refreshing && len(m.coins) > 0 && time.Since(m.lastRefreshed) >= 60*time.Second { + m.refreshing = true + cmds = append(cmds, m.cmdRefresh()) + } + return m, tea.Batch(cmds...) + case coinsLoadedMsg: + m.coins = msg.coins + m.lastErr = "" + m.lastRefreshed = time.Now() + if m.cursor >= len(m.coins) && len(m.coins) > 0 { + m.cursor = len(m.coins) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + case pricesUpdatedMsg: + m.coins = msg.coins + m.refreshing = false + m.lastErr = "" + m.lastRefreshed = time.Now() + case errMsg: + m.lastErr = msg.err.Error() + m.refreshing = false + } + + return m, nil +} + +// cmdRefresh returns a command that refreshes prices for all loaded coins. +func (m MarketsModel) cmdRefresh() tea.Cmd { + return func() tea.Msg { + apiIDs := make([]string, len(m.coins)) + for i, c := range m.coins { + apiIDs[i] = c.ApiID + } + + prices, err := m.client.FetchPrices(m.ctx, apiIDs) + if err != nil { + return errMsg{err: err} + } + + if err := m.store.UpdatePrices(m.ctx, prices); err != nil { + return errMsg{err: err} + } + + updatedCoins, err := m.store.GetAllCoins(m.ctx) + if err != nil { + return errMsg{err: err} + } + + return pricesUpdatedMsg{coins: updatedCoins} + } +} + +// InputActive always returns false — Markets has no text inputs. +func (m MarketsModel) InputActive() bool { + return false +} + +// View renders the coin table + status bar. Assumes height set via WindowSizeMsg. +func (m MarketsModel) View() string { + h := m.tableHeight() + end := m.offset + h + if end > len(m.coins) { + end = len(m.coins) + } + + if len(m.coins) == 0 { + return "loading...\n" + m.renderStatusBar() + } + + wRank := 4 + wName := 22 + wTicker := 8 + wPrice := 14 + wChange := 9 + + highlight := lipgloss.NewStyle().Reverse(true) + green := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")) + red := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")) + + header := fmt.Sprintf( + "%*s %-*s %-*s %*s %*s", + wRank, "#", + wName, "Name", + wTicker, "Ticker", + wPrice, "Price (USD)", + wChange, "24h", + ) + + var lines []string + lines = append(lines, header) + + for i := m.offset; i < end; i++ { + c := m.coins[i] + price := format.FmtPrice(c.Rate) + change := format.FmtChange(c.PriceChange) + + if c.PriceChange >= 0 { + change = green.Render(change) + } else { + change = red.Render(change) + } + + line := fmt.Sprintf( + "%*d %-*s %-*s %*s %*s", + wRank, c.MarketRank, + wName, truncate(c.Name, wName-2), + wTicker, c.Ticker, + wPrice, price, + wChange, change, + ) + + if i == m.cursor { + line = highlight.Render(line) + } + + lines = append(lines, line) + } + + for len(lines)-1 < h { + lines = append(lines, "") + } + + return strings.Join(lines, "\n") + "\n" + m.renderStatusBar() +} + +// statusRight returns the right-hand portion of the status bar. +func (m MarketsModel) statusRight() string { + if m.refreshing { + return "Refreshing" + } + if m.lastErr != "" { + return "error: " + m.lastErr + } + if m.lastRefreshed.IsZero() { + return "loading..." + } + if time.Since(m.lastRefreshed) > staleThreshold { + return "Stale" + } + return "Synced" +} + +// renderStatusBar returns a two-sided status bar with hints on the left and +// sync status on the right. +func (m MarketsModel) renderStatusBar() string { + leftContent := "j/k navigate • g/G top/bottom • r refresh • q quit" + rightContent := m.statusRight() + + grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF4444")) + greenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF00")) + yellowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD700")) + + var rightStyled string + switch rightContent { + case "Synced": + rightStyled = greenStyle.Render(rightContent) + case "Stale": + rightStyled = yellowStyle.Render(rightContent) + case "error: " + m.lastErr: + rightStyled = errStyle.Render(rightContent) + default: + rightStyled = grayStyle.Render(rightContent) + } + + leftStyled := grayStyle.Render(leftContent) + padding := m.width - lipgloss.Width(leftContent) - lipgloss.Width(rightContent) + if padding < 1 { + padding = 1 + } + return leftStyled + strings.Repeat(" ", padding) + rightStyled +} + +// moveCursor moves the cursor by delta and adjusts the viewport. +func (m *MarketsModel) moveCursor(delta int) { + if len(m.coins) == 0 { + return + } + m.cursor += delta + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= len(m.coins) { + m.cursor = len(m.coins) - 1 + } + m.adjustViewport() +} + +// adjustViewport updates m.offset so the cursor row stays visible. +func (m *MarketsModel) adjustViewport() { + h := m.tableHeight() + if m.cursor < m.offset { + m.offset = m.cursor + } + if m.cursor >= m.offset+h { + m.offset = m.cursor - h + 1 + } + maxOff := len(m.coins) - h + if maxOff < 0 { + maxOff = 0 + } + if m.offset > maxOff { + m.offset = maxOff + } + if m.offset < 0 { + m.offset = 0 + } +} + +// tableHeight returns the number of rows available for coin data. +// Reserves 1 row for column headers and 1 row for the status bar. +func (m MarketsModel) tableHeight() int { + h := m.height - 2 + if h < 1 { + return 1 + } + return h +} + +// truncate returns s truncated to maxLen characters with an ellipsis. +func truncate(s string, maxLen int) string { + if utf8.RuneCountInString(s) <= maxLen { + return s + } + if maxLen <= 1 { + return "…" + } + runes := []rune(s) + return string(runes[:maxLen-1]) + "…" +} diff --git a/internal/ui/markets_test.go b/internal/ui/markets_test.go new file mode 100644 index 0000000..9071a37 --- /dev/null +++ b/internal/ui/markets_test.go @@ -0,0 +1,654 @@ +package ui + +import ( + "errors" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/fredericomozzato/crypto_tracker/internal/store" +) + +func TestMarketsInitReturnsBatchedCmd(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + cmd := m.Init() + + if cmd == nil { + t.Fatal("expected non-nil cmd from Init, got nil") + } +} + +func TestMarketsCoinsLoadedMsg(t *testing.T) { + s := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, s, api) + m.width = 100 + m.height = 30 + + coin := store.Coin{ + ApiID: "bitcoin", + Name: "Bitcoin", + Ticker: "BTC", + Rate: 67000.00, + PriceChange: -1.23, + MarketRank: 1, + } + + coins := []store.Coin{coin} + + msg := coinsLoadedMsg{coins: coins} + updated, _ := m.update(msg) + + view := updated.View() + if view == "" { + t.Fatal("expected non-empty view") + } + + if !strings.Contains(view, "Bitcoin") { + t.Errorf("expected view to contain 'Bitcoin', got %q", view) + } + + if !strings.Contains(view, "BTC") { + t.Errorf("expected view to contain 'BTC', got %q", view) + } + + if !strings.Contains(view, "$67,000.00") { + t.Errorf("expected view to contain '$67,000.00', got %q", view) + } + + if !strings.Contains(view, "Name") { + t.Errorf("expected view to contain column header 'Name', got %q", view) + } + if !strings.Contains(view, "Ticker") { + t.Errorf("expected view to contain column header 'Ticker', got %q", view) + } + if !strings.Contains(view, "Price (USD)") { + t.Errorf("expected view to contain column header 'Price (USD)', got %q", view) + } + if !strings.Contains(view, "24h") { + t.Errorf("expected view to contain column header '24h', got %q", view) + } +} + +func TestMarketsErrMsg(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + + testErr := "connection failed" + msg := errMsg{err: errors.New(testErr)} + updated, _ := m.update(msg) + + view := updated.View() + if view == "" { + t.Fatal("expected non-empty view") + } + + if !strings.Contains(view, testErr) { + t.Errorf("expected view to contain error %q, got %q", testErr, view) + } +} + +func TestMarketsViewRendersLoading(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + + view := m.View() + if view == "" { + t.Fatal("expected non-empty view") + } + + if !strings.Contains(view, "loading") { + t.Errorf("expected view to contain 'loading', got %q", view) + } +} + +func TestMarketsViewRendersColumnHeaders(t *testing.T) { + coins := threeCoins() + m := NewMarketsModel(testCtx, &StubStore{coins: coins}, &StubAPI{}) + m.width = 120 + m.height = 40 + updated, _ := m.update(coinsLoadedMsg{coins: coins}) + + view := updated.View() + for _, col := range []string{"#", "Name", "Ticker", "Price (USD)", "24h"} { + if !strings.Contains(view, col) { + t.Errorf("expected view to contain header %q, got %q", col, view) + } + } +} + +func TestMarketsViewRendersHintLine(t *testing.T) { + coins := threeCoins() + m := NewMarketsModel(testCtx, &StubStore{coins: coins}, &StubAPI{}) + m.width = 120 + m.height = 40 + updated, _ := m.update(coinsLoadedMsg{coins: coins}) + + view := updated.View() + if !strings.Contains(view, "j/k") { + t.Errorf("expected view to contain 'j/k', got %q", view) + } +} + +func TestMarketsRefreshKeyReturnsCmdWhenCoinsLoaded(t *testing.T) { + storeStub := &StubStore{coins: []store.Coin{{ApiID: "bitcoin", Name: "Bitcoin", Ticker: "BTC", Rate: 67000.00}}} + api := &StubAPI{prices: map[string]float64{"bitcoin": 68000.00}} + m := NewMarketsModel(testCtx, storeStub, api) + m.width = 100 + m.height = 30 + + updated, _ := m.update(coinsLoadedMsg{coins: storeStub.coins}) + m = updated + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}} + updated, cmd := m.update(msg) + + if !updated.refreshing { + t.Error("expected refreshing to be true") + } + + if cmd == nil { + t.Fatal("expected non-nil cmd when pressing r with coins loaded") + } +} + +func TestMarketsRefreshKeyIgnoredWhenAlreadyRefreshing(t *testing.T) { + stub := &StubStore{coins: []store.Coin{{ApiID: "bitcoin", Name: "Bitcoin", Ticker: "BTC", Rate: 67000.00}}} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + m.coins = stub.coins + m.refreshing = true + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}} + _, cmd := m.update(msg) + + if cmd != nil { + t.Error("expected nil cmd when already refreshing") + } +} + +func TestMarketsRefreshKeyIgnoredWhenNoCoins(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}} + _, cmd := m.update(msg) + + if cmd != nil { + t.Error("expected nil cmd when no coins loaded") + } +} + +func TestMarketsPricesUpdatedMsg(t *testing.T) { + storeStub := &StubStore{coins: []store.Coin{{ApiID: "bitcoin", Name: "Bitcoin", Ticker: "BTC", Rate: 68000.00}}} + api := &StubAPI{} + m := NewMarketsModel(testCtx, storeStub, api) + m.width = 100 + m.height = 30 + m.coins = []store.Coin{{ApiID: "bitcoin", Name: "Bitcoin", Ticker: "BTC", Rate: 67000.00}} + m.refreshing = true + + msg := pricesUpdatedMsg{coins: storeStub.coins} + updated, _ := m.update(msg) + + if updated.refreshing { + t.Error("expected refreshing to be false after pricesUpdatedMsg") + } + + if len(updated.coins) != 1 || updated.coins[0].Rate != 68000.00 { + t.Errorf("expected updated coin with Rate 68000.00, got %v", updated.coins) + } +} + +func TestMarketsViewShowsRefreshHint(t *testing.T) { + stub := &StubStore{coins: []store.Coin{{ApiID: "bitcoin", Name: "Bitcoin", Ticker: "BTC", Rate: 67000.00, MarketRank: 1}}} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + m.coins = stub.coins + + view := m.View() + if !strings.Contains(view, "r refresh") { + t.Errorf("expected view to contain 'r refresh', got %q", view) + } +} + +func TestMarketsCursorMovesDownOnJ(t *testing.T) { + m := setupMarketsModel(t, threeCoins()) + updated, _ := m.update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + if updated.cursor != 1 { + t.Errorf("expected cursor 1 after 'j', got %d", updated.cursor) + } +} + +func TestMarketsCursorMovesUpOnK(t *testing.T) { + m := setupMarketsModel(t, threeCoins()) + m.cursor = 1 + updated, _ := m.update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + if updated.cursor != 0 { + t.Errorf("expected cursor 0 after 'k', got %d", updated.cursor) + } +} + +func TestMarketsCursorClampsAtBottom(t *testing.T) { + m := setupMarketsModel(t, threeCoins()) + m.cursor = 2 + updated, _ := m.update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + if updated.cursor != 2 { + t.Errorf("expected cursor 2 (clamped), got %d", updated.cursor) + } +} + +func TestMarketsCursorClampsAtTop(t *testing.T) { + m := setupMarketsModel(t, threeCoins()) + updated, _ := m.update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + if updated.cursor != 0 { + t.Errorf("expected cursor 0 (clamped), got %d", updated.cursor) + } +} + +func TestMarketsCursorJumpsToTopOnG(t *testing.T) { + m := setupMarketsModel(t, threeCoins()) + m.cursor = 2 + updated, _ := m.update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + if updated.cursor != 0 { + t.Errorf("expected cursor 0 after 'g', got %d", updated.cursor) + } +} + +func TestMarketsCursorJumpsToBottomOnCapG(t *testing.T) { + m := setupMarketsModel(t, threeCoins()) + updated, _ := m.update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + if updated.cursor != 2 { + t.Errorf("expected cursor 2 after 'G', got %d", updated.cursor) + } +} + +func TestMarketsCursorMovesDownOnDownArrow(t *testing.T) { + m := setupMarketsModel(t, threeCoins()) + updated, _ := m.update(tea.KeyMsg{Type: tea.KeyDown}) + if updated.cursor != 1 { + t.Errorf("expected cursor 1 after KeyDown, got %d", updated.cursor) + } +} + +func TestMarketsCursorMovesUpOnUpArrow(t *testing.T) { + m := setupMarketsModel(t, threeCoins()) + m.cursor = 1 + updated, _ := m.update(tea.KeyMsg{Type: tea.KeyUp}) + if updated.cursor != 0 { + t.Errorf("expected cursor 0 after KeyUp, got %d", updated.cursor) + } +} + +func TestMarketsMoveCursorNoPanicOnEmptyCoins(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + + updated, _ := m.update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + if updated.cursor != 0 { + t.Errorf("expected cursor 0 on empty coins, got %d", updated.cursor) + } + + updated, _ = m.update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + if updated.cursor != 0 { + t.Errorf("expected cursor 0 on empty coins after 'k', got %d", updated.cursor) + } +} + +func TestMarketsCursorClampedAfterCoinsLoaded(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + + m.cursor = 5 + updated, _ := m.update(coinsLoadedMsg{coins: threeCoins()}) + if updated.cursor != 2 { + t.Errorf("expected cursor clamped to 2 (last index), got %d", updated.cursor) + } +} + +func TestMarketsTickMsgAlwaysReissuesTicker(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + + updated, cmd := m.update(tickMsg(time.Now())) + + if cmd == nil { + t.Error("expected non-nil cmd from tickMsg (ticker should be re-armed)") + } + _ = updated +} + +func TestMarketsTickMsgBelow60sDoesNotRefresh(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.coins = threeCoins() + m.lastRefreshed = time.Now().Add(-30 * time.Second) + + updated, _ := m.update(tickMsg(time.Now())) + + if updated.refreshing { + t.Error("expected refreshing to stay false when 60s not elapsed") + } +} + +func TestMarketsTickMsgAbove60sFiresRefresh(t *testing.T) { + stub := &StubStore{coins: threeCoins()} + api := &StubAPI{prices: map[string]float64{"coin-1": 100.0}} + m := NewMarketsModel(testCtx, stub, api) + m.coins = threeCoins() + m.lastRefreshed = time.Now().Add(-61 * time.Second) + m.refreshing = false + + updated, cmd := m.update(tickMsg(time.Now())) + + if !updated.refreshing { + t.Error("expected refreshing to be true when 60+ seconds elapsed") + } + if cmd == nil { + t.Error("expected non-nil cmd when refresh fires") + } +} + +func TestMarketsTickMsgWhenAlreadyRefreshing(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.coins = threeCoins() + m.lastRefreshed = time.Now().Add(-61 * time.Second) + m.refreshing = true + + updated, cmd := m.update(tickMsg(time.Now())) + + if !updated.refreshing { + t.Error("expected refreshing to remain true") + } + if cmd == nil { + t.Error("expected non-nil cmd (ticker re-arm) even when already refreshing") + } +} + +func TestMarketsTickMsgWhenNoCoins(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.lastRefreshed = time.Now().Add(-61 * time.Second) + + updated, _ := m.update(tickMsg(time.Now())) + + if updated.refreshing { + t.Error("expected no refresh when no coins loaded") + } +} + +func TestMarketsCoinsLoadedSetsLastRefreshed(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + + if !m.lastRefreshed.IsZero() { + t.Error("expected lastRefreshed to be zero initially") + } + + updated, _ := m.update(coinsLoadedMsg{coins: threeCoins()}) + + if updated.lastRefreshed.IsZero() { + t.Error("expected lastRefreshed to be set after coinsLoadedMsg") + } +} + +func TestMarketsPricesUpdatedSetsLastRefreshed(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + m.coins = threeCoins() + m.refreshing = true + + updated, _ := m.update(pricesUpdatedMsg{coins: threeCoins()}) + + if updated.lastRefreshed.IsZero() { + t.Error("expected lastRefreshed to be set after pricesUpdatedMsg") + } + if updated.refreshing { + t.Error("expected refreshing to be false after pricesUpdatedMsg") + } +} + +func TestMarketsStatusBarShowsLoading(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + + right := m.statusRight() + if right != "loading..." { + t.Errorf("expected statusRight 'loading...', got %q", right) + } +} + +func TestMarketsStatusBarShowsRefreshing(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + m.coins = threeCoins() + m.refreshing = true + m.lastRefreshed = time.Now() + + right := m.statusRight() + if right != "Refreshing" { + t.Errorf("expected statusRight 'Refreshing', got %q", right) + } +} + +func TestMarketsStatusBarShowsError(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + m.coins = threeCoins() + m.lastErr = "some error" + m.lastRefreshed = time.Now() + + right := m.statusRight() + if right != "error: some error" { + t.Errorf("expected statusRight 'error: some error', got %q", right) + } +} + +func TestMarketsStatusBarShowsSynced(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + m.coins = threeCoins() + m.lastRefreshed = time.Now() + + right := m.statusRight() + if right != "Synced" { + t.Errorf("expected statusRight 'Synced', got %q", right) + } +} + +func TestMarketsStatusBarShowsStale(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + m.coins = threeCoins() + m.lastRefreshed = time.Now().Add(-6 * time.Minute) + + right := m.statusRight() + if right != "Stale" { + t.Errorf("expected statusRight 'Stale', got %q", right) + } +} + +func TestMarketsTableRendersWhileError(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + m.coins = threeCoins() + m.lastRefreshed = time.Now() + m.lastErr = "network failed" + + view := m.View() + if !strings.Contains(view, "Coin 1") { + t.Error("expected view to contain coin names even with error") + } + if !strings.Contains(view, "error: network failed") { + t.Error("expected view to contain error text") + } +} + +func TestMarketsStatusBarHasHintsOnLeft(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + m.width = 100 + m.height = 30 + m.coins = threeCoins() + m.lastRefreshed = time.Now() + + view := m.View() + if !strings.Contains(view, "j/k navigate") { + t.Errorf("expected view to contain 'j/k navigate', got %q", view) + } +} + +func TestMarketsInitFetchesHundredCoinsOnFirstLaunch(t *testing.T) { + coins := threeCoins() + api := &StubAPI{coins: coins} + s := &StubStore{} + m := NewMarketsModel(testCtx, s, api) + + msg := executeInitBatchForMarkets(t, m) + + loaded, ok := msg.(coinsLoadedMsg) + if !ok { + t.Fatalf("expected coinsLoadedMsg, got %T: %v", msg, msg) + } + if len(loaded.coins) != 3 { + t.Errorf("expected 3 coins, got %d", len(loaded.coins)) + } + if len(api.fetchMarketsCalls) != 1 || api.fetchMarketsCalls[0] != 100 { + t.Errorf("expected FetchMarkets called with 100, got %v", api.fetchMarketsCalls) + } +} + +func TestMarketsInitLoadsFromDBOnSubsequentLaunch(t *testing.T) { + coins := makeCoins(100) + api := &StubAPI{coins: coins} + s := &StubStore{coins: coins} + m := NewMarketsModel(testCtx, s, api) + + msg := executeInitBatchForMarkets(t, m) + + loaded, ok := msg.(coinsLoadedMsg) + if !ok { + t.Fatalf("expected coinsLoadedMsg, got %T: %v", msg, msg) + } + if len(loaded.coins) != 100 { + t.Errorf("expected 100 coins from DB, got %d", len(loaded.coins)) + } + if len(api.fetchMarketsCalls) != 0 { + t.Errorf("expected no API calls, got %v", api.fetchMarketsCalls) + } +} + +func TestMarketsInitRefetchesWhenDBPartiallySeeded(t *testing.T) { + partial := makeCoins(10) + full := makeCoins(100) + api := &StubAPI{coins: full} + s := &StubStore{coins: partial} + m := NewMarketsModel(testCtx, s, api) + + msg := executeInitBatchForMarkets(t, m) + + loaded, ok := msg.(coinsLoadedMsg) + if !ok { + t.Fatalf("expected coinsLoadedMsg, got %T: %v", msg, msg) + } + if len(loaded.coins) != 100 { + t.Errorf("expected 100 coins after refetch, got %d", len(loaded.coins)) + } + if len(api.fetchMarketsCalls) != 1 || api.fetchMarketsCalls[0] != 100 { + t.Errorf("expected FetchMarkets called with 100, got %v", api.fetchMarketsCalls) + } +} + +func TestMarketsIgnoresOtherKeys(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + otherKeys := []rune{'a', 'b', 'c', 'x', 'z', '1', '2', ' '} + for _, key := range otherKeys { + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{key}} + _, cmd := m.update(msg) + if cmd != nil { + t.Errorf("expected nil cmd for key %q, got non-nil cmd", key) + } + } +} + +func TestMarketsInputActiveFalse(t *testing.T) { + stub := &StubStore{} + api := &StubAPI{} + m := NewMarketsModel(testCtx, stub, api) + if m.InputActive() { + t.Error("expected InputActive() to return false for MarketsModel") + } +} + +func executeInitBatchForMarkets(t *testing.T, m MarketsModel) tea.Msg { + t.Helper() + cmd := m.Init() + result := cmd() + batch, ok := result.(tea.BatchMsg) + if !ok { + return result + } + for _, c := range batch { + msg := c() + switch msg.(type) { + case coinsLoadedMsg, errMsg: + return msg + } + } + t.Fatal("no coinsLoadedMsg or errMsg in batch") + return nil +} diff --git a/internal/ui/portfolio.go b/internal/ui/portfolio.go new file mode 100644 index 0000000..2b8c9e9 --- /dev/null +++ b/internal/ui/portfolio.go @@ -0,0 +1,35 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// PortfolioModel is the Portfolio tab. Slice 5: empty state only. +type PortfolioModel struct { + width int + height int +} + +// NewPortfolioModel creates a new PortfolioModel with zero values. +func NewPortfolioModel() PortfolioModel { + return PortfolioModel{} +} + +// update handles tea.WindowSizeMsg; ignores all other messages. +func (m PortfolioModel) update(msg tea.Msg) (PortfolioModel, tea.Cmd) { + if ws, ok := msg.(tea.WindowSizeMsg); ok { + m.width = ws.Width + m.height = ws.Height + } + return m, nil +} + +// InputActive always returns false in this slice (no dialogs yet). +func (m PortfolioModel) InputActive() bool { + return false +} + +// View renders the empty-state message. +func (m PortfolioModel) View() string { + return "no portfolios — press n to create one" +} diff --git a/internal/ui/portfolio_test.go b/internal/ui/portfolio_test.go new file mode 100644 index 0000000..0038b4d --- /dev/null +++ b/internal/ui/portfolio_test.go @@ -0,0 +1,72 @@ +package ui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestNewPortfolioModel(t *testing.T) { + m := NewPortfolioModel() + if m.width != 0 || m.height != 0 { + t.Errorf("expected zero-value model, got width=%d, height=%d", m.width, m.height) + } +} + +func TestPortfolioViewShowsEmptyState(t *testing.T) { + m := NewPortfolioModel() + m.width = 100 + m.height = 30 + + view := m.View() + if !strings.Contains(view, "no portfolios") { + t.Errorf("expected view to contain 'no portfolios', got %q", view) + } +} + +func TestPortfolioViewShowsCreateHint(t *testing.T) { + m := NewPortfolioModel() + m.width = 100 + m.height = 30 + + view := m.View() + if !strings.Contains(view, "press n to create") { + t.Errorf("expected view to contain 'press n to create', got %q", view) + } +} + +func TestPortfolioInputActiveFalse(t *testing.T) { + m := NewPortfolioModel() + if m.InputActive() { + t.Error("expected InputActive() to return false for empty portfolio model") + } +} + +func TestPortfolioHandlesWindowSizeMsg(t *testing.T) { + m := NewPortfolioModel() + updated, _ := m.update(tea.WindowSizeMsg{Width: 120, Height: 39}) + + if updated.width != 120 { + t.Errorf("expected width 120, got %d", updated.width) + } + if updated.height != 39 { + t.Errorf("expected height 39, got %d", updated.height) + } +} + +func TestPortfolioUpdateIgnoresOtherMessages(t *testing.T) { + m := NewPortfolioModel() + m.width = 100 + m.height = 30 + + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}} + updated, cmd := m.update(msg) + + if cmd != nil { + t.Errorf("expected nil cmd for arbitrary key, got %v", cmd) + } + if updated.width != 100 || updated.height != 30 { + t.Error("expected model dimensions to be unchanged") + } +} diff --git a/internal/ui/testhelpers_test.go b/internal/ui/testhelpers_test.go new file mode 100644 index 0000000..15c0ea5 --- /dev/null +++ b/internal/ui/testhelpers_test.go @@ -0,0 +1,97 @@ +package ui + +import ( + "context" + "fmt" + "testing" + + "github.com/fredericomozzato/crypto_tracker/internal/store" +) + +// StubStore implements store.Store for testing +type StubStore struct { + coins []store.Coin + err error +} + +func (s *StubStore) UpsertCoin(ctx context.Context, c store.Coin) error { + if s.err != nil { + return s.err + } + for i, existing := range s.coins { + if existing.ApiID == c.ApiID { + s.coins[i] = c + return nil + } + } + s.coins = append(s.coins, c) + return nil +} + +func (s *StubStore) GetAllCoins(ctx context.Context) ([]store.Coin, error) { + if s.err != nil { + return nil, s.err + } + return s.coins, nil +} + +func (s *StubStore) Close() error { + return nil +} + +func (s *StubStore) UpdatePrices(ctx context.Context, prices map[string]float64) error { + return s.err +} + +// StubAPI implements api.CoinGeckoClient for testing +type StubAPI struct { + coins []store.Coin + prices map[string]float64 + err error + fetchMarketsCalls []int +} + +func (a *StubAPI) FetchMarkets(ctx context.Context, limit int) ([]store.Coin, error) { + a.fetchMarketsCalls = append(a.fetchMarketsCalls, limit) + if a.err != nil { + return nil, a.err + } + return a.coins, nil +} + +func (a *StubAPI) FetchPrices(ctx context.Context, apiIDs []string) (map[string]float64, error) { + if a.err != nil { + return nil, a.err + } + return a.prices, nil +} + +func threeCoins() []store.Coin { + return makeCoins(3) +} + +func makeCoins(n int) []store.Coin { + coins := make([]store.Coin, n) + for i := range coins { + coins[i] = store.Coin{ + ApiID: fmt.Sprintf("coin-%d", i+1), + Name: fmt.Sprintf("Coin %d", i+1), + Ticker: fmt.Sprintf("C%d", i+1), + Rate: float64((i + 1) * 100), + MarketRank: i + 1, + } + } + return coins +} + +var testCtx = context.Background() + +// setupMarketsModel creates a MarketsModel with pre-loaded coins for cursor tests. +func setupMarketsModel(t *testing.T, coins []store.Coin) MarketsModel { + t.Helper() + m := NewMarketsModel(testCtx, &StubStore{coins: coins}, &StubAPI{}) + m.width = 120 + m.height = 40 + updated, _ := m.update(coinsLoadedMsg{coins: coins}) + return updated +}