// SPDX-License-Identifier: MIT // Copyright (c) 2026 Paul Buetow package integrationtests import ( "os" "os/exec" "path/filepath" "strings" "testing" ) // buildBinary builds the gt binary to a temporary location unique to each test. func buildBinary(t *testing.T) string { t.Helper() // Get the project root directory projectRoot := os.Getenv("GITHUB_WORKSPACE") if projectRoot == "" { projectRoot = "/home/paul/git/gt" } // Use t.TempDir() so each test gets its own binary path; Go cleans up automatically. tmpDir := t.TempDir() binaryPath := filepath.Join(tmpDir, "gt-test") buildCmd := exec.Command("go", "build", "-o", binaryPath, "./cmd/gt") buildCmd.Dir = projectRoot if err := buildCmd.Run(); err != nil { t.Fatalf("build failed: %v", err) } return binaryPath } // TestCLIVersion tests that the version command works correctly. func TestCLIVersion(t *testing.T) { binaryPath := buildBinary(t) cmd := exec.Command(binaryPath, "version") output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("version command failed: %v\nOutput: %s", err, string(output)) } versionOutput := strings.TrimSpace(string(output)) if !strings.HasPrefix(versionOutput, "v") { t.Errorf("version output should start with 'v', got: %s", versionOutput) } } // TestCLIVersionOnly tests that the version command works correctly. func TestCLIVersionOnly(t *testing.T) { binaryPath := buildBinary(t) cmd := exec.Command(binaryPath, "version") output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("version command failed: %v\nOutput: %s", err, string(output)) } versionOutput := strings.TrimSpace(string(output)) if !strings.HasPrefix(versionOutput, "v") { t.Errorf("version output should start with 'v', got: %s", versionOutput) } } // TestCLIPercentageCalculation tests percentage calculation commands. // TestCLIPercentageCalculation tests percentage calculation commands. func TestCLIPercentageCalculation(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string args []string expected string }{ { name: "20% of 150", args: []string{"20% of 150"}, expected: "30", }, { name: "what is 20% of 150", args: []string{"what is 20% of 150"}, expected: "30", }, { name: "30 is what % of 150", args: []string{"30 is what % of 150"}, expected: "20", }, { name: "30 is 20% of what", args: []string{"30 is 20% of what"}, expected: "150", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := exec.Command(binaryPath, tt.args...) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) } outputStr := strings.TrimSpace(string(output)) if !strings.Contains(outputStr, tt.expected) { t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) } }) } } // TestCLIRPNCalculation tests RPN calculation commands. func TestCLIRPNCalculation(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string args []string expected string }{ { name: "3 4 +", args: []string{"3 4 +"}, expected: "7", }, { name: "5 6 *", args: []string{"5 6 *"}, expected: "30", }, { name: "10 2 /", args: []string{"10 2 /"}, expected: "5", }, { name: "2 3 ^", args: []string{"2 3 ^"}, expected: "8", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := exec.Command(binaryPath, tt.args...) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) } outputStr := strings.TrimSpace(string(output)) if !strings.Contains(outputStr, tt.expected) { t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) } }) } } // TestCLIExitCode tests that the CLI returns proper exit codes. func TestCLIExitCode(t *testing.T) { binaryPath := buildBinary(t) // Test successful command returns 0 cmd := exec.Command(binaryPath, "version") if err := cmd.Run(); err != nil { t.Errorf("version command should succeed, got error: %v", err) } // Test invalid command returns non-zero cmd = exec.Command(binaryPath, "invalidcommand") output, err := cmd.CombinedOutput() if err == nil { t.Errorf("invalid command should fail, but succeeded") } if len(output) > 0 && !strings.Contains(string(output), "Error:") { t.Errorf("error output should contain 'Error:', got: %s", string(output)) } } // TestCLIInvalidPercentage tests invalid percentage calculation. func TestCLIInvalidPercentage(t *testing.T) { binaryPath := buildBinary(t) cmd := exec.Command(binaryPath, "invalid percentage") output, err := cmd.CombinedOutput() if err == nil { t.Errorf("invalid percentage should fail, but succeeded") } if len(output) > 0 && !strings.Contains(string(output), "Error:") { t.Errorf("error output should contain 'Error:', got: %s", string(output)) } } // TestCLIInvalidRPN tests invalid RPN expression. func TestCLIInvalidRPN(t *testing.T) { binaryPath := buildBinary(t) cmd := exec.Command(binaryPath, "3 +") output, err := cmd.CombinedOutput() if err == nil { t.Errorf("invalid RPN should fail, but succeeded") } if len(output) > 0 && !strings.Contains(string(output), "Error:") { t.Errorf("error output should contain 'Error:', got: %s", string(output)) } } // TestCLIStdin tests that stdin input works correctly. func TestCLIStdin(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string stdin string expected string }{ { name: "RPN expression via stdin", stdin: "3 4 +", expected: "7", }, { name: "Percentage expression via stdin", stdin: "20% of 150", expected: "30", }, { name: "Variable assignment via stdin", stdin: "x 5 = x x +", expected: "10", }, { name: "Boolean comparison via stdin", stdin: "5 3 ==", expected: "false", }, { name: "Boolean to number coercion via stdin", stdin: "true 2 *", expected: "2", }, { name: "Complex expression via stdin", stdin: "2 3 + 4 *", expected: "20", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := exec.Command(binaryPath) cmd.Stdin = strings.NewReader(tt.stdin) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) } outputStr := strings.TrimSpace(string(output)) if !strings.Contains(outputStr, tt.expected) { t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) } }) } } // TestCLIStdinWithPiping tests stdin via pipe (simulating `echo EXP | gt`). func TestCLIStdinWithPiping(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string input string expected string }{ { name: "echo 3 4 +", input: "3 4 +", expected: "7", }, { name: "echo 20%% of 150", input: "20% of 150", expected: "30", }, { name: "echo x 5 = x x +", input: "x 5 = x x +", expected: "10", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Simulate piping: echo INPUT | gt cmd := exec.Command(binaryPath) cmd.Stdin = strings.NewReader(tt.input) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) } outputStr := strings.TrimSpace(string(output)) if !strings.Contains(outputStr, tt.expected) { t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) } }) } } // TestCLIStdinWithMultipleLines tests stdin with multiple lines (should use first line). func TestCLIStdinWithMultipleLines(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string stdin string expected string }{ { name: "Multiple lines - first line used", stdin: "3 4 +\n5 6 +", expected: "7", }, { name: "Empty line then expression", stdin: "\n3 4 +", expected: "7", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := exec.Command(binaryPath) cmd.Stdin = strings.NewReader(tt.stdin) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) } outputStr := strings.TrimSpace(string(output)) if !strings.Contains(outputStr, tt.expected) { t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) } }) } } // TestCLIVariableAssignment tests all variable assignment syntaxes. func TestCLIVariableAssignment(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string args []string expected string }{ { name: "Standard assignment x = 2", args: []string{"x", "2", "=", "x", "2", "+"}, expected: "4", }, { name: "Right assignment x 2 := (value on stack, right)", args: []string{"x", "2", ":=", "x", "2", "+"}, expected: "4", }, { name: "Left assignment 2 x =: (value on stack, left)", args: []string{"2", "x", "=: ", "x", "2", "+"}, expected: "4", }, { name: "Stack variant with =: (value on stack, left)", args: []string{"2", "x", "=: ", "x", "2", "+"}, expected: "4", }, { name: "Assignment with existing variable", args: []string{"x", "5", "=", "x", "3", "+"}, expected: "8", }, { name: "Assignment with complex expression", args: []string{"x", "2", "=", "x", "x", "*", "x", "+"}, expected: "6", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := exec.Command(binaryPath, tt.args...) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) } outputStr := strings.TrimSpace(string(output)) if !strings.Contains(outputStr, tt.expected) { t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) } }) } } // TestCLIVariableAssignmentSyntaxes tests each assignment syntax individually // and verifies the variable can be used in subsequent expressions. func TestCLIVariableAssignmentSyntaxes(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string args []string expected string }{ { name: "Assignment: x = 2, then x 2 +", args: []string{"x", "2", "=", "x", "2", "+"}, expected: "4", }, { name: "Right assignment: x 2 :=, then x 2 +", args: []string{"x", "2", ":=", "x", "2", "+"}, expected: "4", }, { name: "Left assignment: 2 x =:, then x 2 +", args: []string{"2", "x", "=: ", "x", "2", "+"}, expected: "4", }, { name: "Assignment: y = 10, then y 5 *", args: []string{"y", "10", "=", "y", "5", "*"}, expected: "50", }, { name: "Assignment: pi = 3.14159, then pi 2 *", args: []string{"pi", "3.14159", "=", "pi", "2", "*"}, expected: "6.28318", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := exec.Command(binaryPath, tt.args...) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) } outputStr := strings.TrimSpace(string(output)) if !strings.Contains(outputStr, tt.expected) { t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) } }) } } // TestCLIVariableAssignmentPreservation tests that variables persist within a single expression. func TestCLIVariableAssignmentPreservation(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string args []string expected string }{ { name: "Single assignment, multiple uses", args: []string{"x", "5", "=", "x", "x", "+"}, expected: "10", // x=5, then x+x=10 }, { name: "Assignment with calculation as value (stack-variant)", args: []string{"2", "3", "+", "x", "=: ", "x", "4", "*"}, expected: "20", // 2+3=5, x=: assigns 5 to x, x*4=20 }, { name: "Stack-variant with := (value on stack)", args: []string{"x", "2", "3", "+", ":=", "x", "1", "+"}, expected: "6", // x=2+3=5, x+1=6 }, { name: "Stack-variant with =: (value first)", args: []string{"2", "3", "+", "x", "=: ", "x", "1", "+"}, expected: "6", // 2+3=5, x=: assigns 5 to x, x+1=6 }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := exec.Command(binaryPath, tt.args...) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) } outputStr := strings.TrimSpace(string(output)) if !strings.Contains(outputStr, tt.expected) { t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) } }) } } // TestCLIVariableAssignmentRepetition tests that repeated assignment works correctly. func TestCLIVariableAssignmentRepetition(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string args []string expected string }{ { name: "Reassign same variable", args: []string{"x", "5", ":=", "x", "10", ":=", "x", "2", "+"}, expected: "12", // x=5, x=10, x+2=12 }, { name: "Stack-variant reassignment", args: []string{"x", "1", ":=", "x", "2", ":=", "x", "3", "+"}, expected: "5", // x=1 then x=2, x+3=5 }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := exec.Command(binaryPath, tt.args...) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) } outputStr := strings.TrimSpace(string(output)) if !strings.Contains(outputStr, tt.expected) { t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) } }) } } // TestCLIBinaryExponentiation tests the ** operator with binary exponentiation. func TestCLIBinaryExponentiation(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string args []string expected string }{ { name: "2 ** 10", args: []string{"2", "10", "**"}, expected: "1024", }, { name: "3 ** 4", args: []string{"3", "4", "**"}, expected: "81", }, { name: "2 ** -3", args: []string{"2", "-3", "**"}, expected: "0.125", }, { name: "10 ** -2", args: []string{"10", "-2", "**"}, expected: "0.01", }, { name: "5 ** 0", args: []string{"5", "0", "**"}, expected: "1", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := exec.Command(binaryPath, tt.args...) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) } outputStr := strings.TrimSpace(string(output)) if !strings.Contains(outputStr, tt.expected) { t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) } }) } } // TestCLIBinaryExponentiationStdin tests the ** operator via stdin. func TestCLIBinaryExponentiationStdin(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string stdin string expected string }{ { name: "2 ** 10 via stdin", stdin: "2 10 **", expected: "1024", }, { name: "3 ** 4 via stdin", stdin: "3 4 **", expected: "81", }, { name: "2 ** -3 via stdin", stdin: "2 -3 **", expected: "0.125", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := exec.Command(binaryPath) cmd.Stdin = strings.NewReader(tt.stdin) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) } outputStr := strings.TrimSpace(string(output)) if !strings.Contains(outputStr, tt.expected) { t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) } }) } } // TestCLIMetricConversion tests metric unit conversion end-to-end. func TestCLIMetricConversion(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string args []string expected string }{ { name: "1000Mbps to Gbps", args: []string{"1000Mbps", "@Gbps", "convert"}, expected: "1", }, { name: "1hr to min", args: []string{"1hr", "@min", "convert"}, expected: "60", }, { name: "1km to mi", args: []string{"1km", "@mi", "convert"}, expected: "0.621", }, { name: "Cool to GB", args: []string{"100", "@GB", "convert"}, expected: "100", }, { name: "metric show", args: []string{"100Mbps", "metric", "show"}, expected: "Mbps", }, { name: "metric list", args: []string{"metric", "list"}, expected: "DataRate", }, { name: "metric DataRate", args: []string{"metric", "DataRate"}, expected: "bps", }, { name: "metric compatible same category", args: []string{"100Mbps", "1Gbps", "metric", "compatible"}, expected: "true", }, { name: "custom define and list", args: []string{"custom", "define", "myunit", "42", "Custom"}, expected: "defined", }, { name: "metric binary set", args: []string{"metric", "binary", "set"}, expected: "IEC", }, { name: "metric decimal set", args: []string{"metric", "decimal", "set"}, expected: "SI", }, { name: "cross-category multiply Mbps*hr=bits", args: []string{"100Mbps", "1hr", "*"}, expected: "3.6e+11", }, { name: "metric-aware addition same category", args: []string{"100Mbps", "50Mbps", "+"}, expected: "150", }, { name: "hyper add metric-aware", args: []string{"100Mbps", "50Mbps", "25Mbps", "[+]"}, expected: "175", }, { name: "hyper multiply returns Cool", args: []string{"3", "4", "5", "[*]"}, expected: "60", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := exec.Command(binaryPath, tt.args...) output, err := cmd.CombinedOutput() if err != nil { t.Fatalf("command failed: %v\nOutput: %s", err, string(output)) } outputStr := strings.TrimSpace(string(output)) if !strings.Contains(outputStr, tt.expected) { t.Errorf("output should contain '%s', got: %s", tt.expected, outputStr) } }) } } // TestCLIMetricErrors tests that metric operations produce proper errors. func TestCLIMetricErrors(t *testing.T) { binaryPath := buildBinary(t) tests := []struct { name string args []string }{ { name: "incompatible categories add", args: []string{"100Mbps", "2hr", "+"}, }, { name: "incompatible convert", args: []string{"100Mbps", "@hr", "convert"}, }, { name: "unknown metric", args: []string{"@nope", "convert"}, }, { name: "hyper add incompatible", args: []string{"100Mbps", "2hr", "[+]"}, }, { name: "custom undefine built-in", args: []string{"custom", "undefine", "Cool"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := exec.Command(binaryPath, tt.args...) output, err := cmd.CombinedOutput() if err == nil { t.Errorf("command should fail, but succeeded. Output: %s", string(output)) } }) } }