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.
This commit is contained in:
parent
2a26148c8a
commit
a3ed82c6d9
@ -2,7 +2,10 @@ package calc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Base represents the current display formatting mode.
|
||||
@ -22,20 +25,30 @@ var basesCycle = []Base{BaseDEC, BaseHEX, BaseBIN, BaseOCT}
|
||||
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.
|
||||
// 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 {
|
||||
value float64
|
||||
base Base
|
||||
pendingOp string // "+", "-", "*", "/", "mod", or ""
|
||||
pendingVal float64
|
||||
// entry buffer / more state will be added in engine implementation
|
||||
// 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.
|
||||
// NewEngine creates a fresh calculator engine starting in DEC with 0.
|
||||
func NewEngine() *Engine {
|
||||
return &Engine{
|
||||
value: 0,
|
||||
base: BaseDEC,
|
||||
accumulator: 0,
|
||||
entry: "0",
|
||||
base: BaseDEC,
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,16 +57,29 @@ func (e *Engine) CurrentBase() Base {
|
||||
return e.base
|
||||
}
|
||||
|
||||
// IsInteger reports whether the current value is effectively an integer
|
||||
// (within epsilon to tolerate fp noise from operations like 1/3).
|
||||
// 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 {
|
||||
_, frac := math.Modf(e.value)
|
||||
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 value
|
||||
// has a fractional part. The caller (UI) should trigger a "CERR" flash.
|
||||
// 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
|
||||
@ -64,24 +90,178 @@ func (e *Engine) CycleBase() error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// fallback
|
||||
e.base = BaseDEC
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatForDisplay returns the string to show in the large number area
|
||||
// according to the current base. For MVP only integer values are expected
|
||||
// in non-DEC bases (callers should have checked via CycleBase or IsInteger).
|
||||
// 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 {
|
||||
// Placeholder implementation. Real version in phase 2 will handle
|
||||
// proper integer formatting for hex/bin/oct (no 0x prefix, etc.)
|
||||
// and fall back gracefully.
|
||||
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 {
|
||||
// simple for skeleton
|
||||
return "0"
|
||||
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)
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
// TODO (phase 2): Add EnterDigit, SetOperator, Equals, Mod, ClearEntry,
|
||||
// AllClear, ChangeSign, Backspace, etc. + full FormatForDisplay.
|
||||
// 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).
|
||||
|
||||
@ -5,40 +5,128 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewEngine_DefaultsToDEC(t *testing.T) {
|
||||
func TestNewEngine_Defaults(t *testing.T) {
|
||||
e := NewEngine()
|
||||
if e.CurrentBase() != BaseDEC {
|
||||
t.Errorf("expected DEC, got %s", e.CurrentBase())
|
||||
}
|
||||
if !e.IsInteger() {
|
||||
t.Error("0 should be integer")
|
||||
if got := e.FormatForDisplay(); got != "0" {
|
||||
t.Errorf("initial display: want 0, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleBase_IntegerOK(t *testing.T) {
|
||||
e := NewEngine()
|
||||
e.value = 42 // integer
|
||||
e.accumulator = 42
|
||||
e.entry = ""
|
||||
|
||||
if err := e.CycleBase(); err != nil {
|
||||
t.Fatalf("unexpected error cycling from integer: %v", err)
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if e.CurrentBase() != BaseHEX {
|
||||
t.Errorf("expected HEX after one cycle, got %s", e.CurrentBase())
|
||||
t.Errorf("expected HEX, got %s", e.CurrentBase())
|
||||
}
|
||||
if got := e.FormatForDisplay(); got != "2A" {
|
||||
t.Errorf("hex of 42: want 2A, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleBase_FractionalCERR(t *testing.T) {
|
||||
e := NewEngine()
|
||||
e.value = 23.0 / 6.0 // 3.8333...
|
||||
// 23/6 = 3.8333...
|
||||
e.accumulator = 23.0 / 6.0
|
||||
e.entry = ""
|
||||
|
||||
err := e.CycleBase()
|
||||
if !errors.Is(err, ErrConversionNotPossible) {
|
||||
t.Fatalf("expected ErrConversionNotPossible, got %v", err)
|
||||
}
|
||||
if e.CurrentBase() != BaseDEC {
|
||||
t.Error("base must not change on CERR")
|
||||
t.Error("base must remain DEC after CERR")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO (phase 2): more tests for FormatForDisplay (hex/bin/oct of integers),
|
||||
// ops, clears, entry, combined fractional + multiple BASE presses, etc.
|
||||
func TestBasicArithmetic(t *testing.T) {
|
||||
e := NewEngine()
|
||||
|
||||
// 2 + 3 =
|
||||
e.EnterDigit('2')
|
||||
e.SetOperator("+")
|
||||
e.EnterDigit('3')
|
||||
e.Equals()
|
||||
|
||||
if got := e.FormatForDisplay(); got != "5" {
|
||||
t.Errorf("2+3: want 5, got %s", got)
|
||||
}
|
||||
|
||||
// * 4 =
|
||||
e.SetOperator("*")
|
||||
e.EnterDigit('4')
|
||||
e.Equals()
|
||||
if got := e.FormatForDisplay(); got != "20" {
|
||||
t.Errorf("5*4: want 20, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMOD(t *testing.T) {
|
||||
e := NewEngine()
|
||||
e.EnterDigit('2')
|
||||
e.EnterDigit('3')
|
||||
e.SetOperator("mod")
|
||||
e.EnterDigit('6')
|
||||
e.Equals()
|
||||
|
||||
if got := e.FormatForDisplay(); got != "5" {
|
||||
t.Errorf("23 mod 6: want 5, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFractionalThenBASE_CERR(t *testing.T) {
|
||||
e := NewEngine()
|
||||
|
||||
// 1 / 3 = 0.333...
|
||||
e.EnterDigit('1')
|
||||
e.SetOperator("/")
|
||||
e.EnterDigit('3')
|
||||
e.Equals()
|
||||
|
||||
if e.IsInteger() {
|
||||
t.Error("1/3 should not be integer")
|
||||
}
|
||||
|
||||
err := e.CycleBase()
|
||||
if !errors.Is(err, ErrConversionNotPossible) {
|
||||
t.Fatalf("expected CERR on fractional BASE, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearAndBackspace(t *testing.T) {
|
||||
e := NewEngine()
|
||||
e.EnterDigit('1')
|
||||
e.EnterDigit('2')
|
||||
e.EnterDigit('3')
|
||||
e.Backspace()
|
||||
if got := e.FormatForDisplay(); got != "12" {
|
||||
t.Errorf("backspace: want 12, got %s", got)
|
||||
}
|
||||
e.ClearEntry()
|
||||
if got := e.FormatForDisplay(); got != "0" {
|
||||
t.Errorf("clear entry: want 0, got %s", got)
|
||||
}
|
||||
e.AllClear()
|
||||
if e.CurrentBase() != BaseDEC {
|
||||
t.Error("all clear should reset base to DEC for MVP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeSign(t *testing.T) {
|
||||
e := NewEngine()
|
||||
e.EnterDigit('5')
|
||||
e.ChangeSign()
|
||||
if got := e.FormatForDisplay(); got != "-5" {
|
||||
t.Errorf("sign: want -5, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// More tests (entry buffer, multi-op, decimal point, etc.) will be added as the
|
||||
// TUI spike exercises real usage. The CERR path is the critical one for BASE.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user