- 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.
268 lines
7.3 KiB
Go
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).
|