mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-13 02:12:35 +00:00
f4adeedb8f
New subcommand that connects to a chosen kube context and walks the
user through three picker steps before writing forwards to the config:
1. Namespace multi-select (no system-namespace exclusion)
2. Service multi-select grouped by namespace, with already-configured
rows greyed out and locked off
3. Starting-port input with a live preview of consecutive local-port
assignments, skipping any port already taken by the existing config
or another row in the batch
Multi-port services emit one forward per port. Non-TCP ports are
skipped with a one-line warning at the end (kportal forward layer is
TCP-only). --dry-run prints the planned forwards without writing.
Subcommand dispatch lives in cmd/kportal/main.go: when os.Args[1] ==
'generate', runGenerate(os.Args[2:]) is invoked before the main
flag.Parse() so the flag.NewFlagSet 'generate' parses its own
'--context', '--config', '--dry-run' flags. Invalid contexts list
the available ones for quick correction.
Tests in internal/ui/generate_test.go cover:
- namespace toggle / toggle-all / filter
- service multi-select with locked already-configured rows and
non-TCP filtering
- port-collision-aware consecutive assignment using a real Mutator
against a tempdir config
- reject + recover for starting-port < 1024
- dry-run does not invoke the mutator
- end-to-end Update() walk through the three steps
- parse-starting-port boundary table
- port-step view rendering
- ServiceCandidate.Key() determinism
README updated with a 'Generate Forwards from a Cluster' section
describing the flow and the three flags.
530 lines
17 KiB
Go
530 lines
17 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/lukaszraczylo/kportal/internal/config"
|
|
"github.com/lukaszraczylo/kportal/internal/k8s"
|
|
)
|
|
|
|
// fakeMutator is a minimal MutatorInterface for tests that don't touch the
|
|
// filesystem. It records the order of AddForward calls.
|
|
type fakeMutator struct {
|
|
addError error
|
|
added []config.Forward
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (f *fakeMutator) AddForward(ctxName, ns string, fwd config.Forward) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.addError != nil {
|
|
return f.addError
|
|
}
|
|
fwd.SetContext(ctxName, ns)
|
|
f.added = append(f.added, fwd)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeMutator) RemoveForwards(predicate func(ctx, ns string, fwd config.Forward) bool) error {
|
|
return nil
|
|
}
|
|
func (f *fakeMutator) RemoveForwardByID(id string) error { return nil }
|
|
func (f *fakeMutator) UpdateForward(oldID, newCtx, newNS string, newFwd config.Forward) error {
|
|
return nil
|
|
}
|
|
|
|
// fakeDiscovery is a minimal DiscoveryInterface for tests.
|
|
type fakeDiscovery struct {
|
|
servicesByNS map[string][]k8s.ServiceInfo
|
|
listNamespacesEr error
|
|
listServicesEr error
|
|
namespaces []string
|
|
}
|
|
|
|
func (f *fakeDiscovery) ListContexts() ([]string, error) { return []string{"test"}, nil }
|
|
func (f *fakeDiscovery) GetCurrentContext() (string, error) { return "test", nil }
|
|
func (f *fakeDiscovery) ListNamespaces(_ context.Context, _ string) ([]string, error) {
|
|
return f.namespaces, f.listNamespacesEr
|
|
}
|
|
func (f *fakeDiscovery) ListPods(_ context.Context, _, _ string) ([]k8s.PodInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeDiscovery) ListPodsWithSelector(_ context.Context, _, _, _ string) ([]k8s.PodInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeDiscovery) ListServices(_ context.Context, _, ns string) ([]k8s.ServiceInfo, error) {
|
|
if f.listServicesEr != nil {
|
|
return nil, f.listServicesEr
|
|
}
|
|
return f.servicesByNS[ns], nil
|
|
}
|
|
|
|
// keyOf builds a tea.KeyMsg the same way bubbletea does for typed runes.
|
|
func keyOf(s string) tea.KeyMsg {
|
|
switch s {
|
|
case "enter":
|
|
return tea.KeyMsg{Type: tea.KeyEnter}
|
|
case "esc":
|
|
return tea.KeyMsg{Type: tea.KeyEsc}
|
|
case "space":
|
|
return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}
|
|
case "backspace":
|
|
return tea.KeyMsg{Type: tea.KeyBackspace}
|
|
}
|
|
if len(s) == 1 {
|
|
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
|
|
}
|
|
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
|
|
}
|
|
|
|
// drainModel applies a sequence of messages and returns the final model.
|
|
func drainModel(t *testing.T, m tea.Model, msgs ...tea.Msg) tea.Model {
|
|
t.Helper()
|
|
cur := m
|
|
for _, msg := range msgs {
|
|
next, _ := cur.Update(msg)
|
|
cur = next
|
|
}
|
|
return cur
|
|
}
|
|
|
|
func TestGenerateModel_NamespaceMultiSelect(t *testing.T) {
|
|
disc := &fakeDiscovery{
|
|
namespaces: []string{"alpha", "beta", "gamma"},
|
|
servicesByNS: map[string][]k8s.ServiceInfo{
|
|
"alpha": {{Name: "svc-a", Namespace: "alpha", Ports: []k8s.PortInfo{{Port: 80, Protocol: "TCP"}}}},
|
|
},
|
|
}
|
|
mut := &fakeMutator{}
|
|
m := NewGenerateModel(disc, mut, "ctx", "/tmp/x.yaml", true, nil)
|
|
|
|
// Init (load namespaces)
|
|
cmd := m.Init()
|
|
if cmd == nil {
|
|
t.Fatal("expected Init to return command")
|
|
}
|
|
// Simulate the namespaces-loaded message.
|
|
model, _ := m.Update(generateNamespacesLoadedMsg{namespaces: disc.namespaces})
|
|
gm := model.(*GenerateModel)
|
|
|
|
if gm.loading {
|
|
t.Fatal("expected loading=false after namespaces loaded")
|
|
}
|
|
if len(gm.nsFilteredView) != 3 {
|
|
t.Fatalf("want 3 namespaces, got %d", len(gm.nsFilteredView))
|
|
}
|
|
|
|
// Toggle first item with space — cursor starts at 0.
|
|
gm2 := drainModel(t, gm, keyOf("space")).(*GenerateModel)
|
|
if !gm2.nsSelected["alpha"] {
|
|
t.Fatal("expected alpha to be selected")
|
|
}
|
|
|
|
// 'a' toggles all. Because alpha is selected and the others are not,
|
|
// allSelected=false so the press selects everything visible.
|
|
gm3 := drainModel(t, gm2, keyOf("a")).(*GenerateModel)
|
|
for _, ns := range []string{"alpha", "beta", "gamma"} {
|
|
if !gm3.nsSelected[ns] {
|
|
t.Fatalf("expected %s to be selected after first toggle-all", ns)
|
|
}
|
|
}
|
|
// Press again — now all are selected, so it should deselect all.
|
|
gm4 := drainModel(t, gm3, keyOf("a")).(*GenerateModel)
|
|
for _, ns := range []string{"alpha", "beta", "gamma"} {
|
|
if gm4.nsSelected[ns] {
|
|
t.Fatalf("expected %s to be unselected after second toggle-all", ns)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateModel_NamespaceFilter(t *testing.T) {
|
|
disc := &fakeDiscovery{namespaces: []string{"alpha", "beta", "gamma"}}
|
|
m := NewGenerateModel(disc, &fakeMutator{}, "ctx", "/tmp/x.yaml", true, nil)
|
|
model, _ := m.Update(generateNamespacesLoadedMsg{namespaces: disc.namespaces})
|
|
gm := model.(*GenerateModel)
|
|
|
|
// Enter filter mode
|
|
gm = drainModel(t, gm, keyOf("/")).(*GenerateModel)
|
|
if !gm.nsFiltering {
|
|
t.Fatal("expected to enter filter mode")
|
|
}
|
|
gm = drainModel(t, gm, keyOf("b")).(*GenerateModel)
|
|
if gm.nsFilter != "b" {
|
|
t.Fatalf("expected filter=b, got %q", gm.nsFilter)
|
|
}
|
|
if len(gm.nsFilteredView) != 1 || gm.nsFilteredView[0] != "beta" {
|
|
t.Fatalf("expected [beta], got %v", gm.nsFilteredView)
|
|
}
|
|
// Exit filter
|
|
gm = drainModel(t, gm, tea.KeyMsg{Type: tea.KeyEnter}).(*GenerateModel)
|
|
if gm.nsFiltering {
|
|
t.Fatal("expected filtering to be off after enter")
|
|
}
|
|
}
|
|
|
|
func TestGenerateModel_ServiceMultiSelectAndLock(t *testing.T) {
|
|
disc := &fakeDiscovery{
|
|
namespaces: []string{"ns1"},
|
|
servicesByNS: map[string][]k8s.ServiceInfo{
|
|
"ns1": {
|
|
{Name: "svc-a", Namespace: "ns1", Ports: []k8s.PortInfo{{Port: 80, Protocol: "TCP"}, {Port: 443, Protocol: "TCP"}}},
|
|
{Name: "svc-udp", Namespace: "ns1", Ports: []k8s.PortInfo{{Port: 53, Protocol: "UDP"}}},
|
|
},
|
|
},
|
|
}
|
|
// One forward already configured: svc-a:80 in ns1
|
|
existing := []config.Forward{makeFwd("ctx", "ns1", "service/svc-a", 80, 9000, "tcp")}
|
|
|
|
m := NewGenerateModel(disc, &fakeMutator{}, "ctx", "/tmp/x.yaml", true, existing)
|
|
// Drive past the namespace step.
|
|
model, _ := m.Update(generateNamespacesLoadedMsg{namespaces: disc.namespaces})
|
|
gm := model.(*GenerateModel)
|
|
gm.nsSelected["ns1"] = true
|
|
// Press enter to advance to services step.
|
|
model2, _ := gm.Update(keyOf("enter"))
|
|
gm2 := model2.(*GenerateModel)
|
|
// Provide the loaded services.
|
|
model3, _ := gm2.Update(generateServicesLoadedMsg{servicesByNS: map[string][]ServiceCandidate{
|
|
"ns1": {
|
|
{Namespace: "ns1", Service: "svc-a", Port: 80, Protocol: "TCP"},
|
|
{Namespace: "ns1", Service: "svc-a", Port: 443, Protocol: "TCP"},
|
|
{Namespace: "ns1", Service: "svc-udp", Port: 53, Protocol: "UDP"},
|
|
},
|
|
}})
|
|
gm3 := model3.(*GenerateModel)
|
|
|
|
if len(gm3.svcOrder) != 3 {
|
|
t.Fatalf("want 3 candidates, got %d", len(gm3.svcOrder))
|
|
}
|
|
if !gm3.svcLocked[(ServiceCandidate{Namespace: "ns1", Service: "svc-a", Port: 80, Protocol: "TCP"}).Key()] {
|
|
t.Fatal("svc-a:80 should be locked (already in config)")
|
|
}
|
|
|
|
// Move to svc-a:443 (cursor index 1) and toggle.
|
|
gm4 := drainModel(t, gm3, keyOf("down"), keyOf("space")).(*GenerateModel)
|
|
sel := gm4.selectedCandidates()
|
|
if len(sel) != 1 || sel[0].Service != "svc-a" || sel[0].Port != 443 {
|
|
t.Fatalf("expected [svc-a:443], got %v", sel)
|
|
}
|
|
|
|
// Try to toggle the locked row (cursor 0) — should remain unselected.
|
|
gm5 := drainModel(t, gm4, keyOf("up"), keyOf("space")).(*GenerateModel)
|
|
for _, c := range gm5.selectedCandidates() {
|
|
if c.Port == 80 {
|
|
t.Fatal("locked row was selectable")
|
|
}
|
|
}
|
|
|
|
// Toggle-all should select all selectable (i.e., svc-a:443 only — the others are locked or non-TCP).
|
|
gm6 := drainModel(t, gm5, keyOf("a")).(*GenerateModel)
|
|
// First press: all eligible already selected (svc-a:443) → deselect.
|
|
if len(gm6.selectedCandidates()) != 0 {
|
|
t.Fatalf("expected toggle-all to deselect, got %d", len(gm6.selectedCandidates()))
|
|
}
|
|
gm7 := drainModel(t, gm6, keyOf("a")).(*GenerateModel)
|
|
if len(gm7.selectedCandidates()) != 1 {
|
|
t.Fatalf("expected 1 selected after second toggle-all, got %d", len(gm7.selectedCandidates()))
|
|
}
|
|
}
|
|
|
|
// readyModel returns a model with loading already cleared so step-level
|
|
// behaviour can be tested without injecting load messages first.
|
|
func readyModel(disc DiscoveryInterface, mut MutatorInterface, ctx, cfg string, dryRun bool, existing []config.Forward) *GenerateModel {
|
|
m := NewGenerateModel(disc, mut, ctx, cfg, dryRun, existing)
|
|
m.loading = false
|
|
return m
|
|
}
|
|
|
|
func TestGenerateModel_PortAssignmentWithCollisions(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, ".kportal.yaml")
|
|
// Seed an existing config that has localPort 10000 and 10002 already used.
|
|
seed := []byte(`contexts:
|
|
- name: ctx
|
|
namespaces:
|
|
- name: existing
|
|
forwards:
|
|
- resource: service/legacy
|
|
port: 8080
|
|
localPort: 10000
|
|
protocol: tcp
|
|
- resource: service/legacy2
|
|
port: 8080
|
|
localPort: 10002
|
|
protocol: tcp
|
|
`)
|
|
if err := os.WriteFile(configPath, seed, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Re-load to grab the existing forwards.
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
mut := config.NewMutator(configPath)
|
|
|
|
disc := &fakeDiscovery{}
|
|
m := readyModel(disc, mut, "ctx", configPath, false, cfg.GetAllForwards())
|
|
// Pre-populate svcOrder with three candidates that need ports.
|
|
m.svcOrder = []ServiceCandidate{
|
|
{Namespace: "ns1", Service: "alpha", Port: 80, Protocol: "TCP"},
|
|
{Namespace: "ns1", Service: "beta", Port: 80, Protocol: "TCP"},
|
|
{Namespace: "ns1", Service: "gamma", Port: 80, Protocol: "TCP"},
|
|
}
|
|
for _, c := range m.svcOrder {
|
|
m.svcSelected[c.Key()] = true
|
|
}
|
|
|
|
planned := m.assignPorts(10000)
|
|
if len(planned) != 3 {
|
|
t.Fatalf("expected 3 planned forwards, got %d", len(planned))
|
|
}
|
|
got := []int{planned[0].LocalPort, planned[1].LocalPort, planned[2].LocalPort}
|
|
want := []int{10001, 10003, 10004} // 10000 and 10002 taken
|
|
for i, p := range want {
|
|
if got[i] != p {
|
|
t.Fatalf("planned[%d] localPort: want %d, got %d (full=%v)", i, p, got[i], got)
|
|
}
|
|
}
|
|
|
|
// Now invoke saveCmd through the model and verify mutator side-effects.
|
|
m.startingPortStr = "10000"
|
|
for _, c := range m.svcOrder {
|
|
m.svcSelected[c.Key()] = true
|
|
}
|
|
m.step = GenerateStepPortAssign
|
|
model2, cmd := m.Update(keyOf("enter"))
|
|
if cmd == nil {
|
|
t.Fatal("expected save command")
|
|
}
|
|
msg := cmd()
|
|
saved, ok := msg.(generateSavedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected generateSavedMsg, got %T", msg)
|
|
}
|
|
if saved.added != 3 {
|
|
t.Fatalf("expected 3 added, got %d (errors=%v)", saved.added, saved.errors)
|
|
}
|
|
|
|
// Verify config file now has 5 forwards total.
|
|
cfg2, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("reload config: %v", err)
|
|
}
|
|
if len(cfg2.GetAllForwards()) != 5 {
|
|
t.Fatalf("expected 5 forwards after save, got %d", len(cfg2.GetAllForwards()))
|
|
}
|
|
_ = model2
|
|
}
|
|
|
|
func TestGenerateModel_PortBelow1024Rejected(t *testing.T) {
|
|
m := readyModel(&fakeDiscovery{}, &fakeMutator{}, "ctx", "/tmp/x.yaml", true, nil)
|
|
m.step = GenerateStepPortAssign
|
|
m.svcOrder = []ServiceCandidate{{Namespace: "ns1", Service: "x", Port: 80, Protocol: "TCP"}}
|
|
m.svcSelected[m.svcOrder[0].Key()] = true
|
|
m.startingPortStr = "80"
|
|
|
|
model, cmd := m.Update(keyOf("enter"))
|
|
gm := model.(*GenerateModel)
|
|
if cmd != nil {
|
|
t.Fatal("expected no command (rejected)")
|
|
}
|
|
if gm.portError == "" {
|
|
t.Fatal("expected port error to be set")
|
|
}
|
|
if gm.step != GenerateStepPortAssign {
|
|
t.Fatal("expected to remain on port step after invalid input")
|
|
}
|
|
|
|
// Backspace + retype a valid value should clear the error and allow continuing.
|
|
gm.startingPortStr = "1024"
|
|
model2, cmd2 := gm.Update(keyOf("enter"))
|
|
gm2 := model2.(*GenerateModel)
|
|
if cmd2 == nil {
|
|
t.Fatal("expected save command after valid port")
|
|
}
|
|
if gm2.portError != "" {
|
|
t.Fatalf("expected port error cleared, got %q", gm2.portError)
|
|
}
|
|
}
|
|
|
|
func TestGenerateModel_DryRunDoesNotInvokeMutator(t *testing.T) {
|
|
mut := &fakeMutator{}
|
|
m := readyModel(&fakeDiscovery{}, mut, "ctx", "/tmp/x.yaml", true, nil)
|
|
m.step = GenerateStepPortAssign
|
|
m.svcOrder = []ServiceCandidate{{Namespace: "ns1", Service: "x", Port: 80, Protocol: "TCP"}}
|
|
m.svcSelected[m.svcOrder[0].Key()] = true
|
|
m.startingPortStr = "10000"
|
|
|
|
model, cmd := m.Update(keyOf("enter"))
|
|
gm := model.(*GenerateModel)
|
|
if !gm.result.UsedDryRun {
|
|
t.Fatal("expected dry-run flag set in result")
|
|
}
|
|
if len(mut.added) != 0 {
|
|
t.Fatalf("expected mutator untouched in dry-run, got %d adds", len(mut.added))
|
|
}
|
|
if cmd == nil {
|
|
t.Fatal("expected quit command from dry-run path")
|
|
}
|
|
if msg := cmd(); msg == nil {
|
|
// Quit returns a tea.QuitMsg — just ensure it's non-nil.
|
|
t.Fatal("expected non-nil quit message")
|
|
}
|
|
}
|
|
|
|
func TestGenerateModel_EndToEnd(t *testing.T) {
|
|
disc := &fakeDiscovery{
|
|
namespaces: []string{"ns1"},
|
|
}
|
|
mut := &fakeMutator{}
|
|
m := NewGenerateModel(disc, mut, "ctx", "/tmp/x.yaml", false, nil)
|
|
|
|
// Init returns a Cmd; we don't run it directly. Instead we manually
|
|
// inject the messages it would produce.
|
|
_ = m.Init()
|
|
|
|
// 1. Namespaces load.
|
|
model, _ := m.Update(generateNamespacesLoadedMsg{namespaces: disc.namespaces})
|
|
gm := model.(*GenerateModel)
|
|
|
|
// 2. Toggle ns1 + enter.
|
|
gm = drainModel(t, gm, keyOf("space"), keyOf("enter")).(*GenerateModel)
|
|
if gm.step != GenerateStepServices {
|
|
t.Fatalf("expected services step, got %v", gm.step)
|
|
}
|
|
|
|
// 3. Provide loaded services.
|
|
model2, _ := gm.Update(generateServicesLoadedMsg{servicesByNS: map[string][]ServiceCandidate{
|
|
"ns1": {{Namespace: "ns1", Service: "svc", Port: 8080, Protocol: "TCP"}},
|
|
}})
|
|
gm = model2.(*GenerateModel)
|
|
|
|
// 4. Toggle the (only) service + enter.
|
|
gm = drainModel(t, gm, keyOf("space"), keyOf("enter")).(*GenerateModel)
|
|
if gm.step != GenerateStepPortAssign {
|
|
t.Fatalf("expected port-assign step, got %v", gm.step)
|
|
}
|
|
|
|
// 5. Press enter on the default port (10000).
|
|
model3, cmd := gm.Update(keyOf("enter"))
|
|
gm = model3.(*GenerateModel)
|
|
if cmd == nil {
|
|
t.Fatal("expected save command")
|
|
}
|
|
msg := cmd()
|
|
saved := msg.(generateSavedMsg)
|
|
if saved.added != 1 {
|
|
t.Fatalf("expected 1 added, got %d (errs=%v)", saved.added, saved.errors)
|
|
}
|
|
|
|
// 6. Process the saved message → step should be Done.
|
|
model4, _ := gm.Update(saved)
|
|
final := model4.(*GenerateModel)
|
|
if final.step != GenerateStepDone {
|
|
t.Fatalf("expected Done step, got %v", final.step)
|
|
}
|
|
if final.result.Added != 1 {
|
|
t.Fatalf("expected result.Added=1, got %d", final.result.Added)
|
|
}
|
|
if len(mut.added) != 1 {
|
|
t.Fatalf("expected mutator to record 1 forward, got %d", len(mut.added))
|
|
}
|
|
if mut.added[0].Resource != "service/svc" || mut.added[0].LocalPort != 10000 {
|
|
t.Fatalf("unexpected forward recorded: %+v", mut.added[0])
|
|
}
|
|
}
|
|
|
|
// makeFwd is a small helper to build a Forward with context/namespace pre-set.
|
|
func makeFwd(ctxName, ns, resource string, port, localPort int, proto string) config.Forward {
|
|
f := config.Forward{
|
|
Resource: resource,
|
|
Port: port,
|
|
LocalPort: localPort,
|
|
Protocol: proto,
|
|
}
|
|
f.SetContext(ctxName, ns)
|
|
return f
|
|
}
|
|
|
|
func TestGenerateModel_ParseStartingPortBoundary(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
wantOK bool
|
|
wantVal int
|
|
}{
|
|
{"empty", "", false, 0},
|
|
{"non-numeric", "abc", false, 0},
|
|
{"below min", "1023", false, 0},
|
|
{"at min", "1024", true, 1024},
|
|
{"above max", "70000", false, 0},
|
|
{"valid", "10000", true, 10000},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m := NewGenerateModel(&fakeDiscovery{}, &fakeMutator{}, "c", "/tmp/x.yaml", true, nil)
|
|
m.startingPortStr = tc.input
|
|
got, ok := m.parseStartingPort()
|
|
if ok != tc.wantOK {
|
|
t.Fatalf("ok mismatch: want %v, got %v (err=%q)", tc.wantOK, ok, m.portError)
|
|
}
|
|
if ok && got != tc.wantVal {
|
|
t.Fatalf("val mismatch: want %d, got %d", tc.wantVal, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGenerateModel_PortStepView ensures the port-step view renders without panic.
|
|
func TestGenerateModel_PortStepView(t *testing.T) {
|
|
m := readyModel(&fakeDiscovery{}, &fakeMutator{}, "ctx", "/tmp/x.yaml", true, nil)
|
|
m.step = GenerateStepPortAssign
|
|
m.svcOrder = []ServiceCandidate{{Namespace: "ns", Service: "svc", Port: 80, Protocol: "TCP"}}
|
|
m.svcSelected[m.svcOrder[0].Key()] = true
|
|
view := m.View()
|
|
if !contains(view, "Step 3 / 3") {
|
|
t.Fatalf("expected step header in view, got: %s", view)
|
|
}
|
|
if !contains(view, "10000") {
|
|
t.Fatalf("expected default port in view, got: %s", view)
|
|
}
|
|
}
|
|
|
|
// contains is a tiny strings.Contains wrapper that also gives a clearer test failure message.
|
|
func contains(s, sub string) bool {
|
|
return len(s) >= len(sub) && (sub == "" || stringIndex(s, sub) >= 0)
|
|
}
|
|
|
|
func stringIndex(s, sub string) int {
|
|
if sub == "" {
|
|
return 0
|
|
}
|
|
for i := 0; i+len(sub) <= len(s); i++ {
|
|
if s[i:i+len(sub)] == sub {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// Sanity: ensure the model satisfies tea.Model interface — compile-time check.
|
|
var _ tea.Model = (*GenerateModel)(nil)
|
|
|
|
// Sanity: ensure existing key generation matches a manually-built one.
|
|
func TestServiceCandidate_KeyDeterministic(t *testing.T) {
|
|
c := ServiceCandidate{Namespace: "ns1", Service: "svc", Port: 80, Protocol: "TCP"}
|
|
want := fmt.Sprintf("%s|%s|%s|%d", "ns1", "service/svc", "tcp", 80)
|
|
if c.Key() != want {
|
|
t.Fatalf("Key() mismatch: want %q, got %q", want, c.Key())
|
|
}
|
|
}
|