diff --git a/pkg/tui/integration_test.go b/pkg/tui/integration_test.go index 1fea78a..668c0bc 100644 --- a/pkg/tui/integration_test.go +++ b/pkg/tui/integration_test.go @@ -468,3 +468,35 @@ func TestProgram_EmptyToFirstChart(t *testing.T) { t.Errorf("final frame missing footer text 'z zoom' — program may not have rendered:\n%s", final) } } + +// TestProgram_ProjectsSlide_NoDeadlock drives the slide through a real program +// loop (teatest PTY) to catch bubbletea ordering races the in-process Update +// tests can't: press 'p', wait for a frame showing the projects title, quit +// cleanly. Asserts showProjects committed via FinalModel. +func TestProgram_ProjectsSlide_NoDeadlock(t *testing.T) { + c := newSeededCache(t) + m := New(Deps{Cache: c}) + tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(120, 40)) + t.Cleanup(func() { _ = tm.Quit() }) + + tm.Send(tea.WindowSizeMsg{Width: 120, Height: 40}) + tm.Send(RefreshMsg{}) + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) // show slide (hidden by default) + + teatest.WaitFor(t, tm.Output(), + func(bts []byte) bool { return bytes.Contains(bts, []byte("Projects")) }, + teatest.WithDuration(2*time.Second), + ) + + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second)) + + final := tm.FinalModel(t, teatest.WithFinalTimeout(1*time.Second)) + fm, ok := final.(Model) + if !ok { + t.Fatalf("FinalModel: got %T", final) + } + if !fm.showProjects { + t.Error("after show slide: showProjects=false, want true") + } +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 3818bbd..6e1916f 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -136,6 +136,7 @@ const ( springKindNone springKind = iota springKindUnit springKindZoom + springKindProjects ) // Model is the root Bubble Tea model for the chart view. @@ -268,6 +269,20 @@ type Model struct { zoomSpringR float64 zoomSpringVel float64 zoomSnap zoomAnimSnapshot + // Projects-box slide (#416): single-phase spring on the box's OUTER + // height. projectsAnimH is the animated height the projectsHeight() + // lever returns mid-slide; projectsSlideFrom/To are the endpoints of + // the in-flight slide (re-arm starts From at the current height). + // Frames render through the STEADY pipelines (renderProjectsFrame), so + // no snapshot state exists — endpoint frames equal the steady views by + // construction. Mutually exclusive with the unit/zoom springs via the + // shared springActive flag + springKind tag. + projectsSpring harmonica.Spring + projectsSpringR float64 + projectsSpringVel float64 + projectsAnimH int + projectsSlideFrom int + projectsSlideTo int // nowGen is bumped each time the live-advance tick is re-armed (zoom // change). scheduleNowTick captures the current value into the scheduled // nowTickMsg; the handler drops ticks whose gen doesn't match, so a zoom @@ -443,16 +458,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // handleWindowSize re-lays out the viewport, progress bars, and help width on // terminal resize, then re-queries the chart and (re)arms the intro. +// +// viewport.Width is set before refreshChart because renderWindow reads +// m.viewport.Width to build the content. viewport.Height is set AFTER +// refreshChart: refreshChart aborts any in-flight projects slide +// (springActive=false, springKind=None), which changes what chartHeight() +// returns. Assigning Height before the abort would bake the mid-slide value +// into the viewport, leaving it desynced until the next resize or 'p' press. func (m *Model) handleWindowSize(msg tea.WindowSizeMsg) tea.Cmd { m.w, m.h = msg.Width, msg.Height m.viewport.Width = m.chartWidth() - m.viewport.Height = m.chartHeight() // help.Width controls when ShortHelp ellipsizes; if left at 0 // the footer can wrap onto the body row and break chartHeight(). m.help.Width = m.w m.progress = newProgressBar(m.progressWidth()) m.progress7d = newProgressBar(m.progressWidth()) m.refreshChart() + // Assign after refreshChart so the abort of any in-flight spring is + // reflected in the height (chartHeight() reads projectsHeight(), which + // reads projectsAnimH when springKind==springKindProjects). + m.viewport.Height = m.chartHeight() return m.maybeArmIntro() } @@ -590,9 +615,10 @@ func (m *Model) handleProjectsTick(msg projectsTickMsg) { // the bar-height normalization base, and applyProjectsResize calls // renderWindow (bar mode) / buildLineChart (remaining mode) which would // overwrite it, corrupting the spring frames and flashing steady-state - // content (#420). The deferred recompute is never lost — both spring settle - // paths call refreshChart (pkg/tui/springs.go settle, pkg/tui/zoomspring.go - // settle), whose pre-paint refreshProjects + height re-sync catches it. + // content (#420). The deferred recompute is never lost — every spring + // settle path calls refreshChart (pkg/tui/springs.go, pkg/tui/zoomspring.go, + // and the projects slide in pkg/tui/projectsspring.go, #416), whose + // pre-paint refreshProjects + height re-sync catches it. if m.springActive { return } @@ -654,16 +680,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { case key.Matches(msg, m.keys.Unit): return m.handleUnitKey() case key.Matches(msg, m.keys.Projects): - // Hard layout cut (not a spring): toggling the box changes - // chartHeight, so resize the viewport widget and rebuild content - // at the new height — the same subset of handleWindowSize that - // matters when only the chart's available height changes. - // refreshChart chains refreshProjects, so an on-show requery for - // the current window falls out for free. - m.showProjects = !m.showProjects - m.viewport.Height = m.chartHeight() - m.refreshChart() - return nil + return m.handleProjectsKey() case key.Matches(msg, m.keys.ScrollLeft): m.scrollLeft(ZoomLevels[m.zoomIdx].ScrollStep) return m.scheduleProjectsTick() @@ -707,6 +724,27 @@ func (m *Model) handleUnitKey() tea.Cmd { }) } +// handleProjectsKey slides the projects box up (show) / down (hide) via a +// harmonica spring (#416). reduce_motion, a too-short terminal (no room for +// a box), or an empty/cleared chart (renderWindow would no-op against no +// content) → snap, the pre-#416 hard cut. +func (m *Model) handleProjectsKey() tea.Cmd { + if m.deps.ReduceMotion || m.projectsTargetHeight() == 0 || m.lastCanvasW == 0 { + m.showProjects = !m.showProjects + m.viewport.Height = m.chartHeight() + m.refreshChart() + return nil + } + m.beginProjectsAnimation() + if !m.springActive { + return nil + } + gen := m.springGen + return tea.Tick(time.Second/time.Duration(springFPS), func(time.Time) tea.Msg { + return springTickMsg{gen: gen} + }) +} + // View implements tea.Model; it renders the full TUI frame — header quota bars, separator, and token histogram. func (m Model) View() string { if m.w == 0 { @@ -729,7 +767,12 @@ func (m Model) View() string { parts := []string{header, sep, body} // The projects box sits between chart and footer, suppressed while the // help overlay is up (help replaces the chart body, so the box would be - // out of place). + // out of place). projectsHeight() returns the animated height mid-slide, + // so the SAME render path produces steady and slide frames — the box + // re-flows at each height: real borders and title from the first frames, + // cells filling top-down, the "…N more" overflow recounting as rows fit + // (#416 round two; round one's pre-rendered bottom-slice revealed blank + // padding first). if !m.showHelp { if ph := m.projectsHeight(); ph > 0 { parts = append(parts, renderProjectsBox(m.projectAggs, m.w, ph)) @@ -770,6 +813,19 @@ func (m Model) renderChartBody(rawBody string) string { return overlayYTicks(rawBody, m.chartHeight(), 1.0) } return barZoomYLabel(rawBody, m.zoomSnap, ZoomLevels[m.zoomIdx], m.chartHeight(), m.zoomSpringR) + case m.springActive && m.springKind == springKindProjects: + // Height-only animation: the y-overlay is EXACTLY the steady-state + // overlay at the frame's (animated) chartHeight — same live inputs + // as the steady cases below, so endpoint frames match them + // byte-for-byte (#416 round two). m.peak is recomputed by + // renderWindow each frame from the same fixed window (constant + // during the slide). MUST precede the generic springActive case, + // which reads m.springRatios (unit-toggle state, unset here). + if chartUnit(m.unitIdx) == chartUnitRemaining { + return overlayYTicks(rawBody, m.chartHeight(), 1.0) + } + return overlayYLabel(rawBody, m.peak, chartUnit(m.unitIdx), + m.chartHeight(), 1.0, ZoomLevels[m.zoomIdx].hasInBarNumbers()) case m.springActive: var maxR float64 for _, r := range m.springRatios { diff --git a/pkg/tui/model_nowtick_test.go b/pkg/tui/model_nowtick_test.go index afbbf6f..7bd5be7 100644 --- a/pkg/tui/model_nowtick_test.go +++ b/pkg/tui/model_nowtick_test.go @@ -57,7 +57,7 @@ func TestNextBoundary_24hLocalMidnight(t *testing.T) { // unitIdx selects the chart unit (e.g. int(chartUnitTokens), int(chartUnitRemaining)); // the zoom is fixed at the 15m level (zoomIdx 0). // Mirrors seedBarModel but injects the #311 clock seam. -func seedModelAt(t *testing.T, unitIdx, nBuckets int, now time.Time) (Model, *cache.Cache) { +func seedModelAt(t testing.TB, unitIdx, nBuckets int, now time.Time) (Model, *cache.Cache) { t.Helper() c, err := cache.Open(t.Context(), filepath.Join(t.TempDir(), "state.db")) if err != nil { diff --git a/pkg/tui/model_test.go b/pkg/tui/model_test.go index 0a026dd..800628f 100644 --- a/pkg/tui/model_test.go +++ b/pkg/tui/model_test.go @@ -5692,6 +5692,10 @@ func TestProjectsHeight_HiddenWhenToggledOff(t *testing.T) { func TestProjectsToggle_Key(t *testing.T) { m, cleanup := seedScrollTestModel(t, 200) defer cleanup() + // #416 added the slide animation on the 'p' key; this test asserts the + // pre-#416 synchronous hard-cut contract (chartHeight/viewport resize land + // immediately), which now lives on the reduce-motion snap path. + m.deps.ReduceMotion = true full := m.h - 7 // chartHeight when the box is hidden @@ -5729,6 +5733,10 @@ func TestProjectsToggle_Key(t *testing.T) { func TestProjectsToggle_AddsBoxToFrame(t *testing.T) { m, cleanup := seedScrollTestModel(t, 200) defer cleanup() + // Snap path (#416): the animated path reveals the box over the slide, so + // the full box title only appears at settle. This test asserts the + // immediate-appearance snap contract. + m.deps.ReduceMotion = true // Box is absent by default. if strings.Contains(m.View(), projectsTitle) { @@ -5756,6 +5764,10 @@ func TestProjectsToggle_InertUnderHelp(t *testing.T) { func TestProjectsToggle_ClearsAndRequeries(t *testing.T) { m, cleanup := seedScrollTestModel(t, 200) defer cleanup() + // Snap path (#416): on the animated hide path projectAggs is retained for + // the slide-down and only cleared at settle. This test asserts the + // immediate clear-on-hide snap contract. + m.deps.ReduceMotion = true // Show → requeried for the visible window. m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) diff --git a/pkg/tui/projects_view.go b/pkg/tui/projects_view.go index 6773ae6..e55bcd5 100644 --- a/pkg/tui/projects_view.go +++ b/pkg/tui/projects_view.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "math" + "strings" "github.com/charmbracelet/lipgloss" @@ -36,7 +37,16 @@ func projectCellCols(outerWidth int) int { // (ProjectAggregates guarantees this) and therefore lands in the final cell. // Empty aggs render a centered placeholder. When aggs exceed the cell budget // (cols × bodyRows), the final cell reads "…N more". +// +// Heights 1–3 occur only mid-slide (#416: the steady target is ≥ 4 or 0) and +// degrade gracefully — 1: top border, 2: closed border shell, 3: shell around +// the title row — always exactly `height` rows so View's per-frame height +// conservation holds at every animated height. func renderProjectsBox(aggs []cache.ProjectAggregate, width, height int) string { + if height <= 2 { + return projectsBoxShell(width, height) + } + box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorMuted). @@ -52,6 +62,13 @@ func renderProjectsBox(aggs []cache.ProjectAggregate, width, height int) string lipgloss.NewStyle().Foreground(colorMuted).Render("no activity in this window"))) } + // Height 3: a single inner row — the title, no body. The general layout + // below always emits title + ≥1 body row (≥4 rows total), which would + // overflow the box. + if innerH < 2 { + return box.Render(lipgloss.NewStyle().Foreground(colorMuted).Render(projectsTitle)) + } + // One row is spent on the title, so cells share the remaining innerH-1. bodyRows := max(innerH-1, 1) @@ -104,6 +121,23 @@ func renderProjectsBox(aggs []cache.ProjectAggregate, width, height int) string return box.Render(lipgloss.JoinVertical(lipgloss.Left, title, body)) } +// projectsBoxShell renders the box's border rows alone at the degenerate +// heights the slide passes through (1: top border, 2: top+bottom — the fully +// squashed box), matching renderProjectsBox's RoundedBorder + colorMuted so +// the shell reads as the same box. lipgloss cannot emit a bordered block +// with zero content rows, hence the manual border rows. +func projectsBoxShell(width, height int) string { + b := lipgloss.RoundedBorder() + inner := max(width-2, 0) + style := lipgloss.NewStyle().Foreground(colorMuted) + top := style.Render(b.TopLeft + strings.Repeat(b.Top, inner) + b.TopRight) + if height <= 1 { + return top + } + bottom := style.Render(b.BottomLeft + strings.Repeat(b.Bottom, inner) + b.BottomRight) + return lipgloss.JoinVertical(lipgloss.Left, top, bottom) +} + // Slot widths for the fixed right-hand columns. Each value is right-aligned // in its own slot so the columns line up vertically across stacked cells. // diff --git a/pkg/tui/projects_view_test.go b/pkg/tui/projects_view_test.go index 3907bb2..87ec4da 100644 --- a/pkg/tui/projects_view_test.go +++ b/pkg/tui/projects_view_test.go @@ -72,6 +72,37 @@ func TestRenderProjectsBox_NoProjectLast(t *testing.T) { } } +// TestRenderProjectsBox_DegenerateHeights pins the #416 mid-slide contract: +// every height the slide passes through renders EXACTLY that many rows +// (View's per-frame height conservation depends on it) — 1: top border, +// 2: closed border shell, 3: shell around the title row, 4+: the full +// title+body layout. +func TestRenderProjectsBox_DegenerateHeights(t *testing.T) { + for h := 1; h <= 5; h++ { + got := renderProjectsBox(sampleAggs(), 80, h) + rows := strings.Split(got, "\n") + if len(rows) != h { + t.Errorf("height %d: rendered %d rows, want exactly %d:\n%s", h, len(rows), h, got) + continue + } + if !strings.Contains(rows[0], "╭") || !strings.Contains(rows[0], "╮") { + t.Errorf("height %d: first row is not a top border: %q", h, rows[0]) + } + if h >= 2 { + last := rows[len(rows)-1] + if !strings.Contains(last, "╰") || !strings.Contains(last, "╯") { + t.Errorf("height %d: last row is not a bottom border: %q", h, last) + } + } + if h >= 3 && !strings.Contains(got, projectsTitle) { + t.Errorf("height %d: title missing", h) + } + if h >= 5 && !strings.Contains(got, "dotfiles") { + t.Errorf("height %d: top spender missing", h) + } + } +} + func TestRenderProjectsBox_Overflow(t *testing.T) { // 1 column (narrow) × bodyRows=2 → capacity 2; 10 aggs must overflow // into a "…N more" final cell rather than silently dropping rows. diff --git a/pkg/tui/projectsspring.go b/pkg/tui/projectsspring.go new file mode 100644 index 0000000..4e26123 --- /dev/null +++ b/pkg/tui/projectsspring.go @@ -0,0 +1,141 @@ +// Package tui — projects-box slide animation (issue #416). +// +// Sibling of the zoom-transition machine in zoomspring.go: it reuses the master +// springActive flag and the shared springGen counter (the unit/zoom/projects +// animations are mutually exclusive — refreshChart aborts any in-flight one) and +// is disambiguated by Model.springKind == springKindProjects. +// +// The crux (round two, see spec): every frame is produced by the STEADY +// rendering pipelines at the animated height — the chart via renderWindow / +// buildLineChart at the lever-derived chartHeight, the box re-flowed by the +// steady View path at projectsHeight(). Endpoint frames are byte-identical to +// the steady views by construction. All per-frame inputs are in-memory — no +// DB per frame. +package tui + +import ( + "math" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/harmonica" + "github.com/charmbracelet/x/ansi" +) + +// lerpInt linearly interpolates between integer heights a and b at +// parameter r, rounding to the nearest row. r is clamped to [0,1] here — +// the critically-damped spring approaches 1 asymptotically but nothing +// guarantees it never lands marginally outside the interval. +func lerpInt(a, b int, r float64) int { + r = min(max(r, 0), 1) + return int(math.Round(float64(a) + (float64(b)-float64(a))*r)) +} + +// renderProjectsFrame paints one slide frame entirely through the STEADY +// rendering pipelines at the lever-derived (animated) chart height — the +// property that makes the slide's endpoint frames byte-identical to the +// steady views (#416 round two; round one's parallel skyline/snapshot path +// produced mismatched endpoints, shifted+recolored x-labels and an empty +// box). Bar modes go through renderWindow (visible slice, flush-right +// slack, on-screen peak, in-bar labels); remaining mode uses a WINDOWED +// line build at viewport width (#180 rationale: full-canvas rebuild at +// canvasW=2880 blows the 60fps budget — ~41ms/frame at 30-day history vs. +// the 16.7ms allowance). The braille body maps time→col linearly via +// WithTimeRange, so the windowed plot lines up with the steady view; the +// x-label row is NOT synthesized for the window (that drops any label +// straddling the window's left edge, whose clipped tail the steady +// viewport still shows) — it is built for the FULL canvas exactly as the +// steady path does, then cut to the visible columns with the same +// ansi.Cut the viewport applies to content, keeping the row byte-stable +// mid-slide. m.viewportXOffset is NOT changed here (the logical scroll +// position must survive to the settle frame, where refreshChart restores +// the full canvas and re-applies the offset via setX). All inputs are +// in-memory — zero DB per frame. +func (m *Model) renderProjectsFrame() { + chartH := m.chartHeight() + m.viewport.Height = chartH + if chartUnit(m.unitIdx) == chartUnitRemaining { + zoom := ZoomLevels[m.zoomIdx] + vpW := m.viewport.Width + viewFrom, viewTo := m.visibleWindow() + slicedPts5h := slicePointsInRange(m.lastPts5h, viewFrom, viewTo) + slicedPts7d := slicePointsInRange(m.lastPts7d, viewFrom, viewTo) + // xOff mirrors what setX last applied (m.viewportXOffset is the + // bucket-indexed shadow, already clamped); the cut therefore lands + // on the same columns the steady viewport shows. + xOff := m.viewportXOffset * zoom.stride() + labelRow := ansi.Cut( + renderXLabels(synthLabelStarts(m.lastChartFrom, m.lastChartTo, zoom), + m.lastCanvasW, zoom, m.now(), m.dateOrder), + xOff, xOff+vpW) + m.viewport.SetContent(buildLineChart(slicedPts5h, slicedPts7d, + viewFrom, viewTo, vpW, chartH, + m.now(), zoom, m.dateOrder, "projects", labelRow)) + m.viewport.SetXOffset(0) + return + } + m.renderWindow() +} + +// handleProjectsSpringTick advances one frame of the box slide: step the spring +// toward r=1, lerp the outer box height startH→targetH, re-render the frame, and +// settle when within phaseTransitionThreshold. On settle it commits the height, +// clears the spring, and restores steady state via refreshChart (which chains +// refreshProjects — the 1 settle query on show; a no-op on hide since +// showProjects was committed to false at arm). Returns nil to stop the loop. +func (m *Model) handleProjectsSpringTick(gen int) tea.Cmd { + r, vel := m.projectsSpring.Update(m.projectsSpringR, m.projectsSpringVel, 1.0) + m.projectsSpringR, m.projectsSpringVel = r, vel + m.projectsAnimH = lerpInt(m.projectsSlideFrom, m.projectsSlideTo, r) + + if math.Abs(1.0-r) < phaseTransitionThreshold { + m.projectsAnimH = m.projectsSlideTo + m.springActive = false + m.springKind = springKindNone + m.viewport.Height = m.chartHeight() + m.refreshChart() // steady-state restore (chart + chained refreshProjects) + return nil // stop the loop — idle TUI is zero-animation-cost + } + + m.renderProjectsFrame() + return tea.Tick(time.Second/time.Duration(springFPS), func(time.Time) tea.Msg { + return springTickMsg{gen: gen} + }) +} + +// beginProjectsAnimation arms the box slide. A re-arm mid-slide reverses +// from the CURRENT animated height (every intermediate height renders +// correctly under re-flow — no snap to an extreme first); an in-flight u/z +// is hard-cut via refreshChart exactly as u and z do to each other. +// showProjects commits at arm (keeps u/z aborts free of projects-specific +// wiring). Show pays THE one arm-time ProjectAggregates query via +// refreshProjects (the box was unloaded while hidden, #414); hide pays +// none. The viewport is deliberately NOT repainted: frame 0 of the slide +// IS the current steady frame (show starts at height 0 = the box-hidden +// layout; hide starts at the current target; re-arm wherever the slide +// was) — that no-touch property is half of endpoint identity. +func (m *Model) beginProjectsAnimation() { + from := m.projectsHeight() // animH mid-slide, steady extreme otherwise + if m.springActive && m.springKind != springKindProjects { + m.refreshChart() // abort in-flight u/z; restores steady chart content + } + + m.showProjects = !m.showProjects + to := 0 + if m.showProjects { + // Query BEFORE reading the target: projectsTargetHeight is + // content-aware (#420), and on a show the aggs are still nil from + // the hidden state (#414) — reading first would arm a slide to the + // 4-row empty floor and jump to the real height at settle. + m.refreshProjects() // THE one arm-time query on the show path + to = m.projectsTargetHeight() + } + m.projectsSlideFrom, m.projectsSlideTo = from, to + m.projectsAnimH = from + + m.projectsSpring = harmonica.NewSpring(harmonica.FPS(springFPS), phase2Frequency, phase2Damping) + m.projectsSpringR, m.projectsSpringVel = 0, 0 + m.springActive = true + m.springKind = springKindProjects + m.springGen++ +} diff --git a/pkg/tui/projectsspring_bench_test.go b/pkg/tui/projectsspring_bench_test.go new file mode 100644 index 0000000..65bccbd --- /dev/null +++ b/pkg/tui/projectsspring_bench_test.go @@ -0,0 +1,95 @@ +package tui + +import ( + "testing" + "time" + + "github.com/martinciu/ccpulse/pkg/anthro" + "github.com/martinciu/ccpulse/pkg/cache" +) + +// seedRemainingModelWith30dMessages builds a remaining-mode model with ~30 +// days of 15m-spaced message history (2880 buckets) so that +// EarliestMessageTime widens the canvas to ~2880 cols — the realistic +// worst-case that the line-mode windowing optimisation must stay inside the +// 60fps budget for. Usage samples are also seeded so hasData is true and +// lastPts5h/lastPts7d are populated. Composes seedModelAt (messages) and +// the usage-sample insertion pattern from seedRemainingModelWithSamples. +func seedRemainingModelWith30dMessages(b *testing.B, now time.Time) (Model, *cache.Cache) { + b.Helper() + // 2880 = 30 days × 96 buckets/day at 15m zoom — wide enough to force a + // realistic-history canvas. seedModelAt spaces messages 15m apart. + m, c := seedModelAt(b, int(chartUnitRemaining), 2880, now) + // Add 60 usage samples so the line chart has real utilisation data. + for i := range 60 { + when := now.Add(-time.Duration(i) * 15 * time.Minute) + resets := when.Add(2 * time.Hour) + u := anthro.Usage{ + FiveHour: &anthro.Bucket{Utilization: 20.0 + float64(i)*2.0, ResetsAt: &resets}, + } + if err := c.RecordUsageSample(b.Context(), u, when); err != nil { + b.Fatalf("RecordUsageSample: %v", err) + } + } + m.refreshChart() + return m, c +} + +// BenchmarkProjectsAnimFrame measures one slide-frame render — the work a +// single springTickMsg does besides the O(1) spring step — in both chart +// modes (round-one finding ccpulse-416.3: line mode was unmeasured). The +// per-frame budget is 16.7ms (60fps); renderWindow's own docs put the bar +// path at ~5ms at viewport width. Heights alternate mid-slide values so the +// rebuild cost reflects varying-height frames, not a memoized best case. +func BenchmarkProjectsAnimFrame(b *testing.B) { + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + b.Run("bar", func(b *testing.B) { + m, c := seedBarModelWithMessages(b, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + m.beginProjectsAnimation() // show: arm + 1 aggs query (outside the loop) + target := m.projectsTargetHeight() + b.ReportAllocs() + i := 0 + for b.Loop() { + m.projectsAnimH = 1 + (i % max(target, 2)) // sweep mid-slide heights + m.renderProjectsFrame() + i++ + } + }) + b.Run("line", func(b *testing.B) { + m, c := seedRemainingModelWithSamples(b, 60, now) + defer c.Close() + m.showProjects = false + m.refreshChart() + m.beginProjectsAnimation() + target := m.projectsTargetHeight() + b.ReportAllocs() + i := 0 + for b.Loop() { + m.projectsAnimH = 1 + (i % max(target, 2)) + m.renderProjectsFrame() + i++ + } + }) + b.Run("line_30d", func(b *testing.B) { + m, c := seedRemainingModelWith30dMessages(b, now) + defer c.Close() + // Guard: fixture must have a realistic-history canvas width. + if m.lastCanvasW < 2000 { + b.Fatalf("fixture canvas %d, want realistic-history width (>=2000)", m.lastCanvasW) + } + m.showProjects = false + m.refreshChart() + m.beginProjectsAnimation() + target := m.projectsTargetHeight() + b.ReportAllocs() + i := 0 + for b.Loop() { + m.projectsAnimH = 1 + (i % max(target, 2)) + m.renderProjectsFrame() + i++ + } + }) +} diff --git a/pkg/tui/projectsspring_test.go b/pkg/tui/projectsspring_test.go new file mode 100644 index 0000000..d92eec6 --- /dev/null +++ b/pkg/tui/projectsspring_test.go @@ -0,0 +1,841 @@ +package tui + +import ( + "reflect" + "regexp" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/martinciu/ccpulse/pkg/cache" +) + +func TestLerpInt(t *testing.T) { + cases := []struct { + a, b int + r float64 + want int + }{ + {0, 12, 0, 0}, + {0, 12, 1, 12}, + {0, 12, 0.5, 6}, + {12, 0, 0.5, 6}, + {12, 0, 1, 0}, + {0, 10, 0.24, 2}, // 2.4 rounds to 2 + {0, 10, 0.25, 3}, // 2.5 rounds to 3 (math.Round) + {0, 10, -0.2, 0}, // r clamps to 0 (spring may undershoot marginally) + {0, 10, 1.3, 10}, // r clamps to 1 (spring may overshoot marginally) + } + for _, c := range cases { + if got := lerpInt(c.a, c.b, c.r); got != c.want { + t.Errorf("lerpInt(%d,%d,%g)=%d, want %d", c.a, c.b, c.r, got, c.want) + } + } +} + +func TestProjectsHeight_SpringBranchOverridesTarget(t *testing.T) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + + // Steady target is content-aware (#420): the single-project fixture + // needs border(2)+title(1)+1 body row = 4, well under the 122x40 cap + // (m.h-7=33 → upper min(16,12)=12). The empty-aggs floor is also 4, so + // the value holds whether or not refreshProjects ran. + m.showProjects = true + if got, want := m.projectsHeight(), m.projectsTargetHeight(); got != want { + t.Fatalf("steady projectsHeight()=%d, want projectsTargetHeight()=%d", got, want) + } + if m.projectsTargetHeight() != 4 { + t.Fatalf("projectsTargetHeight()=%d, want 4 (content-aware, 1 project) at 122x40", m.projectsTargetHeight()) + } + + // Spring branch: returns projectsAnimH regardless of showProjects. + m.springActive = true + m.springKind = springKindProjects + m.projectsAnimH = 7 + if got := m.projectsHeight(); got != 7 { + t.Errorf("in-slide projectsHeight()=%d, want 7 (animated)", got) + } + m.showProjects = false + if got := m.projectsHeight(); got != 7 { + t.Errorf("in-slide projectsHeight() with showProjects=false=%d, want 7", got) + } +} + +// renderProjectsFrame must keep viewport.Height in lockstep with the +// lever-derived chartHeight every frame (round-one finding ccpulse-416.1). +func TestRenderProjectsFrame_SetsViewportHeightToChartHeight(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + for range 4 { // advance a few frames so projectsAnimH is mid-flight + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if !m.springActive { + t.Fatal("slide settled in 4 ticks; cannot probe mid-flight") + } + if m.viewport.Height != m.chartHeight() { + t.Errorf("viewport.Height=%d, want chartHeight()=%d", m.viewport.Height, m.chartHeight()) + } +} + +// TestView_DuringSlide_HeightConservedRealBorder probes a mid-flight frame: +// total height is conserved (chartHeight() + projectsHeight() == m.h - 7 and +// the rendered frame is exactly m.h rows), and the box band carries the REAL +// renderProjectsBox top border + title — re-flowed at the animated height, +// not a phantom-topped bottom slice (#416 round two). +func TestView_DuringSlide_HeightConservedRealBorder(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = true + m.refreshProjects() + m.projectsSlideFrom, m.projectsSlideTo = 0, 12 + m.springActive = true + m.springKind = springKindProjects + m.projectsAnimH = 5 + m.renderProjectsFrame() + + if got, want := m.chartHeight()+m.projectsHeight(), m.h-7; got != want { + t.Errorf("chartHeight+projectsHeight=%d, want %d (height lever conserved)", got, want) + } + frame := m.View() + if got := lipgloss.Height(frame); got != m.h { + t.Errorf("View height=%d, want %d (conserved every frame)", got, m.h) + } + // animH=5 ≥ 4: the box band must be the real re-flowed box — top border + // with rounded corners and the title row right beneath it. The header is + // also a rounded-bordered block, so the box's top border is the LAST ╭ + // row in the frame. + lines := strings.Split(frame, "\n") + topIdx := -1 + for i, line := range lines { + if strings.Contains(line, "╭") { + topIdx = i + } + } + if topIdx == -1 { + t.Fatal("mid-slide frame missing the box top border") + } + if topIdx+1 >= len(lines) || !strings.Contains(lines[topIdx+1], projectsTitle) { + t.Errorf("row beneath the top border lacks the title %q (box not re-flowed)", projectsTitle) + } +} + +// armProjectsShowForTest arms a SHOW slide through the production arm path +// (the box starts hidden; beginProjectsAnimation toggles it on, pays the +// arm-time aggs query, and seeds the spring without repainting frame 0). +func armProjectsShowForTest(t testing.TB, m *Model) { + t.Helper() + m.showProjects = false + m.beginProjectsAnimation() +} + +func TestProjectsSpringTick_AdvancesThenSettles(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + armProjectsShowForTest(t, &m) + target := m.projectsSlideTo + + // One tick: ratio moves off 0, animH advances toward target. + updated, cmd := m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + if m.projectsSpringR <= 0 { + t.Errorf("after one tick: projectsSpringR=%g, want >0", m.projectsSpringR) + } + if cmd == nil { + t.Error("mid-slide tick returned nil cmd, want next tick scheduled") + } + if m.projectsAnimH < 0 || m.projectsAnimH > target { + t.Errorf("projectsAnimH=%d out of [0,%d]", m.projectsAnimH, target) + } + + // Drive to settle (never invoke the tick Cmd — it real-sleeps; construct msgs). + const maxTicks = 600 + var lastCmd tea.Cmd + for i := 0; i < maxTicks && m.springActive; i++ { + updated, lastCmd = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if m.springActive { + t.Fatalf("projects slide did not settle within %d ticks", maxTicks) + } + if m.springKind != springKindNone { + t.Errorf("after settle: springKind=%d, want springKindNone", m.springKind) + } + if lastCmd != nil { + t.Errorf("settle: cmd=%v, want nil (loop stops — idle TUI zero-cost)", lastCmd) + } + if m.projectsAnimH != target { + t.Errorf("after settle: projectsAnimH=%d, want target %d", m.projectsAnimH, target) + } + if !m.showProjects { + t.Error("after show settle: showProjects=false, want true (committed)") + } + if m.viewport.Height != m.chartHeight() { + t.Errorf("after settle: viewport.Height=%d, want chartHeight=%d", m.viewport.Height, m.chartHeight()) + } +} + +func TestProjectsSpringTick_StaleGenDropped(t *testing.T) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + armProjectsShowForTest(t, &m) + + updated, cmd := m.Update(springTickMsg{gen: m.springGen - 1}) // superseded + m = updated.(Model) + if cmd != nil { + t.Errorf("stale-gen tick: cmd=%v, want nil (dropped)", cmd) + } + if !m.springActive { + t.Error("stale-gen tick must not settle the live animation") + } +} + +func TestProjectsKey_ShowFromIdle_ArmsAndQueriesOnce(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false // hidden by default (#414) → first 'p' is a show + m.refreshChart() // ensure steady chart inputs present; projectAggs stays nil (hidden) + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + + if !m.springActive || m.springKind != springKindProjects { + t.Fatalf("show 'p': springActive=%v springKind=%d, want true/projects", m.springActive, m.springKind) + } + if !m.showProjects { + t.Error("show 'p': showProjects=false, want true (committed at arm)") + } + if m.projectsSlideFrom != 0 || m.projectsSlideTo != m.projectsTargetHeight() { + t.Errorf("show slide from/to = (%d,%d), want (0,%d)", m.projectsSlideFrom, m.projectsSlideTo, m.projectsTargetHeight()) + } + if len(m.projectAggs) == 0 { + t.Error("show 'p': projectAggs empty after arm (requery missing)") + } + if cmd == nil { + t.Error("show 'p': cmd=nil, want first tick scheduled") + } + + // Zero-DB-per-frame contract: the arm query repopulated projectAggs once; + // driving mid-flight ticks must NOT reissue ProjectAggregates (the slice's + // backing array is untouched), and settle reissues exactly once (new array). + armPtr := projectAggsBackingPtr(m.projectAggs) + for range 3 { // safely mid-flight (critically-damped spring needs ~15+ ticks) + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if !m.springActive { + t.Fatal("3 ticks settled the slide unexpectedly; can't probe mid-flight") + } + if projectAggsBackingPtr(m.projectAggs) != armPtr { + t.Error("projectAggs reassigned mid-slide → a per-tick refreshProjects ran (want zero DB per frame)") + } +} + +func TestProjectsKey_HideFromIdle_NoArmQuery(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = true + m.refreshChart() // box shown → projectAggs populated + if len(m.projectAggs) == 0 { + t.Fatal("seed: projectAggs empty, want populated before hide") + } + beforePtr := projectAggsBackingPtr(m.projectAggs) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + + if !m.springActive || m.springKind != springKindProjects { + t.Fatalf("hide 'p': springActive=%v springKind=%d", m.springActive, m.springKind) + } + if m.showProjects { + t.Error("hide 'p': showProjects=true, want false (committed at arm)") + } + if m.projectsSlideFrom != m.projectsTargetHeight() || m.projectsSlideTo != 0 { + t.Errorf("hide slide from/to=(%d,%d), want (%d,0)", m.projectsSlideFrom, m.projectsSlideTo, m.projectsTargetHeight()) + } + // No arm requery on hide: the snapshot reused the already-populated aggs. + if projectAggsBackingPtr(m.projectAggs) != beforePtr { + t.Error("hide 'p' reissued ProjectAggregates at arm, want 0 queries (reuse in-memory aggs)") + } +} + +func TestProjectsKey_ReduceMotion_Snaps(t *testing.T) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.deps.ReduceMotion = true + m.showProjects = false + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + + if m.springActive { + t.Error("reduce_motion 'p': springActive=true, want snap") + } + if !m.showProjects { + t.Error("reduce_motion 'p': showProjects=false, want toggled on") + } + if cmd != nil { + t.Errorf("reduce_motion 'p': cmd=%v, want nil (synchronous cut)", cmd) + } + if m.viewport.Height != m.chartHeight() { + t.Errorf("reduce_motion 'p': viewport.Height=%d, want chartHeight=%d", m.viewport.Height, m.chartHeight()) + } +} + +func TestProjectsKey_TooShort_Snaps(t *testing.T) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.h = 12 // m.h-7=5 < 9 → projectsTargetHeight()==0 + m.viewport.Height = m.chartHeight() + m.showProjects = false + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + + if m.springActive { + t.Error("too-short 'p': springActive=true, want snap") + } + if cmd != nil { + t.Errorf("too-short 'p': cmd=%v, want nil", cmd) + } +} + +func TestProjectsKey_AbortsInflightZoom(t *testing.T) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + + // Arm a zoom, then press 'p' mid-flight. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}}) + m = updated.(Model) + if m.springKind != springKindZoom { + t.Fatalf("setup: springKind=%d, want zoom", m.springKind) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + + if m.springKind != springKindProjects || !m.springActive { + t.Errorf("'p' during zoom: springKind=%d active=%v, want projects/true (zoom aborted, slide armed)", m.springKind, m.springActive) + } +} + +func TestZoomKey_AbortsInflightProjectsSlide(t *testing.T) { + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) // arm show slide + m = updated.(Model) + if m.springKind != springKindProjects { + t.Fatalf("setup: springKind=%d, want projects", m.springKind) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}}) + m = updated.(Model) + + if m.springKind != springKindZoom || !m.springActive { + t.Errorf("'z' during slide: springKind=%d active=%v, want zoom/true", m.springKind, m.springActive) + } + if !m.showProjects { + t.Error("'z' during show-slide: showProjects=false, want true (slide's committed terminal state)") + } +} + +// TestProjectsSlide_EndpointIdentity_BarMode is the headline #416-round-two +// property: the slide's frame 0 is byte-identical to the steady pre-slide +// View, and the settle frame is byte-identical to the steady post-slide View +// — both directions, under forced color so styling drift fails the test. +func TestProjectsSlide_EndpointIdentity_BarMode(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false // hidden by default (#414) → first 'p' is a show + m.refreshChart() + + m = assertSlideEndpoints(t, m, "show") + m = assertSlideEndpoints(t, m, "hide") + _ = m +} + +// assertSlideEndpoints presses 'p', asserts frame-0 identity, drives the +// spring to settle via constructed springTickMsg (never the real tea.Tick +// Cmd), and asserts settle identity against a fresh steady re-render. +func assertSlideEndpoints(t *testing.T, m Model, dir string) Model { + t.Helper() + pre := m.View() + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + if cmd == nil { + t.Fatalf("%s: 'p' returned nil cmd, want first tick scheduled", dir) + } + if !m.springActive || m.springKind != springKindProjects { + t.Fatalf("%s: springActive=%v kind=%d, want true/projects", dir, m.springActive, m.springKind) + } + if got := m.View(); got != pre { + t.Errorf("%s: frame 0 differs from steady pre-slide view\nframe0:\n%s\nsteady:\n%s", dir, got, pre) + } + + for i := 0; m.springActive; i++ { + if i > 600 { + t.Fatalf("%s: slide did not settle within 600 ticks", dir) + } + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + settled := m.View() + m.refreshChart() // independent steady re-render of the post-slide state + if post := m.View(); settled != post { + t.Errorf("%s: settle frame differs from steady post-slide view\nsettle:\n%s\nsteady:\n%s", dir, settled, post) + } + return m +} + +// TestProjectsSlide_EndpointIdentity_LineMode mirrors the bar-mode headline +// test in remaining (line) mode: the per-frame build is the steady +// full-canvas buildLineChart at the lever height + the steady offset +// re-apply — closing round-one finding ccpulse-416.2 (the old frame skipped +// the steady path's windowing/offset semantics entirely). +func TestProjectsSlide_EndpointIdentity_LineMode(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedRemainingModelWithSamples(t, 60, now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + m = assertSlideEndpoints(t, m, "show-line") + m = assertSlideEndpoints(t, m, "hide-line") + _ = m +} + +// TestProjectsSlide_BoxContentPresentEarly guards the round-one "box rose +// empty" defect: as soon as the box band is a few rows tall it must carry +// the real top border, the title (height 3: shell around the title row, per +// the degenerate-heights contract), and one row later the top spender — +// renderProjectsBox re-flowed at the animated height, not a blank-padded +// pre-render sliced bottom-first. Thresholds are 3/4 rather than 4/5 so the +// assertions stay non-vacuous with the content-aware target (#420): the +// single-project fixture's target is only 4 rows. +func TestProjectsSlide_BoxContentPresentEarly(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + if len(m.projectAggs) == 0 { + t.Fatal("arm did not populate projectAggs (show-path requery missing)") + } + topLabel := m.projectAggs[0].Label + + sawTitle := false + for i := 0; m.springActive && i < 600; i++ { + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + frame := m.View() + if lipgloss.Height(frame) != m.h { + t.Fatalf("tick %d: frame height %d != terminal height %d", i, lipgloss.Height(frame), m.h) + } + if m.springActive && m.projectsAnimH >= 3 { + if !strings.Contains(frame, projectsTitle) { + t.Fatalf("animH=%d: frame lacks box title %q — box rendering empty", m.projectsAnimH, projectsTitle) + } + sawTitle = true + } + if m.springActive && m.projectsAnimH >= 4 && !strings.Contains(frame, topLabel) { + t.Fatalf("animH=%d: frame lacks top spender %q — content not re-flowed", m.projectsAnimH, topLabel) + } + } + if !sawTitle { + t.Fatal("slide settled without ever sampling a frame at animH >= 3") + } +} + +// TestProjectsSlide_XLabelRowStable guards round-one defects 3+4: during the +// slide the x-axis label row must keep its exact content, column position +// and ANSI styling — the steady renderer's own label row, not a synthetic +// re-creation. Asserted by requiring the steady view's label line to appear +// verbatim (bytes, incl. color) in every sampled mid-slide frame. +func TestProjectsSlide_XLabelRowStable(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + labelRow := findXLabelRow(t, m.View()) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + for i := 0; m.springActive && i < 600; i++ { + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + if m.springActive && m.chartHeight() >= 6 && !strings.Contains(m.View(), labelRow) { + t.Fatalf("tick %d (chartH=%d): steady x-label row missing/altered mid-slide\nwant line: %q", i, m.chartHeight(), labelRow) + } + } +} + +// TestProjectsSlide_XLabelRowStable_LineMode is the remaining-mode (line chart) +// sibling of TestProjectsSlide_XLabelRowStable. It pins the windowed per-frame +// buildLineChart fidelity: every mid-flight frame rendered by the WINDOWED +// renderProjectsFrame remaining branch (slicePointsInRange + viewport.Width + +// SetXOffset(0)) must keep the steady label row verbatim and must preserve the +// terminal frame height. The endpoint-identity test (TestProjectsSlide_EndpointIdentity_LineMode) +// sees only frame-0 and the settle frame — this test covers the frames in between. +// +// Threshold: buildLineChart emits the x-label row only when chartH >= 6 +// (identical to bar mode); frames below that height have no label row to check. +func TestProjectsSlide_XLabelRowStable_LineMode(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedRemainingModelWithSamples(t, 60, now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + labelRow := findXLabelRow(t, m.View()) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + for i := 0; m.springActive && i < 600; i++ { + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + frame := m.View() + if lipgloss.Height(frame) != m.h { + t.Fatalf("tick %d: frame height %d != terminal height %d", i, lipgloss.Height(frame), m.h) + } + // buildLineChart emits the x-label row only when chartH >= 6; skip the + // assertion for shorter frames where no label row is rendered. + if m.springActive && m.chartHeight() >= 6 && !strings.Contains(frame, labelRow) { + t.Fatalf("tick %d (chartH=%d): steady x-label row missing/altered mid-slide (windowed line build)\nwant line: %q", i, m.chartHeight(), labelRow) + } + } +} + +// findXLabelRow returns the first View line carrying >= 2 HH:MM time labels +// — the bar chart's x-axis row. +func findXLabelRow(t *testing.T, view string) string { + t.Helper() + re := regexp.MustCompile(`\d{1,2}:\d{2}`) + for line := range strings.SplitSeq(view, "\n") { + if len(re.FindAllString(line, -1)) >= 2 { + return line + } + } + t.Fatal("no x-label row found in steady view") + return "" +} + +// TestProjectsKey_RearmMidSlide_ReversesFromCurrentHeight: a second 'p' +// mid-slide reverses the motion from wherever the box currently is — no +// snap to an extreme first (re-flow rendering makes every intermediate +// height a valid start), no u/z-style hard-cut abort. +func TestProjectsKey_RearmMidSlide_ReversesFromCurrentHeight(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + genShow := m.springGen + // Advance until animH is STRICTLY between the endpoints. The content-aware + // target (#420) is only 4 rows for the single-project fixture, so a fixed + // tick count is fragile: too few ticks round the lerp to 0, too many land + // on the target. Driving on the observed height is robust to both the + // spring constants and the fixture's target size. + for i := 0; m.projectsAnimH <= 0 || m.projectsAnimH >= m.projectsTargetHeight(); i++ { + if i > 600 || !m.springActive { + t.Fatalf("no strictly-mid-flight frame observed (tick %d, animH=%d, target=%d, active=%v)", + i, m.projectsAnimH, m.projectsTargetHeight(), m.springActive) + } + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + mid := m.projectsAnimH + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + if cmd == nil { + t.Fatal("re-arm 'p': cmd=nil, want new tick loop") + } + if m.showProjects { + t.Error("re-arm 'p': showProjects=true, want false (reversed to hide)") + } + if m.springGen == genShow { + t.Error("re-arm 'p': springGen not bumped — stale show ticks would still apply") + } + if m.projectsSlideFrom != mid || m.projectsSlideTo != 0 { + t.Errorf("re-arm from/to = (%d,%d), want (%d,0) — must reverse from current height", + m.projectsSlideFrom, m.projectsSlideTo, mid) + } + if m.projectsAnimH != mid { + t.Errorf("re-arm animH=%d, want %d (frame 0 of the reversal = current frame)", m.projectsAnimH, mid) + } +} + +// TestWindowSize_MidSlide_ViewportHeightSynced is the regression test for the +// handleWindowSize desync found in ccpulse-416.17: before the fix, viewport.Height +// was assigned from chartHeight() BEFORE refreshChart() aborted the in-flight +// projects spring. refreshChart sets springActive=false and springKind=None, which +// changes projectsHeight() (and therefore chartHeight()), so the pre-abort +// assignment baked the mid-slide animated value into the viewport. Every frame +// until the next resize or 'p' press over- or under-filled the terminal by up to +// projectsMaxRows rows. The fix moves the Height assignment to after refreshChart. +func TestWindowSize_MidSlide_ViewportHeightSynced(t *testing.T) { + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + // Arm the show slide. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + if !m.springActive || m.springKind != springKindProjects { + t.Fatalf("setup: springActive=%v springKind=%d, want true/projects", m.springActive, m.springKind) + } + + // Advance 3-4 ticks so projectsAnimH is mid-flight (never invoke the real + // tea.Tick Cmd — it real-sleeps; drive via constructed springTickMsg). + for range 4 { + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if !m.springActive { + t.Fatal("slide settled in 4 ticks; cannot probe mid-flight behaviour") + } + + // Fire a resize mid-slide. handleWindowSize must abort the spring and then + // re-assign viewport.Height from the post-abort chartHeight(). + updated, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = updated.(Model) + + if got, want := m.viewport.Height, m.chartHeight(); got != want { + t.Errorf("after mid-slide resize: viewport.Height=%d, want chartHeight()=%d (desynced)", got, want) + } + if got := lipgloss.Height(m.View()); got != m.h { + t.Errorf("after mid-slide resize: View height=%d, want terminal height %d", got, m.h) + } +} + +// TestRefreshMsg_MidSlide_ViewportHeightSynced is the RefreshMsg sibling of +// TestWindowSize_MidSlide_ViewportHeightSynced. Before the fix, refreshChart's +// spring-abort block cleared springActive/springKind, which changed +// projectsHeight() (and therefore chartHeight()), but nothing re-assigned +// m.viewport.Height — it kept the per-frame value renderProjectsFrame had +// last written. Every subsequent View() would paint more or fewer rows than +// m.h until the next resize or 'p'. The fix adds m.viewport.Height = +// m.chartHeight() inside the abort block (same desync class, watcher-refresh +// abort path). +func TestRefreshMsg_MidSlide_ViewportHeightSynced(t *testing.T) { + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + // Arm the show slide. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + if !m.springActive || m.springKind != springKindProjects { + t.Fatalf("setup: springActive=%v springKind=%d, want true/projects", m.springActive, m.springKind) + } + + // Advance 4 ticks so projectsAnimH is mid-flight (never invoke the real + // tea.Tick Cmd — it real-sleeps; drive via constructed springTickMsg). + for range 4 { + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if !m.springActive { + t.Fatal("slide settled in 4 ticks; cannot probe mid-flight behaviour") + } + + // Fire a RefreshMsg mid-slide. refreshChart must abort the spring and + // re-assign viewport.Height from the post-abort chartHeight(). + updated, _ = m.Update(RefreshMsg{}) + m = updated.(Model) + + if m.springActive { + t.Error("after RefreshMsg mid-slide: springActive=true, want false (slide aborted)") + } + if got, want := m.viewport.Height, m.chartHeight(); got != want { + t.Errorf("after mid-slide RefreshMsg: viewport.Height=%d, want chartHeight()=%d (desynced)", got, want) + } + if got := lipgloss.Height(m.View()); got != m.h { + t.Errorf("after mid-slide RefreshMsg: View height=%d, want terminal height %d", got, m.h) + } +} + +// TestNowTick_MidSlide_ViewportHeightSynced is the nowTickMsg sibling of +// TestWindowSize_MidSlide_ViewportHeightSynced. Before the fix, handleNowTick's +// animatingViewport guard excluded springKindProjects, so nowTickMsg called +// refreshChart during a projects slide; the abort block cleared springActive/Kind +// but left viewport.Height at the mid-slide per-frame value. The fix adds +// m.viewport.Height = m.chartHeight() inside the abort block (same desync class, +// live-advance abort path). Note: handleNowTick returns a non-nil Cmd to +// reschedule the next tick — never invoke it (it real-sleeps up to 1h); ignore. +func TestNowTick_MidSlide_ViewportHeightSynced(t *testing.T) { + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + // Arm the show slide. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + if !m.springActive || m.springKind != springKindProjects { + t.Fatalf("setup: springActive=%v springKind=%d, want true/projects", m.springActive, m.springKind) + } + + // Advance 4 ticks so projectsAnimH is mid-flight (never invoke the real + // tea.Tick Cmd — it real-sleeps; drive via constructed springTickMsg). + for range 4 { + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if !m.springActive { + t.Fatal("slide settled in 4 ticks; cannot probe mid-flight behaviour") + } + + // Fire nowTickMsg mid-slide. handleNowTick calls refreshChart (not guarded + // for springKindProjects), which must abort the spring and re-assign + // viewport.Height from the post-abort chartHeight(). Ignore the returned + // Cmd — it reschedules a real tea.Tick that would real-sleep. + updated, _ = m.Update(nowTickMsg{gen: m.nowGen}) + m = updated.(Model) + + if m.springActive { + t.Error("after nowTickMsg mid-slide: springActive=true, want false (slide aborted)") + } + if got, want := m.viewport.Height, m.chartHeight(); got != want { + t.Errorf("after mid-slide nowTickMsg: viewport.Height=%d, want chartHeight()=%d (desynced)", got, want) + } + if got := lipgloss.Height(m.View()); got != m.h { + t.Errorf("after mid-slide nowTickMsg: View height=%d, want terminal height %d", got, m.h) + } +} + +// projectAggsBackingPtr returns the backing-array address of a ProjectAggregate +// slice, or 0 if empty. refreshProjects reassigns m.projectAggs to a fresh slice +// from ProjectAggregates, so a changed pointer ⇒ a query ran. Used to prove the +// zero-DB-per-frame contract without a cache interface seam. +func projectAggsBackingPtr(a []cache.ProjectAggregate) uintptr { + if len(a) == 0 { + return 0 + } + return reflect.ValueOf(a).Pointer() +} + +// TestProjectsSlide_RealFrame_BoundaryMovesMonotonically drives a real show +// slide tick-by-tick and asserts on the PAINTED BOUNDARY in View() output +// (withForcedColor → real ANSI), not on internal counters. +// +// Property: as the box grows (mid-flight, animH in (0, projectsSlideTo)), the +// row index of the box's top border — the LAST "╭" row in the frame — must +// move UP monotonically (decreasing or equal row index). The header is also a +// rounded-bordered block, so the box's top border is always the last ╭ row; +// this is the same detection used by TestView_DuringSlide_HeightConservedRealBorder. +// +// When animH == 0 there is no box band, and the last ╭ row is the header's — +// a much smaller (higher) index. Monotonic tracking is gated on animH > 0 to +// avoid a spurious first sample against the header-only frame. +// +// Cheap riders: per-tick height conservation (Fatalf), and settle assertions +// (title present, animH == projectsSlideTo). +func TestProjectsSlide_RealFrame_BoundaryMovesMonotonically(t *testing.T) { + withForcedColor(t) + now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC) + m, c := seedBarModelWithMessages(t, int(chartUnitCost), now) + defer c.Close() + m.showProjects = false + m.refreshChart() + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(Model) + + // lastRoundedTopRow returns the row index of the last "╭" line in frame, or + // -1 if none. The box's top border is the last such row (the header's ╭ + // rows sit above it). + lastRoundedTopRow := func(frame string) int { + lines := strings.Split(frame, "\n") + idx := -1 + for i, line := range lines { + if strings.Contains(line, "╭") { + idx = i + } + } + return idx + } + + prevBoxTopRow := -1 // -1 = not yet tracking (animH still 0) + const maxTicks = 600 + for i := range maxTicks { + frame := m.View() + if h := lipgloss.Height(frame); h != m.h { + t.Fatalf("tick %d: frame height=%d, want %d (conserved)", i, h, m.h) + } + band := m.projectsAnimH + if band > 0 && band < m.projectsSlideTo { // mid-slide, box is present + boxTopRow := lastRoundedTopRow(frame) + if boxTopRow == -1 { + t.Errorf("tick %d (animH=%d): no ╭ row found in frame (box top border missing)", i, band) + } else if prevBoxTopRow != -1 && boxTopRow > prevBoxTopRow { + // Box top should move UP (lower row index) as the slide grows. + t.Errorf("tick %d: box top row moved DOWN: %d → %d (non-monotonic; box should rise)", i, prevBoxTopRow, boxTopRow) + } + prevBoxTopRow = boxTopRow + } + if !m.springActive { + break + } + updated, _ = m.Update(springTickMsg{gen: m.springGen}) + m = updated.(Model) + } + if m.springActive { + t.Fatalf("slide did not settle within %d ticks", maxTicks) + } + // Settle frame: full box present (title visible), at the steady target height. + final := m.View() + if !strings.Contains(final, projectsTitle) { + t.Error("settle frame missing the projects box title (full box not restored)") + } + if m.projectsAnimH != m.projectsSlideTo { + t.Errorf("settle animH=%d, want target %d", m.projectsAnimH, m.projectsSlideTo) + } +} diff --git a/pkg/tui/series.go b/pkg/tui/series.go index 3e199eb..b2963f8 100644 --- a/pkg/tui/series.go +++ b/pkg/tui/series.go @@ -146,6 +146,16 @@ func (m *Model) refreshChart() { // remain populated but unread — guarded by springActive=false. // Next beginUnitAnimation re-makes the slices. Zoom scalars // (zoomSpringR/Vel/zoomSnap) likewise stay set but unread (#373). + // The projects slide (#416) rides this same abort: springActive=false + + // springKind=springKindNone drops it; projectsAnimH and the slide + // from/to endpoints stay set but unread, and showProjects was already + // committed at arm so the chart rebuild below reads the correct + // chartHeight. Re-sync the viewport widget's Height here: aborting a + // projects slide changes what chartHeight() returns, and renderProjectsFrame + // last wrote a mid-slide value into viewport.Height; without this line every + // subsequent View() paints the wrong number of rows. For unit/zoom aborts + // the assignment is a no-op (their animations never change the height). + m.viewport.Height = m.chartHeight() } // Snapshot the wall-clock scroll anchor BEFORE the rebuild overwrites diff --git a/pkg/tui/springs.go b/pkg/tui/springs.go index 2873daa..7df9dd7 100644 --- a/pkg/tui/springs.go +++ b/pkg/tui/springs.go @@ -35,6 +35,9 @@ func (m *Model) handleSpringTick(msg springTickMsg) tea.Cmd { if m.springKind == springKindZoom { return m.handleZoomSpringTick(m.springGen) } + if m.springKind == springKindProjects { + return m.handleProjectsSpringTick(m.springGen) + } gen := m.springGen switch m.springPhase { case springShrinking: diff --git a/pkg/tui/viewport.go b/pkg/tui/viewport.go index f9e20fa..9ebca87 100644 --- a/pkg/tui/viewport.go +++ b/pkg/tui/viewport.go @@ -195,20 +195,32 @@ func (m Model) visibleBuckets() int { const projectsMaxRows = 12 // projectsHeight returns the OUTER height (incl. border) reserved for the -// projects box: the rows its content actually needs — border (2) + title (1) -// + ceil(len(projectAggs)/cols) body rows at the current column packing — -// capped at half the post-header area and projectsMaxRows, and 0 when the -// terminal is too short to host both the chart's 5-row floor and a usable -// box. Content-aware since #420: the box shrinks to its aggregates and -// chartHeight reclaims the rows. Heights depend on aggs but never the -// reverse (refreshProjects derives its window from width/scroll state), so -// there is no cycle; every projectAggs mutation re-syncs the viewport in -// one pass (refreshChart inline before its paint, the debounced settle via -// applyProjectsResize, clearChart self-contained). +// projects box. While a slide is in flight it returns the animated value so the +// chart cedes/reclaims rows in lockstep (#416); otherwise 0 when hidden, else +// the steady target (content-aware since #420 — see projectsTargetHeight). func (m Model) projectsHeight() int { + if m.springActive && m.springKind == springKindProjects { + return m.projectsAnimH + } if !m.showProjects { return 0 } + return m.projectsTargetHeight() +} + +// projectsTargetHeight is the steady-state outer box height: the rows its +// content actually needs — border (2) + title (1) + ceil(len(projectAggs)/ +// cols) body rows at the current column packing — capped at half the +// post-header area and projectsMaxRows, and 0 when the terminal is too short +// to host both the chart's 5-row floor and a usable box. Content-aware since +// #420: the box shrinks to its aggregates and chartHeight reclaims the rows. +// Heights depend on aggs but never the reverse (refreshProjects derives its +// window from width/scroll state), so there is no cycle; every projectAggs +// mutation re-syncs the viewport in one pass (refreshChart inline before its +// paint, the debounced settle via applyProjectsResize, clearChart +// self-contained). The #416 slide arms against this target, so the arm must +// populate projectAggs BEFORE reading it (beginProjectsAnimation). +func (m Model) projectsTargetHeight() int { avail := m.h - 7 // shared by chart + projects box (same overhead as chartHeight) // Need the chart's 5-row floor + a minimum 4-row box (border+title+1 row). if avail < 5+4 { diff --git a/pkg/tui/zoomspring_test.go b/pkg/tui/zoomspring_test.go index b036f3e..b6f7189 100644 --- a/pkg/tui/zoomspring_test.go +++ b/pkg/tui/zoomspring_test.go @@ -66,7 +66,7 @@ func TestVisibleWindow_RemainingGeometry(t *testing.T) { // seedRemainingModelWithSamples builds a remaining-mode model at the 15m zoom // with `n` usage samples spaced 15m apart ending at `now`, then refreshes so // lastPts5h / lastChart* / hasData reflect the seeded data. -func seedRemainingModelWithSamples(t *testing.T, n int, now time.Time) (Model, *cache.Cache) { +func seedRemainingModelWithSamples(t testing.TB, n int, now time.Time) (Model, *cache.Cache) { t.Helper() m, c := seedModelAt(t, int(chartUnitRemaining), 0, now) for i := range n { @@ -160,7 +160,7 @@ func TestZoomKey_Remaining_ReduceMotion_Snaps(t *testing.T) { // lastChart* / hasData reflect the seeded data. unitIdx is chartUnitCost or // chartUnitTokens. 60 buckets comfortably exceed visibleBuckets() so the chart // is data-filled (not warming up) across the seeded viewport. -func seedBarModelWithMessages(t *testing.T, unitIdx int, now time.Time) (Model, *cache.Cache) { +func seedBarModelWithMessages(t testing.TB, unitIdx int, now time.Time) (Model, *cache.Cache) { t.Helper() m, c := seedModelAt(t, unitIdx, 60, now) m.introPending = false