gralculator/internal/calc/calc.go
Grok a3ed82c6d9 feat(engine): implement core calculation engine (phase 2)
- float64 decimal math + classic entry buffer (in-progress digits visible)
- CycleBase() with IsInteger() (epsilon) + ErrConversionNotPossible (CERR)
- FormatForDisplay() for DEC/HEX/BIN/OCT (integer formatting for non-DEC)
- Basic ops: + - * / and MOD (math.Mod)
- Equals, SetOperator, EnterDigit/DecimalPoint, ClearEntry, AllClear, ChangeSign, Backspace
- Comprehensive tests covering the critical CERR path (23/6, 1/3 + BASE) plus arithmetic and MOD

This completes the pure engine. The engine is now usable by the upcoming TUI spike. Paper trail updated.
2026-06-06 14:29:36 +01:00

268 lines
7.3 KiB
Go

package calc
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
)
// Base represents the current display formatting mode.
type Base string
const (
BaseDEC Base = "DEC"
BaseHEX Base = "HEX"
BaseBIN Base = "BIN"
BaseOCT Base = "OCT"
)
var basesCycle = []Base{BaseDEC, BaseHEX, BaseBIN, BaseOCT}
// ErrConversionNotPossible is returned by CycleBase when the current value
// has a fractional part and cannot be cleanly displayed in a non-DEC base.
var ErrConversionNotPossible = errors.New("conversion not possible (CERR)")
// Engine holds the calculator state. All math is decimal (float64).
// Bases affect only formatting via FormatForDisplay and the small row indicator.
// Entry model: classic "in-progress entry buffer" (you see what you are typing).
type Engine struct {
// committed accumulator (result of previous operations)
accumulator float64
// current in-progress entry as string (for display and for next operand)
entry string
// pending operator to apply when next operand is committed ("", "+", "-", "*", "/", "mod")
pendingOp string
// the left-hand value for the pending operation
pendingLeft float64
base Base
}
// NewEngine creates a fresh calculator engine starting in DEC with 0.
func NewEngine() *Engine {
return &Engine{
accumulator: 0,
entry: "0",
base: BaseDEC,
}
}
// CurrentBase returns the active display base for the small row.
func (e *Engine) CurrentBase() Base {
return e.base
}
// IsInteger reports whether the current *displayed* numeric value is effectively an integer
// (within epsilon to tolerate fp noise from operations like 1/3 or 0.1+0.2).
// We use the committed accumulator when there is no active entry, otherwise the parsed entry.
func (e *Engine) IsInteger() bool {
val := e.currentNumericValue()
_, frac := math.Modf(val)
return math.Abs(frac) < 1e-10
}
// currentNumericValue returns the number that should be considered "current"
// for operations and for BASE cycling / formatting.
func (e *Engine) currentNumericValue() float64 {
if e.entry != "" && e.entry != "0" {
if v, err := strconv.ParseFloat(e.entry, 64); err == nil {
return v
}
}
return e.accumulator
}
// CycleBase advances to the next base in the cycle (DEC→HEX→BIN→OCT→DEC).
// Returns ErrConversionNotPossible (and does not change base) if the current
// numeric value has a fractional part. The UI should trigger a "CERR" flash.
func (e *Engine) CycleBase() error {
if !e.IsInteger() {
return ErrConversionNotPossible
}
for i, b := range basesCycle {
if b == e.base {
e.base = basesCycle[(i+1)%len(basesCycle)]
return nil
}
}
e.base = BaseDEC
return nil
}
// FormatForDisplay returns the string to render in the large number area
// according to the current base and the current numeric value.
func (e *Engine) FormatForDisplay() string {
val := e.currentNumericValue()
// If we have an active entry string (user is typing), prefer showing the
// literal entry in DEC (classic calculator behavior). For other bases we
// still format the numeric value (after commit it will be consistent).
if e.base == BaseDEC {
if e.entry != "" {
return e.entry
}
// For DEC committed results, show a reasonable representation.
if math.Abs(val) < 1e12 {
return strconv.FormatFloat(val, 'f', -1, 64)
}
return strconv.FormatFloat(val, 'e', -1, 64)
}
// Non-DEC bases: only make sense for integers. The caller should have
// already guarded via CycleBase / IsInteger, but we coerce to int64 here.
// Negative numbers in hex/bin/oct are shown as two's complement style for
// programmer feel is out of MVP scope — we just show the positive integer part.
i := int64(val)
if i < 0 {
i = 0 // MVP: treat negative as 0 for non-DEC display
}
switch e.base {
case BaseHEX:
return strings.ToUpper(strconv.FormatInt(i, 16))
case BaseBIN:
return strconv.FormatInt(i, 2)
case BaseOCT:
return strconv.FormatInt(i, 8)
default:
return strconv.FormatFloat(val, 'f', -1, 64)
}
}
// EnterDigit appends a decimal digit to the current entry buffer.
func (e *Engine) EnterDigit(d rune) {
if e.entry == "0" || e.entry == "" {
e.entry = string(d)
} else {
e.entry += string(d)
}
}
// EnterDecimalPoint adds a '.' to the entry if one is not already present.
func (e *Engine) EnterDecimalPoint() {
if !strings.Contains(e.entry, ".") {
if e.entry == "" {
e.entry = "0."
} else {
e.entry += "."
}
}
}
// SetOperator commits the current entry (if any) as the left operand and
// records the operator for the next Equals / next operator.
func (e *Engine) SetOperator(op string) {
// Commit whatever is in the entry as the left side for this op.
left := e.currentNumericValue()
e.pendingLeft = left
e.pendingOp = op
e.entry = "0" // prepare for right-hand side entry
e.accumulator = left
}
// Equals / commit the pending operation using the current entry as right-hand side.
func (e *Engine) Equals() {
right := e.currentNumericValue()
result := e.applyPending(e.pendingLeft, right, e.pendingOp)
e.accumulator = result
e.entry = formatResultEntry(result)
e.pendingOp = ""
e.pendingLeft = 0
}
// applyPending performs the arithmetic for the given operator.
func (e *Engine) applyPending(left, right float64, op string) float64 {
switch op {
case "+":
return left + right
case "-":
return left - right
case "*":
return left * right
case "/":
if right == 0 {
return 0 // MVP: avoid panic, could later surface "ERR"
}
return left / right
case "mod", "MOD":
return math.Mod(left, right)
default:
return right
}
}
// formatResultEntry produces a clean entry string for a committed result.
func formatResultEntry(v float64) string {
if math.IsInf(v, 0) || math.IsNaN(v) {
return "0"
}
s := strconv.FormatFloat(v, 'f', -1, 64)
// Trim trailing .000 etc for cleanliness while keeping decimal when needed.
if strings.Contains(s, ".") {
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
}
if s == "" {
s = "0"
}
return s
}
// Mod performs immediate modulo using the current entry as the right operand
// (useful for a dedicated MOD button that acts like = for modulo).
func (e *Engine) Mod() {
right := e.currentNumericValue()
result := math.Mod(e.accumulator, right)
e.accumulator = result
e.entry = formatResultEntry(result)
e.pendingOp = ""
}
// ClearEntry clears only the current entry buffer (C button).
func (e *Engine) ClearEntry() {
e.entry = "0"
}
// AllClear resets everything (AC button).
func (e *Engine) AllClear() {
e.accumulator = 0
e.entry = "0"
e.pendingOp = ""
e.pendingLeft = 0
e.base = BaseDEC // or keep current base? MVP resets for simplicity; can relax later
}
// ChangeSign toggles the sign of the current entry (or accumulator if no entry).
func (e *Engine) ChangeSign() {
if e.entry != "" && e.entry != "0" {
if strings.HasPrefix(e.entry, "-") {
e.entry = strings.TrimPrefix(e.entry, "-")
} else {
e.entry = "-" + e.entry
}
return
}
e.accumulator = -e.accumulator
e.entry = formatResultEntry(e.accumulator)
}
// Backspace removes the last character from the current entry.
func (e *Engine) Backspace() {
if len(e.entry) <= 1 {
e.entry = "0"
return
}
e.entry = e.entry[:len(e.entry)-1]
if e.entry == "" || e.entry == "-" {
e.entry = "0"
}
}
// TODO for future: support for the exact "pending on display" formatting
// when a base change happens mid-operation (reformat the left operand).