diff options
Diffstat (limited to 'internal/perc')
| -rw-r--r-- | internal/perc/perc.go | 233 | ||||
| -rw-r--r-- | internal/perc/perc_test.go | 282 |
2 files changed, 515 insertions, 0 deletions
diff --git a/internal/perc/perc.go b/internal/perc/perc.go new file mode 100644 index 0000000..9921ce4 --- /dev/null +++ b/internal/perc/perc.go @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Paul Buetow + +package perc + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// CalculationType represents the type of calculation performed. +type CalculationType int + +const ( + // PercentOfY: "X% of Y" → "X.00% of Y.00 = Z.00" + PercentOfY CalculationType = iota + // IsWhatPercentOfY: "X is what % of Y" → "X.00 is P.00% of Y.00" + IsWhatPercentOfY + // IsYPercentOfWhat: "X is Y% of what" → "X.00 is Y.00% of W.00" + IsYPercentOfWhat +) + +// Calculation represents the result of a percentage calculation. +type Calculation struct { + Type CalculationType + Percent float64 + Base float64 + Result float64 + Steps string +} + +// Format returns the formatted calculation result. +func (c *Calculation) Format() string { + var baseStr string + switch c.Type { + case PercentOfY: + baseStr = fmt.Sprintf("%.2f%% of %.2f = %.2f", c.Percent, c.Base, c.Result) + case IsWhatPercentOfY: + // percent is the result, base is the "whole" + baseStr = fmt.Sprintf("%.2f is %.2f%% of %.2f", c.Result, c.Percent, c.Base) + case IsYPercentOfWhat: + // percent is the known value, base is the "what" + baseStr = fmt.Sprintf("%.2f is %.2f%% of %.2f", c.Result, c.Percent, c.Base) + } + if c.Steps != "" { + return baseStr + "\n Steps: " + c.Steps + } + return baseStr +} + +// ParsingStrategy represents a parsing function that attempts to parse input. +// Returns a Calculation if handled, or error if not. +type ParsingStrategy func(input string) (*Calculation, bool, error) + +// strategyRegistry maintains a registry of parsing strategies. +type strategyRegistry struct { + strategies []ParsingStrategy +} + +// newStrategyRegistry creates a new strategy registry. +func newStrategyRegistry() *strategyRegistry { + return &strategyRegistry{ + strategies: make([]ParsingStrategy, 0), + } +} + +// register adds a parsing strategy to the registry. +func (r *strategyRegistry) register(strategy ParsingStrategy) { + r.strategies = append(r.strategies, strategy) +} + +// parse attempts to parse input using registered strategies in order. +func (r *strategyRegistry) parse(input string) (*Calculation, bool, error) { + for _, strategy := range r.strategies { + if result, handled, err := strategy(input); handled { + return result, true, err + } + } + return nil, false, nil +} + +// Parse parses a percentage calculation input string and returns the result as a formatted string. +// It handles formats like "20% of 150", "30 is what % of 150", and "30 is 20% of what". +// Note: This function only handles percentage calculations, not RPN expressions. +func Parse(input string) (string, error) { + input = strings.ToLower(strings.TrimSpace(input)) + input = strings.ReplaceAll(input, "what is ", "") + input = strings.TrimSpace(input) + + // Create registry and register percentage parsing strategies + registry := newStrategyRegistry() + registry.register(parseXPercentOfY) + registry.register(parseXIsWhatPercentOfY) + registry.register(parseXIsYPercentOfWhat) + + calc, ok, err := registry.parse(input) + if ok { + return calc.Format(), nil + } + if err != nil { + return "", fmt.Errorf("perc: unable to parse input %q: %w", input, err) + } + + return "", fmt.Errorf("perc: unable to parse input %q: unknown error", input) +} + +// ParseCalculation parses a percentage calculation input string and returns the Calculation object. +// It handles formats like "20% of 150", "30 is what % of 150", and "30 is 20% of what". +// This provides callers with more flexibility to access raw values and formatting options. +func ParseCalculation(input string) (*Calculation, error) { + input = strings.ToLower(strings.TrimSpace(input)) + input = strings.ReplaceAll(input, "what is ", "") + input = strings.TrimSpace(input) + + // Create registry and register percentage parsing strategies + registry := newStrategyRegistry() + registry.register(parseXPercentOfY) + registry.register(parseXIsWhatPercentOfY) + registry.register(parseXIsYPercentOfWhat) + + calc, ok, err := registry.parse(input) + if ok { + return calc, nil + } + if err != nil { + return nil, err + } + + return nil, fmt.Errorf("perc: unable to parse input %q. See usage for examples", input) +} + +// parseXPercentOfY calculates "X% of Y" and returns a Calculation. +func parseXPercentOfY(input string) (*Calculation, bool, error) { + re := regexp.MustCompile(`^(\d+(?:\.\d+)?)\s*%\s*(?:of\s+)?(\d+(?:\.\d+)?)$`) + matches := re.FindStringSubmatch(input) + + if matches == nil { + return nil, false, nil + } + + percent, err := strconv.ParseFloat(matches[1], 64) + if err != nil { + return nil, false, err + } + base, err := strconv.ParseFloat(matches[2], 64) + if err != nil { + return nil, false, err + } + + result := (percent / 100.0) * base + + calc := &Calculation{ + Type: PercentOfY, + Percent: percent, + Base: base, + Result: result, + Steps: fmt.Sprintf("(%.2f / 100) * %.2f = %.2f * %.2f = %.2f", percent, base, percent/100.0, base, result), + } + + return calc, true, nil +} + +// parseXIsWhatPercentOfY calculates "X is what % of Y" and returns a Calculation. +func parseXIsWhatPercentOfY(input string) (*Calculation, bool, error) { + re := regexp.MustCompile(`^(\d+(?:\.\d+)?)\s+is\s+what\s*%\s*(?:of\s+)?(\d+(?:\.\d+)?)$`) + matches := re.FindStringSubmatch(input) + + if matches == nil { + return nil, false, nil + } + + part, err := strconv.ParseFloat(matches[1], 64) + if err != nil { + return nil, false, err + } + whole, err := strconv.ParseFloat(matches[2], 64) + if err != nil { + return nil, false, err + } + + if whole == 0 { + return nil, false, fmt.Errorf("division by zero") + } + + percent := (part / whole) * 100.0 + + calc := &Calculation{ + Type: IsWhatPercentOfY, + Percent: percent, + Base: whole, + Result: part, + Steps: fmt.Sprintf("(%.2f / %.2f) * 100 = %.2f * 100 = %.2f%%", part, whole, part/whole, percent), + } + + return calc, true, nil +} + +// parseXIsYPercentOfWhat calculates "X is Y% of what" and returns a Calculation. +func parseXIsYPercentOfWhat(input string) (*Calculation, bool, error) { + re := regexp.MustCompile(`^(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)\s*%\s*(?:of\s+)?what$`) + matches := re.FindStringSubmatch(input) + + if matches == nil { + return nil, false, nil + } + + part, err := strconv.ParseFloat(matches[1], 64) + if err != nil { + return nil, false, err + } + percent, err := strconv.ParseFloat(matches[2], 64) + if err != nil { + return nil, false, err + } + + if percent == 0 { + return nil, false, fmt.Errorf("division by zero") + } + + whole := (part / percent) * 100.0 + + calc := &Calculation{ + Type: IsYPercentOfWhat, + Percent: percent, + Base: whole, + Result: part, + Steps: fmt.Sprintf("(%.2f / %.2f) * 100 = %.2f * 100 = %.2f", part, percent, part/percent, whole), + } + + return calc, true, nil +} diff --git a/internal/perc/perc_test.go b/internal/perc/perc_test.go new file mode 100644 index 0000000..d96fabb --- /dev/null +++ b/internal/perc/perc_test.go @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Paul Buetow + +package perc + +import ( + "strings" + "testing" +) + +// commonTestCases contains common test case patterns used across multiple tests +var commonTestCases = []struct { + name string + input string + expected string +}{ + { + name: "20% of 150", + input: "20% of 150", + expected: "20.00% of 150.00 = 30.00", + }, + { + name: "what is 20% of 150", + input: "what is 20% of 150", + expected: "20.00% of 150.00 = 30.00", + }, + { + name: "50% of 200", + input: "50% of 200", + expected: "50.00% of 200.00 = 100.00", + }, + { + name: "decimal percent", + input: "12.5% of 80", + expected: "12.50% of 80.00 = 10.00", + }, + { + name: "decimal base", + input: "20% of 75.5", + expected: "20.00% of 75.50 = 15.10", + }, + { + name: "without 'of'", + input: "25% 400", + expected: "25.00% of 400.00 = 100.00", + }, + { + name: "30 is what % of 150", + input: "30 is what % of 150", + expected: "30.00 is 20.00% of 150.00", + }, + { + name: "50 is what % of 200", + input: "50 is what % of 200", + expected: "50.00 is 25.00% of 200.00", + }, + { + name: "decimal values", + input: "12.5 is what % of 50", + expected: "12.50 is 25.00% of 50.00", + }, + { + name: "without spaces around %", + input: "75 is what% of 300", + expected: "75.00 is 25.00% of 300.00", + }, + { + name: "without 'of'", + input: "100 is what % 400", + expected: "100.00 is 25.00% of 400.00", + }, + { + name: "30 is 20% of what", + input: "30 is 20% of what", + expected: "30.00 is 20.00% of 150.00", + }, + { + name: "50 is 25% of what", + input: "50 is 25% of what", + expected: "50.00 is 25.00% of 200.00", + }, + { + name: "decimal values", + input: "15 is 30% of what", + expected: "15.00 is 30.00% of 50.00", + }, + { + name: "without spaces around %", + input: "75 is 25% of what", + expected: "75.00 is 25.00% of 300.00", + }, + { + name: "without 'of'", + input: "40 is 20% what", + expected: "40.00 is 20.00% of 200.00", + }, +} + +// runParseTest runs a parse test with common validation logic +func runParseTest(t *testing.T, tests []struct { + name string + input string + expected string +}) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Parse(tt.input) + if err != nil { + t.Fatalf("Parse(%q) returned error: %v", tt.input, err) + } + if !strings.HasPrefix(result, tt.expected) { + t.Errorf("Parse(%q) = %q, expected to start with %q", tt.input, result, tt.expected) + } + if !strings.Contains(result, "Steps:") { + t.Errorf("Parse(%q) = %q, expected to contain calculation steps", tt.input, result) + } + }) + } +} +func TestParseXPercentOfY(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + commonTestCases[0], // "20% of 150" + commonTestCases[1], // "what is 20% of 150" + commonTestCases[2], // "50% of 200" + commonTestCases[3], // "decimal percent" + commonTestCases[4], // "decimal base" + commonTestCases[5], // "without 'of'" + } + runParseTest(t, tests) +} + +func TestParseXIsWhatPercentOfY(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + commonTestCases[6], // "30 is what % of 150" + commonTestCases[7], // "50 is what % of 200" + commonTestCases[8], // "decimal values" + commonTestCases[9], // "without spaces around %" + commonTestCases[10], // "without 'of'" + } + runParseTest(t, tests) +} + +func TestParseXIsYPercentOfWhat(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + commonTestCases[11], // "30 is 20% of what" + commonTestCases[12], // "50 is 25% of what" + commonTestCases[13], // "decimal values" + commonTestCases[14], // "without spaces around %" + commonTestCases[15], // "without 'of'" + } + runParseTest(t, tests) +} + +func TestParseErrors(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "invalid input", + input: "hello world", + }, + { + name: "incomplete input", + input: "20%", + }, + { + name: "missing numbers", + input: "% of", + }, + { + name: "random text", + input: "calculate percentage", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse(tt.input) + if err == nil { + t.Errorf("Parse(%q) expected error, got nil", tt.input) + } + }) + } +} + +func TestParseCaseInsensitive(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "uppercase WHAT IS", + input: "WHAT IS 20% OF 150", + }, + { + name: "mixed case What Is", + input: "What Is 20% Of 150", + }, + { + name: "uppercase IS WHAT", + input: "30 IS WHAT % OF 150", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse(tt.input) + if err != nil { + t.Errorf("Parse(%q) should be case-insensitive, got error: %v", tt.input, err) + } + }) + } +} + +func TestParseDivisionByZero(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "X is what % of 0", + input: "30 is what % of 0", + }, + { + name: "X is 0% of what", + input: "30 is 0% of what", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse(tt.input) + if err == nil { + t.Errorf("Parse(%q) should handle division by zero, expected error", tt.input) + } + }) + } +} + +func TestParseWhitespace(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "extra spaces", + input: " 20% of 150 ", + expected: "20.00% of 150.00 = 30.00", + }, + { + name: "tabs and spaces", + input: "30 is what % of 150", + expected: "30.00 is 20.00% of 150.00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Parse(tt.input) + if err != nil { + t.Fatalf("Parse(%q) returned error: %v", tt.input, err) + } + if !strings.Contains(result, "of") { + t.Errorf("Parse(%q) should handle whitespace properly, got %q", tt.input, result) + } + }) + } +} |
