Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 29 additions & 30 deletions e2e/resource_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package e2e_test

import (
"encoding/json"
"fmt"
"strings"
"testing"
)
Expand Down Expand Up @@ -81,7 +82,19 @@ func TestChannelListNoTrunc(t *testing.T) {
// Member filters
// ---------------------------------------------------------------------------

// Test 132: member list --name
// memberByID reports whether any row has the given member_id.
func memberByID(items []map[string]any, id string) bool {
for _, item := range items {
if fmt.Sprintf("%v", item["member_id"]) == id {
return true
}
}
return false
}

// Test 132: member list --query by name. The generated --query matches name OR
// email server-side, so we assert the seed member is found (not that every row
// matches by name — a result may match via email).
func TestMemberListNameFilter(t *testing.T) {
r := runCLI(t, "member", "list", "--json")
requireSuccess(t, r)
Expand All @@ -90,13 +103,16 @@ func TestMemberListNameFilter(t *testing.T) {
t.Skip("no members available")
}

seedID := fmt.Sprintf("%v", members[0]["member_id"])
filter := mustStringField(t, members[0], "member_name")
r = runCLI(t, "member", "list", "--name", filter, "--json")
r = runCLI(t, "member", "list", "--query", filter, "--json")
requireSuccess(t, r)
requireAllMatchSubstring(t, decodeObjectList(t, r.Stdout), "member_name", filter)
if !memberByID(decodeObjectList(t, r.Stdout), seedID) {
t.Fatalf("--query %q did not return seed member %s", filter, seedID)
}
}

// Test 133: member list --email
// Test 133: member list --query by email.
func TestMemberListEmailFilter(t *testing.T) {
r := runCLI(t, "member", "list", "--json")
requireSuccess(t, r)
Expand All @@ -105,37 +121,20 @@ func TestMemberListEmailFilter(t *testing.T) {
t.Skip("no members available")
}

seedID := fmt.Sprintf("%v", members[0]["member_id"])
filter := mustStringField(t, members[0], "email")
r = runCLI(t, "member", "list", "--email", filter, "--json")
requireSuccess(t, r)
requireAllMatchSubstring(t, decodeObjectList(t, r.Stdout), "email", filter)
}

// Test 134: member list --name + --email combined
func TestMemberListNameAndEmailFilter(t *testing.T) {
r := runCLI(t, "member", "list", "--json")
r = runCLI(t, "member", "list", "--query", filter, "--json")
requireSuccess(t, r)
members := decodeObjectList(t, r.Stdout)
if len(members) == 0 {
t.Skip("no members available")
if !memberByID(decodeObjectList(t, r.Stdout), seedID) {
t.Fatalf("--query %q did not return seed member %s", filter, seedID)
}
}

nameFilter := mustStringField(t, members[0], "member_name")
emailFilter := mustStringField(t, members[0], "email")
r = runCLI(t, "member", "list", "--name", nameFilter, "--email", emailFilter, "--json")
// Test 134: member list --query returns valid JSON for an arbitrary term.
func TestMemberListQueryReturnsJSON(t *testing.T) {
r := runCLI(t, "member", "list", "--query", "a", "--json")
requireSuccess(t, r)
results := decodeObjectList(t, r.Stdout)
if len(results) == 0 {
t.Fatalf("expected at least one result for name=%q email=%q", nameFilter, emailFilter)
}
for _, item := range results {
if !strings.Contains(mustStringField(t, item, "member_name"), nameFilter) {
t.Fatalf("member_name did not match combined filter %q", nameFilter)
}
if !strings.Contains(mustStringField(t, item, "email"), emailFilter) {
t.Fatalf("email did not match combined filter %q", emailFilter)
}
}
requireValidJSON(t, r.Stdout)
}

// Test 135: member list --page 1
Expand Down
4 changes: 2 additions & 2 deletions e2e/resource_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ func TestMemberListJSON(t *testing.T) {

// Test 139: member list empty results
func TestMemberListNoResults(t *testing.T) {
r := runCLI(t, "member", "list", "--name", "nonexistent_xyz_999", "--email", "nonexistent_xyz")
r := runCLI(t, "member", "list", "--query", "nonexistent_xyz_999")
requireSuccess(t, r)
requireContains(t, r.Stdout, "No members found.")
requireContains(t, r.Stdout, "No results.")
}

// ---------------------------------------------------------------------------
Expand Down
57 changes: 56 additions & 1 deletion internal/cli/display_columns.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,47 @@
package cli

import (
"fmt"

"github.com/flashcatcloud/flashduty-cli/internal/output"
)

// colSpec is a display-only column for the generic table renderer: which row
// field to show, its header, and an optional width cap. It NEVER affects flags or
// json/toon output — a wrong entry degrades a single table column at worst, it
// can't cause a functional error. Field is the Go struct field name on the row
// type; timestamp fields are detected and formatted automatically.
// type; timestamp fields are detected and formatted automatically. Format, when
// set, renders the raw field value with a semantic formatter (percent, duration)
// the default scalar rendering doesn't apply — display-only, like the rest.
type colSpec struct {
Header string
Field string
MaxWidth int
Format func(any) string
}

// fmtPercent renders a 0..1 ratio as a whole-number percentage ("85%"), matching
// the curated insight tables. A non-float value yields "".
func fmtPercent(v any) string {
if f, ok := v.(float64); ok {
return fmt.Sprintf("%.0f%%", f*100)
}
return ""
}

// fmtSecondsDuration renders a seconds count (int or float) as a human duration
// ("2m 30s"), matching the curated insight MTTA/MTTR/engaged columns.
func fmtSecondsDuration(v any) string {
switch n := v.(type) {
case float64:
return output.FormatDurationFloat(n)
case int64:
return output.FormatDuration(int(n))
case int:
return output.FormatDuration(n)
default:
return ""
}
}

// displayColumns maps a go-flashduty response row type (by Go type name) to its
Expand Down Expand Up @@ -106,4 +139,26 @@ var displayColumns = map[string][]colSpec{
{Header: "EMAIL", Field: "Email"},
{Header: "STATUS", Field: "Status"},
},
// DimensionInsightItem backs both `insight team` and `insight channel` (same
// Go type, different populated name field) — show both name columns, the
// irrelevant one renders empty. Columns mirror the curated insight tables.
"DimensionInsightItem": {
{Header: "TEAM", Field: "TeamName", MaxWidth: 30},
{Header: "CHANNEL", Field: "ChannelName", MaxWidth: 30},
{Header: "INCIDENTS", Field: "TotalIncidentCnt"},
{Header: "ACK%", Field: "AcknowledgementPct", Format: fmtPercent},
{Header: "MTTA", Field: "MeanSecondsToAck", Format: fmtSecondsDuration},
{Header: "MTTR", Field: "MeanSecondsToClose", Format: fmtSecondsDuration},
{Header: "NOISE_REDUCTION", Field: "NoiseReductionPct", Format: fmtPercent},
{Header: "ALERTS", Field: "TotalAlertCnt"},
{Header: "EVENTS", Field: "TotalAlertEventCnt"},
},
"ResponderInsightItem": {
{Header: "RESPONDER", Field: "ResponderName", MaxWidth: 30},
{Header: "INCIDENTS", Field: "TotalIncidentCnt"},
{Header: "ACK%", Field: "AcknowledgementPct", Format: fmtPercent},
{Header: "MTTA", Field: "MeanSecondsToAck", Format: fmtSecondsDuration},
{Header: "INTERRUPTIONS", Field: "TotalInterruptions"},
{Header: "ENGAGED", Field: "TotalEngagedSeconds", Format: fmtSecondsDuration},
},
}
47 changes: 41 additions & 6 deletions internal/cli/generic_table.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,15 @@ func columnsForType(rowType reflect.Type) []output.Column {
if specs, ok := displayColumns[rowType.Name()]; ok {
cols := make([]output.Column, 0, len(specs))
for _, s := range specs {
field, format := s.Field, s.Format
fieldFn := func(item any) string { return fieldString(item, field) }
if format != nil {
fieldFn = func(item any) string { return format(fieldValue(item, field)) }
}
cols = append(cols, output.Column{
Header: s.Header,
MaxWidth: s.MaxWidth,
Field: func(item any) string { return fieldString(item, s.Field) },
Field: fieldFn,
})
}
return cols
Expand Down Expand Up @@ -205,24 +210,54 @@ func renderVertical(ctx *RunContext, v reflect.Value) error {
return ctx.Printer.Print(rows, cols)
}

// fieldString reads the named Go field from a row item (deref'ing a pointer row)
// and formats it. An absent field yields "" rather than panicking, so a stale
// displayColumns entry degrades a column instead of crashing the command.
func fieldString(item any, goField string) string {
// derefStruct dereferences pointer chains and returns the underlying struct
// reflect.Value. The second return is false when item is nil, not a struct after
// dereferencing, or any pointer in the chain is nil.
func derefStruct(item any) (reflect.Value, bool) {
rv := reflect.ValueOf(item)
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return ""
return reflect.Value{}, false
}
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
return reflect.Value{}, false
}
return rv, true
}

// fieldString reads the named Go field from a row item and formats it as a
// string. An absent field yields "" rather than panicking, so a stale
// displayColumns entry degrades a column instead of crashing the command.
func fieldString(item any, goField string) string {
rv, ok := derefStruct(item)
if !ok {
return ""
}
fv := rv.FieldByName(goField)
if !fv.IsValid() {
return ""
}
return scalarString(fv)
}

// fieldValue reads the named Go field's raw value from a row item, returning
// nil when absent. It feeds a colSpec.Format function so a column can apply
// semantic formatting (percent, duration) that the default scalar rendering
// doesn't know about.
func fieldValue(item any, goField string) any {
rv, ok := derefStruct(item)
if !ok {
return nil
}
fv := rv.FieldByName(goField)
if !fv.IsValid() || !fv.CanInterface() {
return nil
}
return fv.Interface()
}

// scalarString formats a scalar (or timestamp) reflect value. Non-scalars yield
// "" — the generic table never renders nested objects/arrays.
func scalarString(fv reflect.Value) string {
Expand Down
25 changes: 25 additions & 0 deletions internal/cli/generic_table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,29 @@ func TestRenderGenericTable_DisplayColumns(t *testing.T) {
}
}

func TestRenderGenericTable_FormattedColumns(t *testing.T) {
// insight rows carry ratio/seconds fields the curated tables rendered as
// percent/duration; the colSpec.Format path must apply that, not print the
// raw float.
var buf bytes.Buffer
rows := []flashduty.DimensionInsightItem{
{TeamName: "sre", TotalIncidentCnt: 4, AcknowledgementPct: 0.85, MeanSecondsToAck: 150},
}
if err := renderGenericTable(tableCtx(&buf), rows); err != nil {
t.Fatalf("render: %v", err)
}
got := buf.String()
for _, want := range []string{"TEAM", "ACK%", "MTTA", "sre", "85%"} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q\n---\n%s", want, got)
}
}
// The raw ratio must NOT leak — proves Format ran instead of scalarString.
if strings.Contains(got, "0.85") {
t.Errorf("raw ratio leaked; Format not applied\n%s", got)
}
}

func TestRenderGenericTable_Heuristic(t *testing.T) {
var buf bytes.Buffer
resp := &fakeListResp{Items: []heuristicRow{{Name: "alpha", Count: 7}}, Total: 1}
Expand Down Expand Up @@ -156,6 +179,8 @@ var displayColumnSamples = []any{
flashduty.FieldItem{},
flashduty.WarRoomItem{},
flashduty.WarRoomPersonItem{},
flashduty.DimensionInsightItem{},
flashduty.ResponderInsightItem{},
}

// TestDisplayColumns_FieldsResolve guards against typos: every displayColumns
Expand Down
Loading