mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
730 lines
23 KiB
Go
730 lines
23 KiB
Go
package tui
|
|
|
|
import (
|
|
"testing"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"github.com/insights/insights/phrocs/internal/config"
|
|
"github.com/insights/insights/phrocs/internal/process"
|
|
)
|
|
|
|
// ── buildGroupedEntries ──────────────────────────────────────────────────────
|
|
|
|
func TestBuildGroupedEntries_noDimension(t *testing.T) {
|
|
procs := []*process.Process{
|
|
{Name: "alpha"},
|
|
{Name: "beta"},
|
|
}
|
|
cfg := &config.Config{}
|
|
entries := buildGroupedEntries(procs, "", cfg)
|
|
if len(entries) != 2 {
|
|
t.Fatalf("empty dim: want 2 entries, got %d", len(entries))
|
|
}
|
|
for i, e := range entries {
|
|
if e.isHeader() {
|
|
t.Errorf("entry %d should not be a header", i)
|
|
}
|
|
if e.proc != procs[i] {
|
|
t.Errorf("entry %d proc mismatch", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildGroupedEntries_withDimension(t *testing.T) {
|
|
procs := []*process.Process{
|
|
{Name: "backend", Cfg: config.ProcConfig{Groups: map[string]string{"layer": "Application"}}},
|
|
{Name: "capture", Cfg: config.ProcConfig{Groups: map[string]string{"layer": "Ingestion"}}},
|
|
{Name: "docker", Cfg: config.ProcConfig{Groups: map[string]string{"layer": "Infrastructure"}}},
|
|
}
|
|
cfg := &config.Config{
|
|
GroupOrder: map[string][]string{
|
|
"layer": {"Application", "Ingestion", "Infrastructure"},
|
|
},
|
|
}
|
|
entries := buildGroupedEntries(procs, "layer", cfg)
|
|
|
|
// Count headers and processes (spacers don't count)
|
|
headers := 0
|
|
processes := 0
|
|
for _, e := range entries {
|
|
if e.isHeader() {
|
|
headers++
|
|
} else if !e.spacer {
|
|
processes++
|
|
}
|
|
}
|
|
if processes != 3 {
|
|
t.Errorf("want 3 process entries, got %d", processes)
|
|
}
|
|
if headers != 3 {
|
|
t.Errorf("want 3 group headers, got %d", headers)
|
|
}
|
|
|
|
// Application should come before Ingestion which comes before Infrastructure
|
|
var appIdx, ingIdx, infraIdx int
|
|
for i, e := range entries {
|
|
if e.isHeader() {
|
|
switch e.groupHeader {
|
|
case "Application":
|
|
appIdx = i
|
|
case "Ingestion":
|
|
ingIdx = i
|
|
case "Infrastructure":
|
|
infraIdx = i
|
|
}
|
|
}
|
|
}
|
|
if appIdx >= ingIdx {
|
|
t.Errorf("Application (%d) should come before Ingestion (%d)", appIdx, ingIdx)
|
|
}
|
|
if ingIdx >= infraIdx {
|
|
t.Errorf("Ingestion (%d) should come before Infrastructure (%d)", ingIdx, infraIdx)
|
|
}
|
|
}
|
|
|
|
func TestBuildGroupedEntries_emptyGroupsOmitted(t *testing.T) {
|
|
procs := []*process.Process{
|
|
{Name: "backend", Cfg: config.ProcConfig{Groups: map[string]string{"layer": "Application"}}},
|
|
}
|
|
cfg := &config.Config{
|
|
GroupOrder: map[string][]string{
|
|
"layer": {"Application", "Ingestion"},
|
|
},
|
|
}
|
|
entries := buildGroupedEntries(procs, "layer", cfg)
|
|
|
|
for _, e := range entries {
|
|
if e.isHeader() && e.groupHeader == "Ingestion" {
|
|
t.Error("Ingestion header should be omitted when no processes belong to it")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildGroupedEntries_ungroupedFallback(t *testing.T) {
|
|
procs := []*process.Process{
|
|
{Name: "backend", Cfg: config.ProcConfig{Groups: map[string]string{"layer": "Application"}}},
|
|
{Name: "mystery", Cfg: config.ProcConfig{}}, // no groups at all
|
|
}
|
|
cfg := &config.Config{
|
|
GroupOrder: map[string][]string{
|
|
"layer": {"Application"},
|
|
},
|
|
}
|
|
entries := buildGroupedEntries(procs, "layer", cfg)
|
|
|
|
hasUngrouped := false
|
|
for _, e := range entries {
|
|
if e.isHeader() && e.groupHeader == ungroupedName {
|
|
hasUngrouped = true
|
|
}
|
|
}
|
|
if !hasUngrouped {
|
|
t.Error("process without groups should appear under Ungrouped header")
|
|
}
|
|
}
|
|
|
|
func TestBuildGroupedEntries_pinnedGroup(t *testing.T) {
|
|
procs := []*process.Process{
|
|
{Name: "backend", Cfg: config.ProcConfig{Groups: map[string]string{"layer": "Application"}}},
|
|
{Name: "info", Cfg: config.ProcConfig{Groups: map[string]string{"layer": "pinned"}}},
|
|
}
|
|
cfg := &config.Config{}
|
|
entries := buildGroupedEntries(procs, "layer", cfg)
|
|
|
|
// Pinned process should be first, before any headers
|
|
if len(entries) == 0 || entries[0].proc == nil || entries[0].proc.Name != "info" {
|
|
t.Error("process with groups.layer=pinned should be first entry")
|
|
}
|
|
// Pinned should not have a header above it
|
|
for _, e := range entries {
|
|
if e.isHeader() && e.groupHeader == "pinned" {
|
|
t.Error("pinned should not appear as a group header")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildGroupedEntries_pinnedInOneDimensionGroupedInAnother(t *testing.T) {
|
|
procs := []*process.Process{
|
|
{Name: "info", Cfg: config.ProcConfig{Groups: map[string]string{"layer": "pinned", "tech": "Other"}}},
|
|
{Name: "backend", Cfg: config.ProcConfig{Groups: map[string]string{"layer": "Application", "tech": "Python"}}},
|
|
}
|
|
cfg := &config.Config{}
|
|
|
|
// Pinned in layer
|
|
layerEntries := buildGroupedEntries(procs, "layer", cfg)
|
|
if len(layerEntries) == 0 || layerEntries[0].proc == nil || layerEntries[0].proc.Name != "info" {
|
|
t.Error("info should be pinned when grouped by layer")
|
|
}
|
|
|
|
// Not pinned in tech — should appear under its group
|
|
techEntries := buildGroupedEntries(procs, "tech", cfg)
|
|
if len(techEntries) > 0 && techEntries[0].proc != nil && techEntries[0].proc.Name == "info" {
|
|
t.Error("info should not be pinned when grouped by tech (it has tech=Other)")
|
|
}
|
|
}
|
|
|
|
func TestBuildGroupedEntries_missingDimensionFallsToUngrouped(t *testing.T) {
|
|
// Process has groups but not the active dimension
|
|
procs := []*process.Process{
|
|
{Name: "backend", Cfg: config.ProcConfig{Groups: map[string]string{"tech": "Rust"}}},
|
|
}
|
|
cfg := &config.Config{}
|
|
entries := buildGroupedEntries(procs, "layer", cfg)
|
|
|
|
hasUngrouped := false
|
|
for _, e := range entries {
|
|
if e.isHeader() && e.groupHeader == ungroupedName {
|
|
hasUngrouped = true
|
|
}
|
|
}
|
|
if !hasUngrouped {
|
|
t.Error("process with groups but missing the active dimension should appear under Ungrouped")
|
|
}
|
|
}
|
|
|
|
func TestBuildGroupedEntries_ungroupedAppearsAfterConfiguredGroups(t *testing.T) {
|
|
procs := []*process.Process{
|
|
{Name: "backend", Cfg: config.ProcConfig{Groups: map[string]string{"layer": "Application"}}},
|
|
{Name: "mystery", Cfg: config.ProcConfig{}},
|
|
}
|
|
cfg := &config.Config{
|
|
GroupOrder: map[string][]string{
|
|
"layer": {"Application"},
|
|
},
|
|
}
|
|
entries := buildGroupedEntries(procs, "layer", cfg)
|
|
|
|
appIdx, ungroupedIdx := -1, -1
|
|
for i, e := range entries {
|
|
if e.isHeader() && e.groupHeader == "Application" {
|
|
appIdx = i
|
|
}
|
|
if e.isHeader() && e.groupHeader == ungroupedName {
|
|
ungroupedIdx = i
|
|
}
|
|
}
|
|
if appIdx < 0 || ungroupedIdx < 0 {
|
|
t.Fatalf("expected both Application and Ungrouped headers, got appIdx=%d ungroupedIdx=%d", appIdx, ungroupedIdx)
|
|
}
|
|
if ungroupedIdx <= appIdx {
|
|
t.Errorf("Ungrouped (%d) should come after Application (%d)", ungroupedIdx, appIdx)
|
|
}
|
|
}
|
|
|
|
// ── capability dimension ─────────────────────────────────────────────────────
|
|
|
|
// The capability dimension is inferred by config.Load() (it copies each
|
|
// proc's Capability field into Groups["capability"]). These tests exercise
|
|
// the TUI layer: once Groups["capability"] is populated, all the existing
|
|
// grouping mechanics should work unchanged.
|
|
|
|
func TestBuildGroupedEntries_capabilityDimension(t *testing.T) {
|
|
procs := []*process.Process{
|
|
{Name: "capture", Cfg: config.ProcConfig{Groups: map[string]string{"capability": "event_ingestion"}}},
|
|
{Name: "nodejs", Cfg: config.ProcConfig{Groups: map[string]string{"capability": "event_ingestion"}}},
|
|
{Name: "feature-flags", Cfg: config.ProcConfig{Groups: map[string]string{"capability": "flag_evaluation"}}},
|
|
}
|
|
cfg := &config.Config{
|
|
GroupOrder: map[string][]string{
|
|
"capability": {"event_ingestion", "flag_evaluation"},
|
|
},
|
|
}
|
|
entries := buildGroupedEntries(procs, "capability", cfg)
|
|
|
|
var headerOrder []string
|
|
procsUnder := map[string][]string{}
|
|
currentHeader := ""
|
|
for _, e := range entries {
|
|
switch {
|
|
case e.isHeader():
|
|
headerOrder = append(headerOrder, e.groupHeader)
|
|
currentHeader = e.groupHeader
|
|
case e.proc != nil:
|
|
procsUnder[currentHeader] = append(procsUnder[currentHeader], e.proc.Name)
|
|
}
|
|
}
|
|
|
|
if len(headerOrder) != 2 || headerOrder[0] != "event_ingestion" || headerOrder[1] != "flag_evaluation" {
|
|
t.Errorf("header order: got %v, want [event_ingestion flag_evaluation]", headerOrder)
|
|
}
|
|
if len(procsUnder["event_ingestion"]) != 2 {
|
|
t.Errorf("event_ingestion: got %v, want 2 procs", procsUnder["event_ingestion"])
|
|
}
|
|
if len(procsUnder["flag_evaluation"]) != 1 || procsUnder["flag_evaluation"][0] != "feature-flags" {
|
|
t.Errorf("flag_evaluation: got %v, want [feature-flags]", procsUnder["flag_evaluation"])
|
|
}
|
|
}
|
|
|
|
func TestBuildGroupedEntries_capabilityUngroupedFallback(t *testing.T) {
|
|
// Procs without a capability (e.g. backend, frontend, manual tools) have
|
|
// no Groups["capability"], so they fall under Ungrouped — matching how
|
|
// layer/tech behave today.
|
|
procs := []*process.Process{
|
|
{Name: "capture", Cfg: config.ProcConfig{Groups: map[string]string{"capability": "event_ingestion"}}},
|
|
{Name: "backend", Cfg: config.ProcConfig{}},
|
|
}
|
|
cfg := &config.Config{
|
|
GroupOrder: map[string][]string{"capability": {"event_ingestion"}},
|
|
}
|
|
entries := buildGroupedEntries(procs, "capability", cfg)
|
|
|
|
hasUngrouped := false
|
|
for _, e := range entries {
|
|
if e.isHeader() && e.groupHeader == ungroupedName {
|
|
hasUngrouped = true
|
|
}
|
|
}
|
|
if !hasUngrouped {
|
|
t.Error("proc without capability should appear under Ungrouped")
|
|
}
|
|
}
|
|
|
|
func TestBuildGroupedEntries_capabilityPinned(t *testing.T) {
|
|
// The `info` proc uses capability=pinned to stay at the top regardless
|
|
// of the active dimension, matching its layer=pinned / tech=pinned.
|
|
procs := []*process.Process{
|
|
{Name: "capture", Cfg: config.ProcConfig{Groups: map[string]string{"capability": "event_ingestion"}}},
|
|
{Name: "info", Cfg: config.ProcConfig{Groups: map[string]string{"capability": "pinned"}}},
|
|
}
|
|
cfg := &config.Config{}
|
|
entries := buildGroupedEntries(procs, "capability", cfg)
|
|
|
|
if len(entries) == 0 || entries[0].proc == nil || entries[0].proc.Name != "info" {
|
|
t.Error("info should be pinned at the top of capability grouping")
|
|
}
|
|
for _, e := range entries {
|
|
if e.isHeader() && e.groupHeader == "pinned" {
|
|
t.Error("pinned should never appear as a group header")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── groupDimensions ──────────────────────────────────────────────────────────
|
|
|
|
func TestGroupDimensions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cfg *config.Config
|
|
want []string
|
|
}{
|
|
{
|
|
name: "no groups",
|
|
cfg: &config.Config{Procs: map[string]config.ProcConfig{"a": {Shell: "echo hi"}}},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "from procs",
|
|
cfg: &config.Config{Procs: map[string]config.ProcConfig{
|
|
"a": {Groups: map[string]string{"layer": "App", "tech": "Python"}},
|
|
"b": {Groups: map[string]string{"layer": "Infra"}},
|
|
}},
|
|
want: []string{"layer", "tech"},
|
|
},
|
|
{
|
|
name: "from group_order only",
|
|
cfg: &config.Config{GroupOrder: map[string][]string{"team": {"Platform", "Product"}}},
|
|
want: []string{"team"},
|
|
},
|
|
{
|
|
name: "extensible — multiple procs, overlapping dims",
|
|
cfg: &config.Config{Procs: map[string]config.ProcConfig{
|
|
"plain": {Shell: "echo"},
|
|
"simple": {Shell: "echo", Groups: map[string]string{"layer": "App"}},
|
|
"complex": {Shell: "echo", Groups: map[string]string{"tech": "Rust", "team": "Infra", "cost": "High"}},
|
|
}},
|
|
want: []string{"cost", "layer", "team", "tech"},
|
|
},
|
|
{
|
|
// capability is inferred from ProcConfig.Capability and always
|
|
// sorts after user-declared dims so the `g` cycle ends on it.
|
|
name: "capability is always last",
|
|
cfg: &config.Config{Procs: map[string]config.ProcConfig{
|
|
"a": {Groups: map[string]string{"layer": "App", "tech": "Python", "capability": "event_ingestion"}},
|
|
"b": {Groups: map[string]string{"capability": "flag_evaluation"}},
|
|
}},
|
|
want: []string{"layer", "tech", "capability"},
|
|
},
|
|
{
|
|
name: "capability only",
|
|
cfg: &config.Config{Procs: map[string]config.ProcConfig{
|
|
"a": {Groups: map[string]string{"capability": "event_ingestion"}},
|
|
}},
|
|
want: []string{"capability"},
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dims := groupDimensions(tc.cfg)
|
|
if len(dims) != len(tc.want) {
|
|
t.Fatalf("got %v, want %v", dims, tc.want)
|
|
}
|
|
for i := range dims {
|
|
if dims[i] != tc.want[i] {
|
|
t.Errorf("got %v, want %v", dims, tc.want)
|
|
break
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── initialGroupDimIndex ─────────────────────────────────────────────────────
|
|
|
|
func TestInitialGroupDimIndex(t *testing.T) {
|
|
dims := []string{"layer", "tech", "capability"}
|
|
cases := []struct {
|
|
name string
|
|
defaultGroup string
|
|
want int
|
|
}{
|
|
{"unset is ungrouped", "", -1},
|
|
{"first dimension", "layer", 0},
|
|
{"middle dimension", "tech", 1},
|
|
{"last dimension", "capability", 2},
|
|
{"unknown dimension is ungrouped", "team", -1},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := initialGroupDimIndex(dims, tc.defaultGroup); got != tc.want {
|
|
t.Errorf("initialGroupDimIndex(%v, %q) = %d, want %d", dims, tc.defaultGroup, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── TUI integration ──────────────────────────────────────────────────────────
|
|
|
|
func readyGroupModel(t *testing.T) Model {
|
|
t.Helper()
|
|
f := false
|
|
procs := map[string]config.ProcConfig{
|
|
"backend": {Shell: "start-backend", Autostart: &f, Groups: map[string]string{"layer": "Application", "tech": "Python"}},
|
|
"capture": {Shell: "start-rust-service capture", Autostart: &f, Groups: map[string]string{"layer": "Ingestion", "tech": "Rust"}},
|
|
"docker-compose": {Shell: "docker compose up", Autostart: &f, Groups: map[string]string{"layer": "Infrastructure", "tech": "Docker"}},
|
|
"celery-worker": {Shell: "start-celery worker", Autostart: &f, Groups: map[string]string{"layer": "Processing", "tech": "Python"}},
|
|
"frontend": {Shell: "start-frontend", Autostart: &f, Groups: map[string]string{"layer": "Application", "tech": "Frontend"}},
|
|
}
|
|
cfg := &config.Config{
|
|
Procs: procs,
|
|
MouseScrollSpeed: 3,
|
|
Scrollback: 1000,
|
|
GroupOrder: map[string][]string{
|
|
"layer": {"Application", "Processing", "Ingestion", "Infrastructure"},
|
|
"tech": {"Python", "Frontend", "Rust", "Docker"},
|
|
},
|
|
}
|
|
mgr := process.NewManager(cfg)
|
|
m := New(mgr, cfg, "", nil)
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
|
return next.(Model)
|
|
}
|
|
|
|
func TestGroup_cycleWithG(t *testing.T) {
|
|
m := readyGroupModel(t)
|
|
if m.isGrouped() {
|
|
t.Fatal("should start ungrouped")
|
|
}
|
|
m = update(m, keypress('g'))
|
|
if m.activeGroupDim() != "layer" {
|
|
t.Errorf("after first g: got %q, want layer", m.activeGroupDim())
|
|
}
|
|
m = update(m, keypress('g'))
|
|
if m.activeGroupDim() != "tech" {
|
|
t.Errorf("after second g: got %q, want tech", m.activeGroupDim())
|
|
}
|
|
m = update(m, keypress('g'))
|
|
if m.isGrouped() {
|
|
t.Errorf("after third g: should be ungrouped, got %q", m.activeGroupDim())
|
|
}
|
|
}
|
|
|
|
func TestGroup_defaultGroupStartsGrouped(t *testing.T) {
|
|
f := false
|
|
cfg := &config.Config{
|
|
Procs: map[string]config.ProcConfig{
|
|
"backend": {Shell: "echo", Autostart: &f, Groups: map[string]string{"layer": "Application"}},
|
|
"capture": {Shell: "echo", Autostart: &f, Groups: map[string]string{"layer": "Ingestion"}},
|
|
"frontend": {Shell: "echo", Autostart: &f, Groups: map[string]string{"layer": "Application"}},
|
|
},
|
|
DefaultGroup: "layer",
|
|
MouseScrollSpeed: 3,
|
|
Scrollback: 1000,
|
|
GroupOrder: map[string][]string{"layer": {"Application", "Ingestion"}},
|
|
}
|
|
mgr := process.NewManager(cfg)
|
|
m := New(mgr, cfg, "", nil)
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
|
m = next.(Model)
|
|
|
|
if !m.isGrouped() {
|
|
t.Fatal("default_group set: model should start grouped")
|
|
}
|
|
if m.activeGroupDim() != "layer" {
|
|
t.Errorf("default_group: got %q, want layer", m.activeGroupDim())
|
|
}
|
|
// Cursor must land on a real process, never a header.
|
|
if m.sidebarEntries[m.entryCursor].isNonSelectable() {
|
|
t.Error("entryCursor landed on a non-selectable entry on startup")
|
|
}
|
|
}
|
|
|
|
func TestGroup_cyclesThroughAllDimensions(t *testing.T) {
|
|
f := false
|
|
procs := map[string]config.ProcConfig{
|
|
"a": {Shell: "echo", Autostart: &f, Groups: map[string]string{"layer": "App", "tech": "Python", "team": "Platform", "cost": "High"}},
|
|
"b": {Shell: "echo", Autostart: &f, Groups: map[string]string{"layer": "Infra"}},
|
|
}
|
|
cfg := &config.Config{
|
|
Procs: procs,
|
|
MouseScrollSpeed: 3,
|
|
Scrollback: 1000,
|
|
}
|
|
mgr := process.NewManager(cfg)
|
|
m := New(mgr, cfg, "", nil)
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
|
m = next.(Model)
|
|
|
|
// Should have 4 dimensions: cost, layer, team, tech (alphabetical)
|
|
if len(m.groupDims) != 4 {
|
|
t.Fatalf("want 4 dimensions, got %d: %v", len(m.groupDims), m.groupDims)
|
|
}
|
|
|
|
// Cycle through all 4, then back to none
|
|
expected := []string{"cost", "layer", "team", "tech", ""}
|
|
for _, want := range expected {
|
|
m = update(m, keypress('g'))
|
|
got := m.activeGroupDim()
|
|
if got != want {
|
|
t.Errorf("after g: got %q, want %q", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGroup_sidebarEntriesHaveHeaders(t *testing.T) {
|
|
m := readyGroupModel(t)
|
|
m = update(m, keypress('g')) // layer
|
|
hasHeader := false
|
|
for _, e := range m.sidebarEntries {
|
|
if e.isHeader() {
|
|
hasHeader = true
|
|
break
|
|
}
|
|
}
|
|
if !hasHeader {
|
|
t.Error("grouped mode should have at least one header entry")
|
|
}
|
|
}
|
|
|
|
func TestGroup_navigationDownSkipsHeadersAndSpacers(t *testing.T) {
|
|
m := readyGroupModel(t)
|
|
m = update(m, keypress('g')) // layer
|
|
|
|
hasHeader, hasSpacer := false, false
|
|
for _, e := range m.sidebarEntries {
|
|
if e.isHeader() {
|
|
hasHeader = true
|
|
}
|
|
if e.spacer {
|
|
hasSpacer = true
|
|
}
|
|
}
|
|
if !hasHeader {
|
|
t.Fatal("test setup: expected at least one group header in entries")
|
|
}
|
|
if !hasSpacer {
|
|
t.Fatal("test setup: expected at least one spacer in entries")
|
|
}
|
|
|
|
for i := 0; i < len(m.sidebarEntries); i++ {
|
|
entry := m.sidebarEntries[m.entryCursor]
|
|
if entry.isHeader() {
|
|
t.Errorf("down: entryCursor %d landed on header %q", m.entryCursor, entry.groupHeader)
|
|
}
|
|
if entry.spacer {
|
|
t.Errorf("down: entryCursor %d landed on spacer", m.entryCursor)
|
|
}
|
|
m = update(m, keypress('j'))
|
|
}
|
|
}
|
|
|
|
func TestGroup_navigationUpSkipsHeadersAndSpacers(t *testing.T) {
|
|
m := readyGroupModel(t)
|
|
m = update(m, keypress('g')) // layer
|
|
|
|
// Navigate to the last process
|
|
for i := 0; i < len(m.sidebarEntries); i++ {
|
|
m = update(m, keypress('j'))
|
|
}
|
|
|
|
for i := 0; i < len(m.sidebarEntries); i++ {
|
|
entry := m.sidebarEntries[m.entryCursor]
|
|
if entry.isHeader() {
|
|
t.Errorf("up: entryCursor %d landed on header %q", m.entryCursor, entry.groupHeader)
|
|
}
|
|
if entry.spacer {
|
|
t.Errorf("up: entryCursor %d landed on spacer", m.entryCursor)
|
|
}
|
|
m = update(m, keypress('k'))
|
|
}
|
|
}
|
|
|
|
func TestGroup_navigationPreservesProcess(t *testing.T) {
|
|
m := readyGroupModel(t)
|
|
for i, p := range m.services {
|
|
if p.Name == "capture" {
|
|
m.servicesCursor = i
|
|
break
|
|
}
|
|
}
|
|
m = update(m, keypress('g')) // layer
|
|
if p := m.activeProc(); p == nil || p.Name != "capture" {
|
|
name := ""
|
|
if p != nil {
|
|
name = p.Name
|
|
}
|
|
t.Errorf("after grouping, active proc should be capture, got %q", name)
|
|
}
|
|
}
|
|
|
|
func TestGroup_sortWithinGroups(t *testing.T) {
|
|
m := readyGroupModel(t)
|
|
m = update(m, keypress('g')) // layer
|
|
m = update(m, keypress('o')) // SortCPU
|
|
|
|
hasHeader := false
|
|
for _, e := range m.sidebarEntries {
|
|
if e.isHeader() {
|
|
hasHeader = true
|
|
break
|
|
}
|
|
}
|
|
if !hasHeader {
|
|
t.Error("sort change should preserve group headers")
|
|
}
|
|
if !m.isGrouped() {
|
|
t.Error("should still be grouped after sort change")
|
|
}
|
|
if m.sortMode != SortCPU {
|
|
t.Errorf("sortMode should be SortCPU, got %v", m.sortMode)
|
|
}
|
|
|
|
// Each process should appear under its correct group
|
|
for i, e := range m.sidebarEntries {
|
|
if e.isHeader() || e.proc == nil {
|
|
continue
|
|
}
|
|
expectedGroup, ok := e.proc.Cfg.Groups["layer"]
|
|
if !ok {
|
|
expectedGroup = ungroupedName
|
|
}
|
|
foundHeader := ""
|
|
for j := i - 1; j >= 0; j-- {
|
|
if m.sidebarEntries[j].isHeader() {
|
|
foundHeader = m.sidebarEntries[j].groupHeader
|
|
break
|
|
}
|
|
}
|
|
if foundHeader != expectedGroup {
|
|
t.Errorf("process %q is under header %q, want %q", e.proc.Name, foundHeader, expectedGroup)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGroup_cursorPreservedAcrossSortChange(t *testing.T) {
|
|
m := readyGroupModel(t)
|
|
m = update(m, keypress('g')) // layer
|
|
|
|
for i := 0; i < len(m.sidebarEntries); i++ {
|
|
if p := m.activeProc(); p != nil && p.Name == "frontend" {
|
|
break
|
|
}
|
|
m = update(m, keypress('j'))
|
|
}
|
|
if p := m.activeProc(); p == nil || p.Name != "frontend" {
|
|
t.Fatal("could not navigate to frontend")
|
|
}
|
|
|
|
m = update(m, keypress('o'))
|
|
if p := m.activeProc(); p == nil || p.Name != "frontend" {
|
|
name := ""
|
|
if p := m.activeProc(); p != nil {
|
|
name = p.Name
|
|
}
|
|
t.Errorf("after sort change, active proc should be frontend, got %q", name)
|
|
}
|
|
}
|
|
|
|
func TestGroup_backToNoneRestoresFlat(t *testing.T) {
|
|
m := readyGroupModel(t)
|
|
m = update(m, keypress('g')) // layer
|
|
m = update(m, keypress('g')) // tech
|
|
m = update(m, keypress('g')) // none
|
|
|
|
for _, e := range m.sidebarEntries {
|
|
if e.isHeader() {
|
|
t.Error("ungrouped should have no headers")
|
|
}
|
|
}
|
|
if len(m.sidebarEntries) != len(m.services) {
|
|
t.Errorf("ungrouped entries: got %d, want %d", len(m.sidebarEntries), len(m.services))
|
|
}
|
|
}
|
|
|
|
func TestGroup_ungroupedProcessIsNavigable(t *testing.T) {
|
|
f := false
|
|
procs := map[string]config.ProcConfig{
|
|
"backend": {Shell: "echo", Autostart: &f, Groups: map[string]string{"layer": "Application"}},
|
|
"mystery": {Shell: "echo", Autostart: &f}, // no groups
|
|
}
|
|
cfg := &config.Config{
|
|
Procs: procs,
|
|
MouseScrollSpeed: 3,
|
|
Scrollback: 1000,
|
|
GroupOrder: map[string][]string{
|
|
"layer": {"Application"},
|
|
},
|
|
}
|
|
mgr := process.NewManager(cfg)
|
|
m := New(mgr, cfg, "", nil)
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
|
m = next.(Model)
|
|
|
|
m = update(m, keypress('g')) // layer
|
|
|
|
// Navigate down until we find "mystery"
|
|
found := false
|
|
for i := 0; i < len(m.sidebarEntries); i++ {
|
|
if p := m.activeProc(); p != nil && p.Name == "mystery" {
|
|
found = true
|
|
break
|
|
}
|
|
m = update(m, keypress('j'))
|
|
}
|
|
if !found {
|
|
t.Error("should be able to navigate to ungrouped process")
|
|
}
|
|
}
|
|
|
|
func TestGroup_noGroupDimsDisablesGrouping(t *testing.T) {
|
|
// Config with no groups at all
|
|
f := false
|
|
cfg := &config.Config{
|
|
Procs: map[string]config.ProcConfig{
|
|
"a": {Shell: "echo", Autostart: &f},
|
|
"b": {Shell: "echo", Autostart: &f},
|
|
},
|
|
MouseScrollSpeed: 3,
|
|
Scrollback: 1000,
|
|
}
|
|
mgr := process.NewManager(cfg)
|
|
m := New(mgr, cfg, "", nil)
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
|
m = next.(Model)
|
|
|
|
// Pressing g should do nothing
|
|
m = update(m, keypress('g'))
|
|
if m.isGrouped() {
|
|
t.Error("g should not enable grouping when no dimensions exist")
|
|
}
|
|
}
|