diff --git a/README.md b/README.md
index 67f6474..b4bd250 100644
--- a/README.md
+++ b/README.md
@@ -12,14 +12,14 @@ Tiny8 is a lightweight and educational toolkit for exploring the fundamentals of
-

+
Real-time visualization of a bubble sort algorithm executing on Tiny8
## ✨ Features
### 🎯 **Interactive Terminal Debugger**
-
+
- **Vim-style navigation**: Step through execution with intuitive keyboard controls
- **Change highlighting**: See exactly what changed at each step (registers, flags, memory)
@@ -182,10 +182,10 @@ tiny8 examples/bubblesort.asm -m ani -o sort.gif -ms 0x60 -me 0x80 # Create GI
from tiny8 import CPU, assemble_file
cpu = CPU()
-cpu.load_program(*assemble_file("examples/bubblesort.asm"))
+cpu.load_program(assemble_file("examples/bubblesort.asm"))
cpu.run()
-print("Sorted:", [cpu.read_ram(i) for i in range(100, 132)])
+print("Sorted:", [cpu.read_ram(i) for i in range(0x60, 0x80)])
```
## 🔧 CLI Options
diff --git a/docs/examples/bubblesort.rst b/docs/examples/bubblesort.rst
index 1b8ac72..4224747 100644
--- a/docs/examples/bubblesort.rst
+++ b/docs/examples/bubblesort.rst
@@ -104,11 +104,11 @@ Inner Loop
add r22, r23
ld r24, r22 ; r24 = B
- ; compare and swap if needed
+ ; compare and swap if A > B (for ascending order)
cp r21, r24
- brcc no_swap
- st r20, r24 ; swap
- st r22, r21
+ brcs no_swap ; skip if A < B
+ st r20, r24 ; swap: RAM[addr_A] = B
+ st r22, r21 ; swap: RAM[addr_B] = A
no_swap:
inc r19
@@ -118,6 +118,7 @@ For each j:
1. Load adjacent elements (j and j+1)
2. Compare them
+3. Swap if first > second (for ascending order)
3. Swap if first > second
4. Advance to next pair
@@ -225,11 +226,12 @@ Python API
cpu.run(max_steps=50000) # Bubble sort needs many steps!
# Read sorted array
- sorted_array = [cpu.mem.read(0x60 + i) for i in range(32)]
+ sorted_array = [cpu.read_ram(i) for i in range(0x60, 0x80)]
print("Sorted array:", sorted_array)
- # Verify it's sorted
- assert sorted_array == sorted(sorted_array)
+ # Verify it's sorted in ascending order
+ assert all(sorted_array[i] <= sorted_array[i+1]
+ for i in range(len(sorted_array)-1))
print("✓ Array is correctly sorted!")
Performance Analysis
@@ -295,10 +297,10 @@ Conditional Swapping
.. code-block:: asm
- cp r21, r24 ; Compare values
- brcc no_swap ; Skip swap if in order
- st r20, r24 ; Perform swap
- st r22, r21
+ cp r21, r24 ; Compare values (A vs B)
+ brcs no_swap ; Skip swap if A < B (already in order)
+ st r20, r24 ; Perform swap: RAM[A] = B
+ st r22, r21 ; Perform swap: RAM[B] = A
no_swap:
Exercises
@@ -317,7 +319,7 @@ Solutions Hints
**Optimize**: Set a flag when swapping, check it at end of outer loop.
-**Descending order**: Change ``brcc no_swap`` to ``brcs no_swap``.
+**Descending order**: Change ``brcs no_swap`` to ``brcc no_swap``.
Visualization Tips
------------------
@@ -370,11 +372,3 @@ Related Examples
* :doc:`linear_search` - Sequential array access
* :doc:`find_max` - Array comparison operations
* :doc:`reverse` - Array manipulation
-
-Next Steps
-----------
-
-* Study :doc:`../architecture` for memory model details
-* Review :doc:`../instruction_reference` for LD, ST, CP
-* Try implementing other sorting algorithms
-* Use :doc:`../visualization` to understand algorithm behavior
diff --git a/docs/examples/fibonacci.rst b/docs/examples/fibonacci.rst
index b9237f9..eb682f8 100644
--- a/docs/examples/fibonacci.rst
+++ b/docs/examples/fibonacci.rst
@@ -234,10 +234,3 @@ Related Examples
* :doc:`factorial` - Another iterative calculation
* :doc:`sum_1_to_n` - Similar loop structure
* :doc:`power` - Repeated operation pattern
-
-Next Steps
-----------
-
-* Learn about :doc:`../assembly_language` syntax
-* Review the :doc:`../instruction_reference` for ADD, MOV, DEC, BRNE
-* Try the :doc:`../visualization` tools to see execution flow
diff --git a/docs/examples/index.rst b/docs/examples/index.rst
index bb75b78..dfd2fc3 100644
--- a/docs/examples/index.rst
+++ b/docs/examples/index.rst
@@ -149,12 +149,3 @@ Recommended order for learning:
3. **Master control flow**: linear_search, gcd
4. **Tackle algorithms**: bubblesort, power, is_prime
5. **Explore bit operations**: multiply_by_shift, count_bits
-
-Next Steps
-----------
-
-* Read the detailed :doc:`fibonacci` walkthrough
-* Study the :doc:`bubblesort` algorithm implementation
-* Review the :doc:`../architecture` for CPU details
-* Practice with :doc:`../visualization` tools
-* Explore the :doc:`../api/modules` for Python integration
diff --git a/examples/array_sum.asm b/examples/array_sum.asm
index 018eda5..af21797 100644
--- a/examples/array_sum.asm
+++ b/examples/array_sum.asm
@@ -1,52 +1,51 @@
-; Array sum: compute sum of array values [5, 10, 15, 20, 25, 30, 35, 40]
+; Array Sum - Sum array elements [5, 10, 15, 20, 25, 30, 35, 40]
; Demonstrates array initialization and iteration
-; Registers: R16 = sum, R17 = address, R18 = count, R19 = temp, R20-R21 = init
-; Output: R16 = 180 (sum of all array elements)
+; Output: R16 = 180
start:
- ; initialize array at RAM[0x60..0x67] with values [5, 10, 15, 20, 25, 30, 35, 40]
+ ; Initialize array at RAM[0x60..0x67]
ldi r20, 0x60
ldi r21, 5
- st r20, r21 ; RAM[0x60] = 5
+ st r20, r21
+ inc r20
- ldi r20, 0x61
ldi r21, 10
- st r20, r21 ; RAM[0x61] = 10
+ st r20, r21
+ inc r20
- ldi r20, 0x62
ldi r21, 15
- st r20, r21 ; RAM[0x62] = 15
+ st r20, r21
+ inc r20
- ldi r20, 0x63
ldi r21, 20
- st r20, r21 ; RAM[0x63] = 20
+ st r20, r21
+ inc r20
- ldi r20, 0x64
ldi r21, 25
- st r20, r21 ; RAM[0x64] = 25
+ st r20, r21
+ inc r20
- ldi r20, 0x65
ldi r21, 30
- st r20, r21 ; RAM[0x65] = 30
+ st r20, r21
+ inc r20
- ldi r20, 0x66
ldi r21, 35
- st r20, r21 ; RAM[0x66] = 35
+ st r20, r21
+ inc r20
- ldi r20, 0x67
ldi r21, 40
- st r20, r21 ; RAM[0x67] = 40
+ st r20, r21
- ; compute sum by iterating through array
- ldi r16, 0 ; sum = 0 (accumulator)
- ldi r17, 0x60 ; address = 0x60 (start of array)
- ldi r18, 8 ; count = 8 (number of elements)
+ ; Sum array elements
+ ldi r16, 0 ; sum = 0
+ ldi r17, 0x60 ; address
+ ldi r18, 8 ; count
sum_loop:
- ld r19, r17 ; load current array element into r19
- add r16, r19 ; add element to sum: sum += array[i]
- inc r17 ; advance to next address: address++
- dec r18 ; decrement counter: count--
+ ld r19, r17 ; Load array[i]
+ add r16, r19 ; sum += array[i]
+ inc r17 ; address++
+ dec r18 ; count--
; continue if more elements remain
cpi r18, 0
diff --git a/examples/bubblesort.asm b/examples/bubblesort.asm
index 01d6f35..0aba750 100644
--- a/examples/bubblesort.asm
+++ b/examples/bubblesort.asm
@@ -1,71 +1,63 @@
-; Bubble sort: fill RAM[0x60..0x80] with random values and sort ascending
-; Uses PRNG (pseudo-random number generator) to create test data
-; Then performs bubble sort by comparing adjacent elements
-; Registers: R16 = address, R17 = index/i, R18 = seed/i, R19 = j
-; R20-R24 = temp values, R25 = PRNG multiplier
-; Output: Sorted array at RAM[0x60..0x80] (32 bytes)
+; Bubble Sort - Generate and sort 32 random bytes in ascending order
+; Uses PRNG to fill RAM[0x60..0x7F], then bubble sorts in place
+; Output: Sorted array at RAM[0x60..0x7F]
- ; initialize PRNG and loop counters
- ldi r16, 0x60 ; base address
- ldi r17, 0 ; index = 0
- ldi r18, 123 ; PRNG seed (starting value)
- ldi r25, 75 ; PRNG multiplier (constant for random generation)
+ ; Initialize PRNG and counters
+ ldi r16, 0x60 ; Base address
+ ldi r17, 0 ; Index
+ ldi r18, 123 ; PRNG seed
+ ldi r25, 75 ; PRNG multiplier
init_loop:
- ; generate pseudo-random byte: seed = (seed * 75) + 1
- mul r18, r25 ; multiply seed by 75 (low byte only)
- inc r18 ; add 1 to avoid zero cycles
+ ; Generate random byte: seed = (seed * 75) + 1
+ mul r18, r25
+ inc r18
- ; store generated value at RAM[base + index]
- st r16, r18 ; RAM[0x60 + index] = random value
- inc r16 ; advance base pointer
- inc r17 ; increment index
+ st r16, r18 ; Store random value
+ inc r16
+ inc r17
- ; check if we've generated 32 values
ldi r23, 32
- cp r17, r23 ; compare index with 32
- brne init_loop ; continue if not done
+ cp r17, r23
+ brne init_loop
- ; bubble sort: 32 elements (outer loop runs 31 times)
- ldi r18, 0 ; i = 0 (outer loop counter)
+ ; Bubble sort: 32 elements
+ ldi r18, 0 ; Outer loop: i = 0
outer_loop:
- ldi r19, 0 ; j = 0 (inner loop counter - element index)
+ ldi r19, 0 ; Inner loop: j = 0
inner_loop:
- ; load element A = RAM[0x60 + j]
- ldi r20, 0x60 ; compute address of element A
+ ; Load pair: A = RAM[0x60 + j], B = RAM[0x60 + j + 1]
+ ldi r20, 0x60
add r20, r19
- ld r21, r20 ; r21 = value of element A
+ ld r21, r20 ; r21 = A
- ; load element B = RAM[0x60 + j + 1]
- ldi r22, 0x60 ; compute address of element B
+ ldi r22, 0x60
add r22, r19
ldi r23, 1
- add r22, r23 ; address = 0x60 + j + 1
- ld r24, r22 ; r24 = value of element B
+ add r22, r23
+ ld r24, r22 ; r24 = B
- ; compare and swap if A > B (ascending order)
- cp r21, r24 ; compare A with B
- brcc no_swap ; skip swap if A < B (carry clear)
+ ; Swap if A > B (ascending order)
+ cp r21, r24
+ brcs no_swap ; Skip if A < B
st r20, r24 ; RAM[addr_A] = B
st r22, r21 ; RAM[addr_B] = A
no_swap:
- ; advance to next pair of elements
- inc r19 ; j++
- ldi r23, 31 ; check if j < 31 (last valid pair)
+ inc r19
+ ldi r23, 31
cp r19, r23
- breq end_inner ; exit inner loop if j == 31
+ breq end_inner
jmp inner_loop
end_inner:
- ; advance outer loop counter
- inc r18 ; i++
- ldi r23, 31 ; check if i < 31 (need 31 passes)
+ inc r18
+ ldi r23, 31
cp r18, r23
- breq done ; exit if all passes complete
+ breq done
jmp outer_loop
done:
- jmp done ; infinite loop (halt)
+ jmp done
diff --git a/examples/find_max.asm b/examples/find_max.asm
index 3851302..e216310 100644
--- a/examples/find_max.asm
+++ b/examples/find_max.asm
@@ -1,77 +1,58 @@
-; Find max: find maximum value and its index in array [12, 45, 7, 89, 23, 56, 34, 78]
-; Uses linear search to track both maximum value and position
-; Registers: R16 = max value, R17 = max index, R18 = address, R19 = count
-; R20 = current index, R21 = current value
-; Output: R16 = 89 (maximum value), R17 = 3 (index of maximum)
+; Find Maximum - Find max value and index in [12, 45, 7, 89, 23, 56, 34, 78]
+; Output: R16 = 89 (max value), R17 = 3 (index)
start:
- ; initialize array at RAM[0x60..0x67] with test data
+ ; Initialize array at RAM[0x60..0x67]
ldi r20, 0x60
ldi r21, 12
- st r20, r21 ; RAM[0x60] = 12
+ st r20, r21
+ inc r20
- ldi r20, 0x61
ldi r21, 45
- st r20, r21 ; RAM[0x61] = 45
+ st r20, r21
+ inc r20
- ldi r20, 0x62
ldi r21, 7
- st r20, r21 ; RAM[0x62] = 7
+ st r20, r21
+ inc r20
- ldi r20, 0x63
- ldi r21, 89
- st r20, r21 ; RAM[0x63] = 89 (this is the maximum)
+ ldi r21, 89 ; Maximum value
+ st r20, r21
+ inc r20
- ldi r20, 0x64
ldi r21, 23
- st r20, r21 ; RAM[0x64] = 23
+ st r20, r21
+ inc r20
- ldi r20, 0x65
ldi r21, 56
- st r20, r21 ; RAM[0x65] = 56
+ st r20, r21
+ inc r20
- ldi r20, 0x66
ldi r21, 34
- st r20, r21 ; RAM[0x66] = 34
+ st r20, r21
+ inc r20
- ldi r20, 0x67
ldi r21, 78
- st r20, r21 ; RAM[0x67] = 78
-
- ; find maximum value and its index
- ldi r16, 0 ; max = 0 (current maximum value)
- ldi r17, 0 ; max_index = 0 (index of maximum)
- ldi r18, 0x60 ; address = 0x60 (current array position)
- ldi r19, 8 ; count = 8 (elements remaining)
- ldi r20, 0 ; current_index = 0
+ st r20, r21
+
+ ; Find maximum
+ ldi r16, 0 ; max = 0
+ ldi r17, 0 ; max_index = 0
+ ldi r18, 0x60 ; address
+ ldi r19, 8 ; count
+ ldi r20, 0 ; current_index
find_loop:
- ld r21, r18 ; load current array element
-
- ; compare current value with max: if value > max, update both
- cp r16, r21 ; compare max with current value
- brge no_update ; skip update if max >= value
- mov r16, r21 ; update max = current value
- mov r17, r20 ; update max_index = current_index
-
-no_update:
- inc r18 ; advance to next address
- inc r20 ; increment current index
- dec r19 ; decrement count
+ ld r21, r18 ; Load array[i]
- ; continue if more elements remain
- cpi r19, 0
- brne find_loop ; branch if count != 0
-
-done:
- jmp done ; infinite loop (halt)
-
- mov r16, r21 ; max = value
- mov r17, r20 ; max_index = current_index
+ cp r16, r21 ; Compare max with current
+ brge no_update ; Skip if max >= current
+ mov r16, r21 ; max = current
+ mov r17, r20 ; max_index = i
no_update:
- inc r18 ; next address
- inc r20 ; next index
+ inc r18 ; address++
+ inc r20 ; i++
dec r19 ; count--
cpi r19, 0
diff --git a/examples/gcd.asm b/examples/gcd.asm
index 80bb959..c0a5a63 100644
--- a/examples/gcd.asm
+++ b/examples/gcd.asm
@@ -1,27 +1,26 @@
-; GCD: compute gcd(48, 18)
+; GCD - Compute greatest common divisor using Euclidean algorithm
+; Example: gcd(48, 18) = 6
; Output: R16 = 6
start:
ldi r16, 48 ; a = 48
ldi r17, 18 ; b = 18
- ; if b == 0, done
cpi r17, 0
breq done
loop:
- ; compute remainder: a % b
- mov r18, r16 ; save a
- div r16, r17 ; r16 = a / b
- mul r16, r17 ; r16 = (a / b) * b
+ ; Compute remainder: a % b
+ mov r18, r16 ; Save a
+ div r16, r17 ; a / b
+ mul r16, r17 ; (a / b) * b
mov r19, r18
- sub r19, r16 ; r19 = a % b
+ sub r19, r16 ; remainder = a - (a/b)*b
- ; shift: a = b, b = remainder
+ ; Shift: a = b, b = remainder
mov r16, r17
mov r17, r19
- ; continue if b != 0
cpi r17, 0
brne loop
diff --git a/examples/hello_world.asm b/examples/hello_world.asm
index 70d0aa7..34466bc 100644
--- a/examples/hello_world.asm
+++ b/examples/hello_world.asm
@@ -1,33 +1,27 @@
-; Hello World: store ASCII string "HELLO" in memory
-; Demonstrates basic memory storage operations
-; Registers: R16 = address pointer, R17 = ASCII character value
-; Output: RAM[0x60..0x64] contains bytes [72, 69, 76, 76, 79] ("HELLO")
+; Hello World - Store ASCII string "HELLO" in memory
+; Demonstrates basic memory store operations
+; Output: RAM[0x60..0x64] contains "HELLO" in ASCII
start:
- ; store 'H' (ASCII 72 = 0x48)
- ldi r16, 0x60 ; address = 0x60
- ldi r17, 72 ; ASCII code for 'H'
- st r16, r17 ; RAM[0x60] = 'H'
+ ldi r16, 0x60 ; Base address
- ; store 'E' (ASCII 69 = 0x45)
- ldi r16, 0x61 ; address = 0x61
- ldi r17, 69 ; ASCII code for 'E'
- st r16, r17 ; RAM[0x61] = 'E'
+ ldi r17, 72 ; 'H'
+ st r16, r17
+ inc r16
- ; store first 'L' (ASCII 76 = 0x4C)
- ldi r16, 0x62 ; address = 0x62
- ldi r17, 76 ; ASCII code for 'L'
- st r16, r17 ; RAM[0x62] = 'L'
+ ldi r17, 69 ; 'E'
+ st r16, r17
+ inc r16
- ; store second 'L' (ASCII 76 = 0x4C)
- ldi r16, 0x63 ; address = 0x63
- ldi r17, 76 ; ASCII code for 'L'
- st r16, r17 ; RAM[0x63] = 'L'
+ ldi r17, 76 ; 'L'
+ st r16, r17
+ inc r16
- ; store 'O' (ASCII 79 = 0x4F)
- ldi r16, 0x64 ; address = 0x64
- ldi r17, 79 ; ASCII code for 'O'
- st r16, r17 ; RAM[0x64] = 'O'
+ st r16, r17 ; 'L' (reuse value)
+ inc r16
+
+ ldi r17, 79 ; 'O'
+ st r16, r17
done:
jmp done ; infinite loop (halt)
diff --git a/examples/sum_1_to_n.asm b/examples/sum_1_to_n.asm
index ae6f923..a952243 100644
--- a/examples/sum_1_to_n.asm
+++ b/examples/sum_1_to_n.asm
@@ -1,24 +1,18 @@
-; Sum: compute 1 + 2 + 3 + ... + 20 using loop
-; Demonstrates accumulation pattern with counter
-; Registers: R16 = sum (accumulator), R17 = n (limit), R18 = i (loop counter)
-; Output: R16 = 210 (sum of integers from 1 to 20)
+; Sum 1+2+3+...+N - Calculate sum of integers from 1 to 20
+; Demonstrates loop with accumulator pattern
+; Output: R16 = 210
start:
- ldi r17, 20 ; n = 20 (upper limit)
- ldi r16, 0 ; sum = 0 (accumulator starts at zero)
- ldi r18, 1 ; i = 1 (loop counter starts at 1)
+ ldi r17, 20 ; N = 20
+ ldi r16, 0 ; sum = 0
+ ldi r18, 1 ; i = 1
loop:
- ; add current counter value to sum: sum += i
- add r16, r18
-
- ; increment counter: i = i + 1
- inc r18
-
- ; continue loop if i <= n
- cp r18, r17 ; compare i with n
- brlt loop ; branch if i < n
- breq loop ; also branch if i == n (include n in sum)
+ add r16, r18 ; sum += i
+ inc r18 ; i++
+ cp r18, r17 ; Compare i with N
+ brlt loop ; Continue if i < N
+ breq loop ; Continue if i == N
done:
- jmp done ; infinite loop (halt)
+ jmp done
diff --git a/src/tiny8/parser.py b/src/tiny8/parser.py
deleted file mode 100644
index d05bbd3..0000000
--- a/src/tiny8/parser.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""Simple parser shim for tiny8.
-
-This module re-exports the assembler's ``parse_asm`` function and serves as
-the future location for a more featureful parser if needed.
-"""
-
-from .assembler import parse_asm as parse_asm
-
-__all__ = ["parse_asm"]
diff --git a/tests/test_assembler.py b/tests/test_assembler.py
new file mode 100644
index 0000000..4ebf3d5
--- /dev/null
+++ b/tests/test_assembler.py
@@ -0,0 +1,93 @@
+"""Assembler functionality tests.
+
+Tests for assembler parsing, number formats, and edge cases.
+"""
+
+import pytest
+
+from tiny8 import assemble, assemble_file
+
+
+class TestAssemblerBasics:
+ """Test basic assembler functionality."""
+
+ def test_assemble_file_missing(self):
+ """Test assembling non-existent file."""
+ with pytest.raises(FileNotFoundError):
+ assemble_file("nonexistent.asm")
+
+ def test_assemble_empty_string(self):
+ """Test assembling empty string."""
+ result = assemble("")
+ assert len(result.program) == 0
+
+ def test_assemble_only_comments(self):
+ """Test assembling file with only comments."""
+ result = assemble("""
+ ; This is a comment
+ ; Another comment
+ """)
+ assert len(result.program) == 0
+
+
+class TestNumberParsing:
+ """Test number parsing in assembler."""
+
+ def test_parse_number_hex_dollar(self):
+ """Test parsing hex immediate with $ prefix."""
+ from tiny8.assembler import _parse_number
+
+ assert _parse_number("$FF") == 255
+ assert _parse_number("$10") == 16
+
+ def test_parse_number_hex_0x(self):
+ """Test parsing hex immediate with 0x prefix."""
+ from tiny8.assembler import _parse_number
+
+ assert _parse_number("0xFF") == 255
+ assert _parse_number("0x10") == 16
+
+ def test_parse_number_binary(self):
+ """Test parsing binary immediate."""
+ from tiny8.assembler import _parse_number
+
+ assert _parse_number("0b1111") == 15
+ assert _parse_number("0b10101010") == 170
+
+ def test_parse_number_decimal(self):
+ """Test parsing decimal immediate."""
+ from tiny8.assembler import _parse_number
+
+ assert _parse_number("100") == 100
+ assert _parse_number("255") == 255
+
+ def test_parse_number_with_hash(self):
+ """Test parsing immediate with # marker."""
+ from tiny8.assembler import _parse_number
+
+ assert _parse_number("#100") == 100
+ assert _parse_number("#$FF") == 255
+
+ def test_parse_number_invalid(self):
+ """Test parsing invalid immediate raises ValueError."""
+ from tiny8.assembler import _parse_number
+
+ with pytest.raises(ValueError):
+ _parse_number("not_a_number")
+ with pytest.raises(ValueError):
+ _parse_number("label_name")
+
+
+class TestAssemblerWithFiles:
+ """Test assembler with actual files."""
+
+ def test_assemble_file_with_actual_file(self, tmp_path):
+ """Test assembling from actual file."""
+ asm_file = tmp_path / "test.asm"
+ asm_file.write_text("""
+ ldi r16, 42
+ ldi r17, 100
+ """)
+
+ result = assemble_file(str(asm_file))
+ assert len(result.program) == 2
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..9a10abd
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,794 @@
+"""Comprehensive tests for CLI module functions.
+
+Tests command parsing, state management, and utility functions
+without requiring actual terminal/curses interaction.
+"""
+
+import time
+
+from tiny8 import CPU, assemble
+from tiny8.cli import KeyContext, ViewState, format_byte, run_command
+
+
+class TestFormatByte:
+ """Test byte formatting utility."""
+
+ def test_format_byte_zero(self):
+ """Test formatting zero."""
+ assert format_byte(0) == "00"
+
+ def test_format_byte_single_digit(self):
+ """Test formatting single digit values."""
+ assert format_byte(1) == "01"
+ assert format_byte(9) == "09"
+ assert format_byte(15) == "0F"
+
+ def test_format_byte_two_digit(self):
+ """Test formatting two digit values."""
+ assert format_byte(16) == "10"
+ assert format_byte(255) == "FF"
+ assert format_byte(128) == "80"
+
+ def test_format_byte_various(self):
+ """Test formatting various byte values."""
+ assert format_byte(0x42) == "42"
+ assert format_byte(0xAB) == "AB"
+ assert format_byte(0xCD) == "CD"
+
+
+class TestRunCommandNumeric:
+ """Test numeric command parsing."""
+
+ def test_absolute_step_valid(self):
+ """Test jumping to absolute step number."""
+ state = ViewState()
+ traces = [{"step": i} for i in range(10)]
+
+ result = run_command(state, traces)
+ state.command_buffer = "5"
+ result = run_command(state, traces)
+
+ assert "→ step 5" in result
+ assert state.step_idx == 5
+ assert state.scroll_offset == 0
+
+ def test_absolute_step_zero(self):
+ """Test jumping to step zero."""
+ state = ViewState(step_idx=5)
+ traces = [{"step": i} for i in range(10)]
+ state.command_buffer = "0"
+
+ result = run_command(state, traces)
+
+ assert "→ step 0" in result
+ assert state.step_idx == 0
+
+ def test_absolute_step_invalid_too_large(self):
+ """Test invalid step number (too large)."""
+ state = ViewState()
+ traces = [{"step": i} for i in range(10)]
+ state.command_buffer = "20"
+
+ result = run_command(state, traces)
+
+ assert "Invalid" in result
+ assert state.step_idx == 0 # Should not change
+
+ def test_absolute_step_invalid_negative(self):
+ """Test invalid step number (negative shown as invalid)."""
+ state = ViewState()
+ traces = [{"step": i} for i in range(10)]
+ state.command_buffer = "-5"
+
+ result = run_command(state, traces)
+
+ assert state.step_idx == 0 or "Invalid" in result
+
+
+class TestRunCommandRelative:
+ """Test relative jump commands."""
+
+ def test_relative_forward(self):
+ """Test jumping forward relative steps."""
+ state = ViewState(step_idx=5)
+ traces = [{"step": i} for i in range(20)]
+ state.command_buffer = "+3"
+
+ result = run_command(state, traces)
+
+ assert "→ step 8" in result
+ assert state.step_idx == 8
+
+ def test_relative_backward(self):
+ """Test jumping backward relative steps."""
+ state = ViewState(step_idx=10)
+ traces = [{"step": i} for i in range(20)]
+ state.command_buffer = "-5"
+
+ result = run_command(state, traces)
+
+ assert "→ step 5" in result
+ assert state.step_idx == 5
+
+ def test_relative_forward_out_of_bounds(self):
+ """Test relative forward beyond trace length."""
+ state = ViewState(step_idx=8)
+ traces = [{"step": i} for i in range(10)]
+ state.command_buffer = "+10"
+
+ result = run_command(state, traces)
+
+ assert "Invalid" in result
+ assert state.step_idx == 8 # Should not change
+
+ def test_relative_backward_out_of_bounds(self):
+ """Test relative backward beyond start."""
+ state = ViewState(step_idx=3)
+ traces = [{"step": i} for i in range(10)]
+ state.command_buffer = "-5"
+
+ result = run_command(state, traces)
+
+ assert "Invalid" in result
+ assert state.step_idx == 3 # Should not change
+
+
+class TestRunCommandSearch:
+ """Test instruction search commands."""
+
+ def test_forward_search_found(self):
+ """Test forward search finding instruction."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "instr": "LDI R16, 42"},
+ {"step": 1, "instr": "LDI R17, 100"},
+ {"step": 2, "instr": "ADD R16, R17"},
+ ]
+ state.command_buffer = "/add"
+
+ result = run_command(state, traces)
+
+ assert "Found at step 2" in result
+ assert "ADD" in result
+ assert state.step_idx == 2
+
+ def test_forward_search_case_insensitive(self):
+ """Test forward search is case insensitive."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "instr": "LDI R16, 42"},
+ {"step": 1, "instr": "ADD R16, R17"},
+ ]
+ state.command_buffer = "/ADD"
+
+ result = run_command(state, traces)
+
+ assert "Found" in result
+ assert state.step_idx == 1
+
+ def test_forward_search_not_found(self):
+ """Test forward search when not found."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "instr": "LDI R16, 42"},
+ {"step": 1, "instr": "LDI R17, 100"},
+ ]
+ state.command_buffer = "/mul"
+
+ result = run_command(state, traces)
+
+ assert "Not found" in result
+ assert state.step_idx == 0 # Should not change
+
+ def test_forward_search_empty(self):
+ """Test forward search with empty pattern."""
+ state = ViewState(step_idx=0)
+ traces = [{"step": 0, "instr": "LDI R16, 42"}]
+ state.command_buffer = "/"
+
+ result = run_command(state, traces)
+
+ assert "Empty search" in result
+
+ def test_backward_search_found(self):
+ """Test backward search finding instruction."""
+ state = ViewState(step_idx=2)
+ traces = [
+ {"step": 0, "instr": "LDI R16, 42"},
+ {"step": 1, "instr": "LDI R17, 100"},
+ {"step": 2, "instr": "ADD R16, R17"},
+ ]
+ state.command_buffer = "?ldi"
+
+ result = run_command(state, traces)
+
+ assert "Found at step 1" in result
+ assert state.step_idx == 1
+
+ def test_backward_search_not_found(self):
+ """Test backward search when not found."""
+ state = ViewState(step_idx=2)
+ traces = [
+ {"step": 0, "instr": "LDI R16, 42"},
+ {"step": 1, "instr": "LDI R17, 100"},
+ {"step": 2, "instr": "ADD R16, R17"},
+ ]
+ state.command_buffer = "?mul"
+
+ result = run_command(state, traces)
+
+ assert "Not found" in result
+ assert state.step_idx == 2
+
+
+class TestRunCommandPCJump:
+ """Test PC address jump commands."""
+
+ def test_pc_jump_decimal(self):
+ """Test jumping to PC address in decimal."""
+ state = ViewState()
+ traces = [
+ {"step": 0, "pc": 0},
+ {"step": 1, "pc": 1},
+ {"step": 2, "pc": 100},
+ ]
+ state.command_buffer = "@100"
+
+ result = run_command(state, traces)
+
+ assert "→ step 2" in result
+ assert "PC=0x0064" in result
+ assert state.step_idx == 2
+
+ def test_pc_jump_hex(self):
+ """Test jumping to PC address in hex."""
+ state = ViewState()
+ traces = [
+ {"step": 0, "pc": 0},
+ {"step": 1, "pc": 1},
+ {"step": 2, "pc": 0x64},
+ ]
+ state.command_buffer = "@0x64"
+
+ result = run_command(state, traces)
+
+ assert "→ step 2" in result
+ assert state.step_idx == 2
+
+ def test_pc_jump_not_found(self):
+ """Test PC jump when address not found."""
+ state = ViewState()
+ traces = [
+ {"step": 0, "pc": 0},
+ {"step": 1, "pc": 1},
+ ]
+ state.command_buffer = "@999"
+
+ result = run_command(state, traces)
+
+ assert "not found" in result
+ assert state.step_idx == 0
+
+ def test_pc_jump_invalid_format(self):
+ """Test PC jump with invalid format."""
+ state = ViewState()
+ traces = [{"step": 0, "pc": 0}]
+ state.command_buffer = "@xyz"
+
+ result = run_command(state, traces)
+
+ assert "Invalid address" in result
+
+
+class TestRunCommandRegister:
+ """Test register tracking and search commands."""
+
+ def test_register_track_change(self):
+ """Test tracking register changes."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "regs": [0] * 32},
+ {"step": 1, "regs": [0] * 32},
+ {"step": 2, "regs": [42] + [0] * 31}, # R0 changes
+ ]
+ state.command_buffer = "r0"
+
+ result = run_command(state, traces)
+
+ assert "R0 changed at step 2" in result
+ assert "0x2A" in result
+ assert state.step_idx == 2
+
+ def test_register_track_no_change(self):
+ """Test tracking register that doesn't change."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "regs": [0] * 32},
+ {"step": 1, "regs": [0] * 32},
+ ]
+ state.command_buffer = "r16"
+
+ result = run_command(state, traces)
+
+ assert "doesn't change" in result
+ assert state.step_idx == 0
+
+ def test_register_search_value_decimal(self):
+ """Test searching for register value in decimal."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "regs": [0] * 32},
+ {"step": 1, "regs": [42] + [0] * 31},
+ ]
+ state.command_buffer = "r0=42"
+
+ result = run_command(state, traces)
+
+ assert "R0=0x2A at step 1" in result
+ assert state.step_idx == 1
+
+ def test_register_search_value_hex(self):
+ """Test searching for register value in hex."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "regs": [0] * 32},
+ {"step": 1, "regs": [255] + [0] * 31},
+ ]
+ state.command_buffer = "r0=0xFF"
+
+ result = run_command(state, traces)
+
+ assert "R0=0xFF at step 1" in result
+ assert state.step_idx == 1
+
+ def test_register_search_not_found(self):
+ """Test register search when value not found."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "regs": [0] * 32},
+ {"step": 1, "regs": [42] + [0] * 31},
+ ]
+ state.command_buffer = "r0=99"
+
+ result = run_command(state, traces)
+
+ assert "not found" in result
+
+ def test_register_invalid_number(self):
+ """Test invalid register number."""
+ state = ViewState()
+ traces = [{"step": 0, "regs": [0] * 32}]
+ state.command_buffer = "r32"
+
+ result = run_command(state, traces)
+
+ assert "Invalid register" in result
+
+ def test_register_invalid_format(self):
+ """Test invalid register command format."""
+ state = ViewState()
+ traces = [{"step": 0, "regs": [0] * 32}]
+ state.command_buffer = "rabc"
+
+ result = run_command(state, traces)
+
+ assert "Invalid" in result
+
+
+class TestRunCommandMemory:
+ """Test memory tracking and search commands."""
+
+ def test_memory_track_change(self):
+ """Test tracking memory changes."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "mem": {}},
+ {"step": 1, "mem": {}},
+ {"step": 2, "mem": {100: 0x42}},
+ ]
+ state.command_buffer = "m100"
+
+ result = run_command(state, traces)
+
+ assert "Mem[0x0064] changed at step 2" in result
+ assert "0x42" in result
+ assert state.step_idx == 2
+
+ def test_memory_track_no_change(self):
+ """Test tracking memory that doesn't change."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "mem": {}},
+ {"step": 1, "mem": {}},
+ ]
+ state.command_buffer = "m100"
+
+ result = run_command(state, traces)
+
+ assert "doesn't change" in result
+
+ def test_memory_search_value_decimal(self):
+ """Test searching for memory value in decimal."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "mem": {}},
+ {"step": 1, "mem": {100: 42}},
+ ]
+ state.command_buffer = "m100=42"
+
+ result = run_command(state, traces)
+
+ assert "Mem[0x0064]=0x2A at step 1" in result
+ assert state.step_idx == 1
+
+ def test_memory_search_value_hex(self):
+ """Test searching for memory value in hex."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "mem": {}},
+ {"step": 1, "mem": {0x64: 0xFF}},
+ ]
+ state.command_buffer = "m0x64=0xFF"
+
+ result = run_command(state, traces)
+
+ assert "at step 1" in result
+ assert state.step_idx == 1
+
+ def test_memory_search_not_found(self):
+ """Test memory search when value not found."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "mem": {}},
+ {"step": 1, "mem": {100: 42}},
+ ]
+ state.command_buffer = "m100=99"
+
+ result = run_command(state, traces)
+
+ assert "not found" in result
+
+ def test_memory_invalid_format(self):
+ """Test invalid memory command format."""
+ state = ViewState()
+ traces = [{"step": 0, "mem": {}}]
+ state.command_buffer = "mxyz"
+
+ result = run_command(state, traces)
+
+ assert "Invalid" in result
+
+
+class TestRunCommandFlag:
+ """Test flag tracking and search commands."""
+
+ def test_flag_track_zero_change(self):
+ """Test tracking Zero flag changes."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "sreg": 0b00000000}, # Z=0
+ {"step": 1, "sreg": 0b00000010}, # Z=1
+ ]
+ state.command_buffer = "fZ"
+
+ result = run_command(state, traces)
+
+ assert "Flag Z changed at step 1" in result
+ assert state.step_idx == 1
+
+ def test_flag_track_carry_change(self):
+ """Test tracking Carry flag changes."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "sreg": 0b00000000}, # C=0
+ {"step": 1, "sreg": 0b00000001}, # C=1
+ ]
+ state.command_buffer = "fC"
+
+ result = run_command(state, traces)
+
+ assert "Flag C changed at step 1" in result
+ assert state.step_idx == 1
+
+ def test_flag_track_no_change(self):
+ """Test tracking flag that doesn't change."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "sreg": 0b00000000},
+ {"step": 1, "sreg": 0b00000000},
+ ]
+ state.command_buffer = "fZ"
+
+ result = run_command(state, traces)
+
+ assert "doesn't change" in result
+
+ def test_flag_search_value_set(self):
+ """Test searching for flag set (=1)."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "sreg": 0b00000000},
+ {"step": 1, "sreg": 0b00000010}, # Z=1
+ ]
+ state.command_buffer = "fZ=1"
+
+ result = run_command(state, traces)
+
+ assert "Flag Z=1 at step 1" in result
+ assert state.step_idx == 1
+
+ def test_flag_search_value_clear(self):
+ """Test searching for flag clear (=0)."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "sreg": 0b00000010}, # Z=1
+ {"step": 1, "sreg": 0b00000000}, # Z=0
+ ]
+ state.command_buffer = "fZ=0"
+
+ result = run_command(state, traces)
+
+ assert "Flag Z=0 at step 1" in result
+ assert state.step_idx == 1
+
+ def test_flag_all_flags(self):
+ """Test all flag names."""
+ flags = ["I", "T", "H", "S", "V", "N", "Z", "C"]
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "sreg": 0b00000000},
+ {"step": 1, "sreg": 0b11111111}, # All flags set
+ ]
+
+ for flag in flags:
+ state.step_idx = 0
+ state.command_buffer = f"f{flag}"
+ result = run_command(state, traces)
+ assert "changed" in result
+
+ def test_flag_case_insensitive(self):
+ """Test flag names are case insensitive."""
+ state = ViewState(step_idx=0)
+ traces = [
+ {"step": 0, "sreg": 0b00000000},
+ {"step": 1, "sreg": 0b00000010},
+ ]
+ state.command_buffer = "fz" # lowercase
+
+ result = run_command(state, traces)
+
+ assert "Flag Z changed" in result
+
+ def test_flag_invalid_name(self):
+ """Test invalid flag name."""
+ state = ViewState()
+ traces = [{"step": 0, "sreg": 0}]
+ state.command_buffer = "fX"
+
+ result = run_command(state, traces)
+
+ assert "Invalid flag" in result
+
+ def test_flag_invalid_value(self):
+ """Test invalid flag value."""
+ state = ViewState()
+ traces = [{"step": 0, "sreg": 0}]
+ state.command_buffer = "fZ=2"
+
+ result = run_command(state, traces)
+
+ assert "must be 0 or 1" in result
+
+
+class TestRunCommandMisc:
+ """Test miscellaneous commands."""
+
+ def test_help_command_h(self):
+ """Test help command with 'h'."""
+ state = ViewState()
+ traces = [{"step": 0}]
+ state.command_buffer = "h"
+
+ result = run_command(state, traces)
+
+ assert "Commands:" in result
+ assert "NUM" in result
+
+ def test_help_command_help(self):
+ """Test help command with 'help'."""
+ state = ViewState()
+ traces = [{"step": 0}]
+ state.command_buffer = "help"
+
+ result = run_command(state, traces)
+
+ assert "Commands:" in result
+
+ def test_unknown_command(self):
+ """Test unknown command."""
+ state = ViewState()
+ traces = [{"step": 0}]
+ state.command_buffer = "xyz123"
+
+ result = run_command(state, traces)
+
+ assert "Unknown:" in result
+
+ def test_empty_command(self):
+ """Test empty command."""
+ state = ViewState()
+ traces = [{"step": 0}]
+ state.command_buffer = ""
+
+ result = run_command(state, traces)
+
+ assert "Unknown:" in result or result == ""
+
+
+class TestRunCommandIntegration:
+ """Integration tests with actual CPU traces."""
+
+ def test_command_with_real_trace(self):
+ """Test commands with actual CPU execution trace."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 10
+ ldi r17, 20
+ add r16, r17
+ ldi r18, 100
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+
+ traces = cpu.step_trace
+ state = ViewState()
+
+ state.command_buffer = "/add"
+ result = run_command(state, traces)
+ assert "Found" in result
+
+ state.step_idx = 0
+ state.command_buffer = "r16=30"
+ result = run_command(state, traces)
+ assert "R16=0x1E" in result or "not found" in result
+
+ def test_multiple_commands_sequence(self):
+ """Test sequence of different commands."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 1
+ ldi r17, 2
+ add r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+
+ traces = cpu.step_trace
+ state = ViewState()
+
+ state.command_buffer = "1"
+ run_command(state, traces)
+ assert state.step_idx == 1
+
+ state.command_buffer = "+1"
+ run_command(state, traces)
+ assert state.step_idx == 2
+
+ state.command_buffer = "-1"
+ run_command(state, traces)
+ assert state.step_idx == 1
+
+
+class TestKeyContext:
+ """Test KeyContext methods."""
+
+ def test_set_status(self):
+ """Test setting status message."""
+ state = ViewState()
+ ctx = KeyContext(
+ state=state,
+ scr=None,
+ traces=[],
+ cpu=None,
+ mem_addr_start=0,
+ mem_addr_end=31,
+ source_lines=None,
+ n=0,
+ )
+
+ before_time = time.time()
+ ctx.set_status("Test message")
+ after_time = time.time()
+
+ assert state.status_msg == "Test message"
+ assert state.status_time >= before_time
+ assert state.status_time <= after_time
+
+ def test_set_status_multiple_times(self):
+ """Test setting status message multiple times."""
+ state = ViewState()
+ ctx = KeyContext(
+ state=state,
+ scr=None,
+ traces=[],
+ cpu=None,
+ mem_addr_start=0,
+ mem_addr_end=31,
+ source_lines=None,
+ n=0,
+ )
+
+ ctx.set_status("First message")
+ first_time = state.status_time
+
+ time.sleep(0.01) # Small delay to ensure time changes
+
+ ctx.set_status("Second message")
+ second_time = state.status_time
+
+ assert state.status_msg == "Second message"
+ assert second_time > first_time
+
+
+class TestKeyHandlerDecorator:
+ """Test the key_handler decorator."""
+
+ def test_key_handler_decorator_registration(self):
+ """Test that key_handler decorator registers handlers correctly."""
+ from tiny8.cli import _key_handlers
+
+ assert ord("q") in _key_handlers
+ assert 27 in _key_handlers
+ assert ord(" ") in _key_handlers
+ assert ord("l") in _key_handlers
+ assert ord("h") in _key_handlers
+ assert ord("w") in _key_handlers
+ assert ord("b") in _key_handlers
+
+
+class TestViewStateDefaults:
+ """Test ViewState default values."""
+
+ def test_viewstate_defaults(self):
+ """Test ViewState initializes with correct defaults."""
+ state = ViewState()
+
+ assert state.step_idx == 0
+ assert state.scroll_offset == 0
+ assert not state.playing
+ assert state.last_advance_time == 0.0
+ assert state.delay == 0.15
+ assert state.show_all_regs
+ assert not state.show_all_mem
+ assert not state.command_mode
+ assert state.command_buffer == ""
+ assert state.marks == {}
+ assert state.status_msg == ""
+ assert state.status_time == 0.0
+
+ def test_viewstate_custom_values(self):
+ """Test ViewState with custom values."""
+ marks = {"a": 10, "b": 20}
+ state = ViewState(
+ step_idx=5,
+ scroll_offset=3,
+ playing=True,
+ delay=0.5,
+ show_all_regs=False,
+ show_all_mem=True,
+ command_mode=True,
+ command_buffer="test",
+ marks=marks,
+ status_msg="Status",
+ )
+
+ assert state.step_idx == 5
+ assert state.scroll_offset == 3
+ assert state.playing
+ assert state.delay == 0.5
+ assert not state.show_all_regs
+ assert state.show_all_mem
+ assert state.command_mode
+ assert state.command_buffer == "test"
+ assert state.marks == marks
+ assert state.status_msg == "Status"
diff --git a/tests/test_cpu_branches.py b/tests/test_cpu_branches.py
new file mode 100644
index 0000000..df9c9dc
--- /dev/null
+++ b/tests/test_cpu_branches.py
@@ -0,0 +1,347 @@
+"""CPU branch instruction tests.
+
+Tests for conditional branches, stack operations, calls, and returns.
+"""
+
+from tiny8 import CPU, assemble
+
+
+class TestConditionalBranches:
+ """Test conditional branch instructions."""
+
+ def test_brmi_not_taken(self):
+ """Test branch if minus when not taken."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 100
+ ldi r17, 50
+ sub r16, r17
+ brmi skip
+ ldi r18, 1
+ skip:
+ ldi r19, 2
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(18) == 1 # Not skipped
+
+ def test_brmi_jump_taken(self):
+ """Test BRMI when negative flag is set and jump is taken."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 100
+ ldi r17, 200
+ sub r16, r17
+ brmi target
+ ldi r18, 1
+ target:
+ ldi r19, 2
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ # The jump should be taken because 100 - 200 is negative
+ assert cpu.regs[19] == 2
+ assert cpu.regs[18] == 0 # This should not execute
+
+ def test_brpl_not_taken(self):
+ """Test branch if plus when not taken."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 50
+ ldi r17, 100
+ sub r16, r17
+ brpl skip
+ ldi r18, 1
+ skip:
+ ldi r19, 2
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(18) == 1 # Not skipped
+
+ def test_brpl_jump_taken(self):
+ """Test BRPL when negative flag is clear and jump is taken."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 200
+ ldi r17, 100
+ sub r16, r17
+ brpl target
+ ldi r18, 1
+ target:
+ ldi r19, 2
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ # The jump should be taken because 200 - 100 is positive
+ assert cpu.regs[19] == 2
+ assert cpu.regs[18] == 0 # This should not execute
+
+ def test_brge_taken(self):
+ """Test branch if greater or equal when taken."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 100
+ ldi r17, 50
+ cp r16, r17
+ brge skip
+ ldi r18, 99
+ skip:
+ ldi r19, 1
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(18) == 0 # Skipped
+ assert cpu.read_reg(19) == 1
+
+ def test_brge_not_taken(self):
+ """Test branch if greater or equal when not taken."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 50
+ ldi r17, 100
+ cp r16, r17
+ brge skip
+ ldi r18, 1
+ skip:
+ ldi r19, 2
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(18) == 1 # Not skipped
+
+ def test_brlt_taken(self):
+ """Test branch if less than when taken."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 50
+ ldi r17, 100
+ cp r16, r17
+ brlt skip
+ ldi r18, 99
+ skip:
+ ldi r19, 1
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(18) == 0 # Skipped
+ assert cpu.read_reg(19) == 1
+
+ def test_brlt_not_taken(self):
+ """Test branch if less than when not taken."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 100
+ ldi r17, 50
+ cp r16, r17
+ brlt skip
+ ldi r18, 1
+ skip:
+ ldi r19, 2
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(18) == 1 # Not skipped
+
+ def test_brpl_jump_case(self):
+ """Test BRPL actually jumping."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 100
+ ldi r17, 50
+ sub r16, r17
+ ldi r18, 0
+ brpl target
+ ldi r18, 99
+ target:
+ ldi r19, 1
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ # With positive result, should jump and skip r18 assignment
+ assert cpu.read_reg(18) == 0
+
+ def test_brmi_with_integer_label(self):
+ """Test BRMI with integer label."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 200
+ ldi r17, 100
+ sub r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ # Now manually test brmi with integer
+ cpu.program.append(("BRMI", (0,)))
+ cpu.pc = len(cpu.program) - 1
+ cpu.step()
+
+ def test_brpl_with_integer_label(self):
+ """Test BRPL with integer label."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 100
+ ldi r17, 50
+ sub r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ # Now manually test brpl with integer
+ cpu.program.append(("BRPL", (0,)))
+ cpu.pc = len(cpu.program) - 1
+ cpu.step()
+
+
+class TestStackAndCall:
+ """Test stack operations, calls, and returns."""
+
+ def test_push_pop(self):
+ """Test push and pop instructions."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 42
+ push r16
+ ldi r16, 0
+ pop r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(17) == 42
+
+ def test_multiple_push_pop(self):
+ """Test multiple push and pop operations."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 10
+ ldi r17, 20
+ ldi r18, 30
+ push r16
+ push r17
+ push r18
+ pop r19
+ pop r20
+ pop r21
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(19) == 30
+ assert cpu.read_reg(20) == 20
+ assert cpu.read_reg(21) == 10
+
+ def test_call_ret(self):
+ """Test call and ret instructions."""
+ cpu = CPU()
+ asm = assemble("""
+ call func
+ ldi r16, 1
+ jmp done
+ func:
+ ldi r17, 2
+ ret
+ done:
+ nop
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 1
+ assert cpu.read_reg(17) == 2
+
+ def test_nested_calls(self):
+ """Test nested function calls."""
+ cpu = CPU()
+ asm = assemble("""
+ call func1
+ ldi r16, 1
+ jmp done
+ func1:
+ call func2
+ ldi r17, 2
+ ret
+ func2:
+ ldi r18, 3
+ ret
+ done:
+ nop
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 1
+ assert cpu.read_reg(17) == 2
+ assert cpu.read_reg(18) == 3
+
+ def test_reti(self):
+ """Test return from interrupt."""
+ cpu = CPU()
+ asm = assemble("""
+ call isr
+ ldi r16, 1
+ jmp done
+ isr:
+ ldi r17, 2
+ reti
+ done:
+ nop
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 1
+ assert cpu.read_reg(17) == 2
+
+
+class TestSetClearInstructions:
+ """Test SET and CLEAR type instructions."""
+
+ def test_ser_clr(self):
+ """Test SER and CLR instructions."""
+ cpu = CPU()
+ asm = assemble("""
+ ser r16
+ clr r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0xFF
+ assert cpu.read_reg(17) == 0
+
+ def test_adc_with_carry(self):
+ """Test ADC with carry flag set."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 5
+ ldi r17, 10
+ add r16, r17
+ ldi r18, 20
+ ldi r19, 30
+ adc r18, r19
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ # r18 should be 20 + 30 + carry (if any)
+
+
+class TestLogicalOperations:
+ """Test logical operations for branch coverage."""
+
+ def test_or_operation_edge_case(self):
+ """Test OR operation with different values."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0x0F
+ ldi r17, 0xF0
+ or r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0xFF
+
+ def test_eor_operation_edge_case(self):
+ """Test EOR operation with same values."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0xFF
+ ldi r17, 0xFF
+ eor r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0
diff --git a/tests/test_cpu_core.py b/tests/test_cpu_core.py
new file mode 100644
index 0000000..1300b17
--- /dev/null
+++ b/tests/test_cpu_core.py
@@ -0,0 +1,400 @@
+"""Core CPU instruction tests.
+
+Tests for CPU instructions including shifts, rotates, logical operations,
+bit manipulation, and word operations.
+"""
+
+from tiny8 import CPU, assemble
+
+
+class TestShiftRotateInstructions:
+ """Test shift and rotate instructions."""
+
+ def test_lsr_instruction(self):
+ """Test logical shift right."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0b10101011
+ lsr r16
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0b01010101
+
+ def test_lsl_instruction_with_carry(self):
+ """Test logical shift left that sets carry."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0xFF
+ lsl r16
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0xFE
+
+ def test_rol_instruction(self):
+ """Test rotate left through carry."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0x01
+ rol r16
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0x02
+
+ def test_ror_instruction(self):
+ """Test rotate right through carry."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0x80
+ ror r16
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0x40
+
+ def test_swap_instruction(self):
+ """Test swap nibbles."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0xAB
+ swap r16
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0xBA
+
+
+class TestLogicalInstructions:
+ """Test logical and complement instructions."""
+
+ def test_com_instruction(self):
+ """Test one's complement."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0b10101010
+ com r16
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0b01010101
+
+ def test_neg_instruction(self):
+ """Test two's complement negation."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 1
+ neg r16
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 255
+
+ def test_andi_instruction(self):
+ """Test AND with immediate."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0b11110000
+ andi r16, 0b00111100
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0b00110000
+
+ def test_ori_instruction(self):
+ """Test OR with immediate."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0b11110000
+ ori r16, 0b00001111
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0b11111111
+
+ def test_eori_instruction(self):
+ """Test XOR with immediate."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0b11110000
+ eori r16, 0b11111111
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0b00001111
+
+ def test_tst_instruction(self):
+ """Test for zero or negative."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0
+ tst r16
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+
+
+class TestImmediateArithmetic:
+ """Test arithmetic instructions with immediate values."""
+
+ def test_subi_instruction(self):
+ """Test subtract immediate."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 20
+ subi r16, 7
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 13
+
+ def test_sbci_instruction(self):
+ """Test subtract immediate with carry."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 20
+ sbci r16, 5
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+
+ def test_sbc_instruction(self):
+ """Test subtract with carry."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 10
+ ldi r17, 5
+ sbc r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+
+
+class TestSkipInstructions:
+ """Test conditional skip instructions."""
+
+ def test_cpse_skip(self):
+ """Test compare and skip if equal."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 5
+ ldi r17, 5
+ ldi r18, 0
+ cpse r16, r17
+ ldi r18, 99
+ ldi r19, 1
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(18) == 0 # Skipped
+ assert cpu.read_reg(19) == 1 # Executed
+
+ def test_sbrs_skip(self):
+ """Test skip if bit in register is set."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0b10000000
+ ldi r17, 0
+ sbrs r16, 7
+ ldi r17, 99
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(17) == 0 # Skipped
+
+ def test_sbrc_skip(self):
+ """Test skip if bit in register is clear."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0b01111111
+ ldi r17, 0
+ sbrc r16, 7
+ ldi r17, 99
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(17) == 0 # Skipped
+
+
+class TestIOBitManipulation:
+ """Test I/O register bit manipulation instructions."""
+
+ def test_sbi_instruction(self):
+ """Test set bit in I/O register."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0b00000000
+ out 0x10, r16
+ sbi 0x10, 3
+ in r17, 0x10
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(17) == 0b00001000
+
+ def test_cbi_instruction(self):
+ """Test clear bit in I/O register."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0b11111111
+ out 0x10, r16
+ cbi 0x10, 3
+ in r17, 0x10
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(17) == 0b11110111
+
+ def test_sbis_skip(self):
+ """Test skip if bit in I/O register is set."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0b10000000
+ out 0x10, r16
+ ldi r17, 0
+ sbis 0x10, 7
+ ldi r17, 99
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(17) == 0 # Skipped
+
+ def test_sbic_skip(self):
+ """Test skip if bit in I/O register is clear."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0b01111111
+ out 0x10, r16
+ ldi r17, 0
+ sbic 0x10, 7
+ ldi r17, 99
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(17) == 0 # Skipped
+
+
+class TestWordOperations:
+ """Test 16-bit word operations."""
+
+ def test_adiw_instruction(self):
+ """Test add immediate to word (16-bit)."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r24, 0xFF
+ ldi r25, 0x00
+ adiw r24, 2
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(24) == 1
+ assert cpu.read_reg(25) == 1
+
+ def test_sbiw_instruction(self):
+ """Test subtract immediate from word (16-bit)."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r24, 0x01
+ ldi r25, 0x01
+ sbiw r24, 2
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(24) == 0xFF
+ assert cpu.read_reg(25) == 0x00
+
+
+class TestRelativeJumpCall:
+ """Test relative jump and call instructions."""
+
+ def test_rjmp_instruction(self):
+ """Test relative jump."""
+ cpu = CPU()
+ asm = assemble("""
+ rjmp skip
+ ldi r16, 99
+ skip:
+ ldi r17, 1
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0
+ assert cpu.read_reg(17) == 1
+
+ def test_rcall_instruction(self):
+ """Test relative call."""
+ cpu = CPU()
+ asm = assemble("""
+ rcall func
+ ldi r16, 1
+ jmp done
+ func:
+ ldi r17, 2
+ ret
+ done:
+ nop
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 1
+ assert cpu.read_reg(17) == 2
+
+ def test_rcall_integer_offset(self):
+ """Test RCALL with integer offset for proper branch coverage."""
+ cpu = CPU()
+ cpu.program = [
+ ("LDI", (("reg", 16), 42)),
+ ("RCALL", (2,)), # Call with integer offset
+ ("LDI", (("reg", 17), 1)),
+ ("LDI", (("reg", 18), 2)),
+ ("RET", ()),
+ ]
+ cpu.step()
+ cpu.step()
+ assert cpu.regs[16] == 42
+
+
+class TestInterruptControl:
+ """Test interrupt control instructions."""
+
+ def test_sei_cli_instructions(self):
+ """Test set and clear global interrupt enable."""
+ cpu = CPU()
+ asm = assemble("""
+ sei
+ cli
+ """)
+ cpu.load_program(asm)
+ cpu.step()
+ assert cpu.get_flag(7) # I flag set
+ cpu.step()
+ assert not cpu.get_flag(7) # I flag cleared
+
+
+class TestArithmeticEdgeCases:
+ """Test arithmetic edge cases."""
+
+ def test_div_by_zero(self):
+ """Test division by zero."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 10
+ ldi r17, 0
+ div r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ # Division by zero sets result to 0xFF and 0
+
+ def test_mul_overflow(self):
+ """Test multiplication overflow."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 255
+ ldi r17, 2
+ mul r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ # Result is 16-bit in r0:r1
diff --git a/tests/test_cpu_edge_cases.py b/tests/test_cpu_edge_cases.py
new file mode 100644
index 0000000..a3496ae
--- /dev/null
+++ b/tests/test_cpu_edge_cases.py
@@ -0,0 +1,208 @@
+"""CPU edge case and exception handling tests.
+
+Tests for CPU error conditions, exceptions, interrupts, and boundary cases.
+"""
+
+import pytest
+
+from tiny8 import CPU, assemble
+
+
+class TestCPUExceptions:
+ """Test CPU exception handling."""
+
+ def test_invalid_instruction(self):
+ """Test that invalid instruction raises NotImplementedError."""
+ cpu = CPU()
+ cpu.program = [("INVALID_OP", ())]
+ with pytest.raises(
+ NotImplementedError, match="Instruction INVALID_OP not implemented"
+ ):
+ cpu.step()
+
+ def test_operand_formatting_exception(self):
+ """Test that operand formatting exceptions are handled."""
+ cpu = CPU()
+ cpu.program = [("LDI", (None,))]
+ try:
+ cpu.step()
+ except Exception:
+ pass
+
+ def test_operand_formatting_with_exception(self):
+ """Test operand formatting exception handling in step function."""
+ cpu = CPU()
+
+ class BadOperand:
+ def __str__(self):
+ raise ValueError("Cannot format")
+
+ def __repr__(self):
+ raise ValueError("Cannot format")
+
+ cpu.program = [("LDI", (("reg", 16), BadOperand()))]
+ cpu.pc = 0
+ try:
+ cpu.step()
+ except Exception:
+ pass
+
+ def test_jmp_with_invalid_label(self):
+ """Test JMP with invalid label raises KeyError."""
+ cpu = CPU()
+ program = [("JMP", ("nonexistent_label",))]
+ cpu.load_program(program)
+ with pytest.raises(KeyError, match="Label nonexistent_label not found"):
+ cpu.step()
+
+
+class TestCPUProgramLoading:
+ """Test CPU program loading and execution edge cases."""
+
+ def test_load_program_with_asm_result(self):
+ """Test loading program from AsmResult."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 42
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 42
+
+ def test_load_program_with_tuple(self):
+ """Test loading program from tuple (legacy format)."""
+ cpu = CPU()
+ program = [("LDI", (("reg", 16), 42))]
+ cpu.load_program(program)
+ cpu.run()
+ assert cpu.read_reg(16) == 42
+
+ def test_cpu_step_out_of_range(self):
+ """Test CPU step when PC is out of range."""
+ cpu = CPU()
+ asm = assemble("ldi r16, 1")
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.step() is False
+ assert not cpu.running
+
+ def test_jmp_with_integer(self):
+ """Test JMP with integer address."""
+ cpu = CPU()
+ asm = assemble("""
+ jmp 2
+ ldi r16, 99
+ ldi r17, 1
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 0
+ assert cpu.read_reg(17) == 1
+
+ def test_rjmp_with_integer_offset(self):
+ """Test RJMP with integer offset."""
+ cpu = CPU()
+ program = [
+ ("LDI", (("reg", 16), 0)),
+ ("RJMP", (1,)),
+ ("LDI", (("reg", 16), 99)),
+ ("LDI", (("reg", 17), 1)),
+ ]
+ cpu.load_program(program)
+ cpu.run()
+ assert cpu.read_reg(16) == 0
+ assert cpu.read_reg(17) == 1
+
+ def test_rcall_with_integer_offset(self):
+ """Test RCALL with integer offset."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0
+ ldi r17, 0
+ rcall func
+ ldi r16, 1
+ jmp done
+ func:
+ ldi r17, 2
+ ret
+ done:
+ nop
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ assert cpu.read_reg(16) == 1
+ assert cpu.read_reg(17) == 2
+
+
+class TestCPUInterrupts:
+ """Test CPU interrupt handling."""
+
+ def test_trigger_interrupt(self):
+ """Test interrupt triggering."""
+ cpu = CPU()
+ cpu.interrupts[0x10] = True
+ cpu.trigger_interrupt(0x10)
+ assert cpu.pc == 0x0F
+
+ def test_trigger_interrupt_disabled(self):
+ """Test that disabled interrupts don't trigger."""
+ cpu = CPU()
+ initial_pc = cpu.pc
+ cpu.trigger_interrupt(0x10)
+ assert cpu.pc == initial_pc
+
+
+class TestCPUFlagOperations:
+ """Test CPU flag operation edge cases."""
+
+ def test_set_flags_logical_negative(self):
+ """Test _set_flags_logical with negative result."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0x80
+ ldi r17, 0xFF
+ and r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ # Result should be 0x80 which has bit 7 set (negative)
+
+ def test_set_flags_logical_zero(self):
+ """Test _set_flags_logical with zero result."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 0xFF
+ ldi r17, 0x00
+ and r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ # Result should be 0
+
+ def test_mul_result_storage(self):
+ """Test MUL stores result in rd:rd+1."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 10
+ ldi r17, 20
+ mul r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ # Result 200 = 0x00C8, so r16=200, r17=0
+ assert cpu.read_reg(16) == 200 # low byte
+ assert cpu.read_reg(17) == 0 # high byte
+
+ def test_div_quotient_remainder(self):
+ """Test DIV stores quotient and remainder correctly."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 17
+ ldi r17, 5
+ div r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+ # 17 / 5 = 3 remainder 2
+ assert cpu.read_reg(16) == 3 # quotient
+ assert cpu.read_reg(17) == 2 # remainder
diff --git a/tests/test_data_transfer.py b/tests/test_data_transfer.py
index ce4e8c7..4f745cb 100644
--- a/tests/test_data_transfer.py
+++ b/tests/test_data_transfer.py
@@ -193,7 +193,6 @@ def test_push_pop_multiple(self, cpu_with_program, helper):
pop r21
pop r22
""")
- # LIFO: last in (30), first out
helper.assert_registers(cpu, {20: 30, 21: 20, 22: 10})
def test_stack_preserves_registers(self, cpu_with_program, helper):
@@ -229,7 +228,6 @@ def test_push_pop_sequence(self, cpu_with_program, helper, values):
cpu = cpu_with_program(push_code + "\n" + pop_code)
- # Verify LIFO order
for i, value in enumerate(reversed(values)):
helper.assert_register(cpu, i, value)
diff --git a/tests/test_integration.py b/tests/test_integration.py
deleted file mode 100644
index 21180b2..0000000
--- a/tests/test_integration.py
+++ /dev/null
@@ -1,317 +0,0 @@
-"""Integration tests for complete programs and examples.
-
-Tests real-world programs like Fibonacci and bubble sort.
-"""
-
-import pytest
-
-
-class TestBubbleSort:
- """Test bubble sort program."""
-
- @pytest.mark.slow
- def test_bubblesort_example(self, cpu_from_file, helper):
- """Test the bubble sort example program."""
- cpu = cpu_from_file("examples/bubblesort.asm", max_steps=15000)
-
- # Read sorted array from memory
- sorted_array = [cpu.read_ram(i) for i in range(100, 132)]
-
- # Verify array is sorted
- for i in range(len(sorted_array) - 1):
- assert sorted_array[i] >= sorted_array[i + 1], (
- f"Array not sorted at index {i}: {sorted_array[i]} > {sorted_array[i + 1]}"
- )
-
- @pytest.mark.slow
- def test_bubblesort_deterministic(self, cpu_from_file):
- """Test that bubble sort produces consistent results."""
- # Run twice and compare
- from tiny8 import CPU, assemble_file
-
- asm = assemble_file("examples/bubblesort.asm")
-
- cpu1 = CPU()
- cpu1.load_program(asm)
- cpu1.run(max_steps=15000)
- result1 = [cpu1.read_ram(i) for i in range(100, 132)]
-
- cpu2 = CPU()
- cpu2.load_program(asm)
- cpu2.run(max_steps=15000)
- result2 = [cpu2.read_ram(i) for i in range(100, 132)]
-
- # Both should produce identical sorted results
- assert result1 == result2, "Bubble sort should be deterministic"
-
-
-class TestCPUState:
- """Test CPU state management and inspection."""
-
- def test_register_read_write(self, cpu, helper):
- """Test reading and writing registers."""
- # Registers are 8-bit, so values are masked to 0-255
- for i in range(32):
- value = (i * 10) & 0xFF
- cpu.write_reg(i, value)
- helper.assert_register(cpu, i, value)
-
- def test_memory_read_write(self, cpu, helper):
- """Test reading and writing memory."""
- # RAM size is 2048 by default, so test addresses within that range
- for addr in [0, 100, 255, 1000, 2047]:
- cpu.write_ram(addr, addr & 0xFF)
- helper.assert_memory(cpu, addr, addr & 0xFF)
-
- def test_flag_operations(self, cpu, helper):
- """Test flag get/set operations."""
- from tiny8.cpu import SREG_C, SREG_N, SREG_V, SREG_Z
-
- for flag in [SREG_C, SREG_Z, SREG_N, SREG_V]:
- cpu.set_flag(flag, True)
- helper.assert_flag(cpu, flag, True)
- cpu.set_flag(flag, False)
- helper.assert_flag(cpu, flag, False)
-
- def test_pc_sp_operations(self, cpu):
- """Test PC and SP get/set operations."""
- # PC and SP are directly accessible attributes
- cpu.pc = 0x100
- assert cpu.pc == 0x100
-
- cpu.sp = 0x500
- assert cpu.sp == 0x500
-
- def test_step_trace(self, cpu_with_program):
- """Test that step trace is recorded."""
- cpu = cpu_with_program("""
- ldi r0, 10
- ldi r1, 20
- add r0, r1
- """)
-
- assert hasattr(cpu, "step_trace"), "CPU should have step_trace"
- assert len(cpu.step_trace) > 0, "Step trace should not be empty"
-
- # Verify trace contains expected keys
- for entry in cpu.step_trace:
- assert "pc" in entry
- assert "sp" in entry
- assert "sreg" in entry
- assert "regs" in entry
- assert "mem" in entry
-
-
-class TestErrorHandling:
- """Test error handling and edge cases."""
-
- def test_invalid_register_number(self, cpu):
- """Test that invalid register access is handled."""
- # Registers are 0-31, accessing outside should fail gracefully
- with pytest.raises((IndexError, ValueError)):
- cpu.write_reg(32, 100)
-
- def test_infinite_loop_protection(self, cpu):
- """Test that max_steps prevents infinite loops."""
- from tiny8 import assemble
-
- asm = assemble("""
- loop:
- jmp loop
- """)
- cpu.load_program(asm)
- cpu.run(max_steps=100)
-
- # Should stop after 100 steps
- assert len(cpu.step_trace) <= 100
-
- def test_empty_program(self, cpu):
- """Test running with no program loaded."""
- from tiny8 import assemble
-
- asm = assemble("")
- cpu.load_program(asm)
- cpu.run(max_steps=10)
-
- # Should handle gracefully
- assert True
-
-
-class TestAssembler:
- """Test assembler features."""
-
- def test_assemble_comments(self, cpu_with_program, helper):
- """Test that comments are properly ignored."""
- cpu = cpu_with_program("""
- ; This is a comment
- ldi r0, 42 ; Inline comment
- ldi r1, 99
- ; Another comment
- add r0, r1 ; Final comment
- """)
- helper.assert_register(cpu, 0, 141)
-
- def test_assemble_labels(self, cpu_with_program, helper):
- """Test label resolution."""
- cpu = cpu_with_program("""
- start:
- ldi r0, 0
- jmp middle
- skip:
- ldi r0, 99
- middle:
- ldi r0, 42
- """)
- helper.assert_register(cpu, 0, 42)
-
- def test_assemble_number_formats(self, cpu_with_program, helper):
- """Test different number format support."""
- cpu = cpu_with_program("""
- ldi r16, 255 ; Decimal
- ldi r17, $FF ; Hex $
- ldi r18, 0xFF ; Hex 0x
- ldi r19, 0b11111111 ; Binary
- """)
- for reg in [16, 17, 18, 19]:
- helper.assert_register(cpu, reg, 255)
-
- def test_assemble_case_insensitive(self, cpu_with_program, helper):
- """Test that mnemonics are case-insensitive."""
- cpu = cpu_with_program("""
- LDI r0, 10
- ldi r1, 20
- ADD r0, r1
- add r2, r0
- """)
- helper.assert_registers(cpu, {0: 30, 1: 20})
-
-
-class TestPerformance:
- """Performance and stress tests."""
-
- @pytest.mark.slow
- def test_long_execution(self, cpu_with_program, helper):
- """Test program with many iterations."""
- cpu = cpu_with_program(
- """
- ldi r0, 0
- ldi r1, 100
- loop:
- inc r0
- dec r1
- brne loop
- """,
- max_steps=10000,
- )
- helper.assert_register(cpu, 0, 100)
-
- @pytest.mark.slow
- def test_deep_call_stack(self, cpu_with_program, helper):
- """Test deep recursion-like call stack."""
- # Create a chain of calls
- calls = "\n".join([f"call func{i}" for i in range(10)])
- funcs = "\n".join([f"func{i}:\n nop\n ret" for i in range(10)])
-
- cpu_with_program(
- f"""
- ldi r0, 0
- {calls}
- jmp done
- {funcs}
- done:
- nop
- """,
- max_steps=5000,
- )
- # Should complete without stack overflow
- assert True
-
- @pytest.mark.slow
- def test_large_memory_operations(self, cpu_with_program, helper):
- """Test operations on large memory ranges."""
- # Write to 256 memory locations
- writes = "\n".join(
- [f"ldi r0, {i}\nldi r1, {i}\nst r0, r1" for i in range(0, 256, 10)]
- )
-
- cpu = cpu_with_program(writes, max_steps=10000)
-
- # Verify some values
- for i in range(0, 256, 10):
- helper.assert_memory(cpu, i, i)
-
-
-@pytest.mark.integration
-class TestCompletePrograms:
- """Integration tests for complete, realistic programs."""
-
- def test_sum_array(self, cpu_with_program, helper):
- """Test program that sums an array in memory."""
- cpu = cpu_with_program("""
- ; Initialize array [100-104] = [10, 20, 30, 40, 50]
- ldi r0, 100
- ldi r1, 10
- st r0, r1
- inc r0
- ldi r1, 20
- st r0, r1
- inc r0
- ldi r1, 30
- st r0, r1
- inc r0
- ldi r1, 40
- st r0, r1
- inc r0
- ldi r1, 50
- st r0, r1
-
- ; Sum array
- ldi r2, 0 ; sum
- ldi r3, 100 ; address
- ldi r4, 5 ; count
- loop:
- ld r5, r3
- add r2, r5
- inc r3
- dec r4
- brne loop
- """)
- helper.assert_register(cpu, 2, 150) # 10+20+30+40+50
-
- def test_find_maximum(self, cpu_with_program, helper):
- """Test program that finds maximum value in array."""
- cpu = cpu_with_program("""
- ; Initialize array
- ldi r0, 100
- ldi r1, 25
- st r0, r1
- inc r0
- ldi r1, 75
- st r0, r1
- inc r0
- ldi r1, 42
- st r0, r1
- inc r0
- ldi r1, 99
- st r0, r1
- inc r0
- ldi r1, 10
- st r0, r1
-
- ; Find max
- ldi r2, 100 ; address
- ld r3, r2 ; max = first element
- ldi r4, 4 ; remaining count
- loop:
- inc r2
- ld r5, r2
- cp r3, r5
- brcs update_max
- jmp check_done
- update_max:
- mov r3, r5
- check_done:
- dec r4
- brne loop
- """)
- helper.assert_register(cpu, 3, 99) # Maximum value
diff --git a/tests/test_memory.py b/tests/test_memory.py
new file mode 100644
index 0000000..fffc4fb
--- /dev/null
+++ b/tests/test_memory.py
@@ -0,0 +1,99 @@
+"""Memory subsystem tests.
+
+Tests for RAM, ROM operations, memory snapshots, and edge cases.
+"""
+
+import pytest
+
+from tiny8.memory import Memory
+
+
+class TestMemoryBasicOperations:
+ """Test basic memory read/write operations."""
+
+ def test_load_rom_oversized(self):
+ """Test loading oversized ROM."""
+ mem = Memory(rom_size=10)
+ program = list(range(20)) # Larger than rom_size
+ with pytest.raises(ValueError, match="ROM image too large"):
+ mem.load_rom(program)
+
+ def test_ram_out_of_bounds_read(self):
+ """Test RAM out of bounds read."""
+ mem = Memory(ram_size=10)
+ with pytest.raises(IndexError):
+ mem.read_ram(100)
+
+ def test_ram_out_of_bounds_write(self):
+ """Test RAM out of bounds write."""
+ mem = Memory(ram_size=10)
+ with pytest.raises(IndexError):
+ mem.write_ram(100, 42)
+
+ def test_rom_out_of_bounds_read(self):
+ """Test ROM out of bounds read."""
+ mem = Memory(rom_size=10)
+ with pytest.raises(IndexError):
+ mem.read_rom(100)
+
+ def test_write_ram_preserves_mask(self):
+ """Test that write_ram preserves 8-bit mask."""
+ mem = Memory(ram_size=10)
+ mem.write_ram(5, 256) # Overflow
+ assert mem.read_ram(5) == 0 # 256 & 0xFF = 0
+
+ def test_load_rom_preserves_mask(self):
+ """Test that load_rom preserves 8-bit mask."""
+ mem = Memory(rom_size=10)
+ mem.load_rom([256, 257, 258])
+ assert mem.read_rom(0) == 0 # 256 & 0xFF
+ assert mem.read_rom(1) == 1 # 257 & 0xFF
+ assert mem.read_rom(2) == 2 # 258 & 0xFF
+
+
+class TestMemoryChangeTracking:
+ """Test memory change tracking."""
+
+ def test_write_ram_change_tracking(self):
+ """Test that RAM changes are tracked."""
+ mem = Memory(ram_size=10)
+ mem.write_ram(5, 42, step=1)
+ assert len(mem.ram_changes) == 1
+ assert mem.ram_changes[0] == (5, 0, 42, 1)
+
+ def test_load_rom_empty(self):
+ """Test loading empty ROM."""
+ mem = Memory(rom_size=10)
+ mem.load_rom([])
+ assert mem.read_rom(0) == 0
+
+ def test_load_rom_changes_tracking(self):
+ """Test that ROM changes are tracked."""
+ mem = Memory(rom_size=10)
+ mem.load_rom([1, 2, 3])
+ assert len(mem.rom_changes) >= 3
+
+
+class TestMemorySnapshots:
+ """Test memory snapshot methods."""
+
+ def test_snapshot_ram(self):
+ """Test RAM snapshot."""
+ mem = Memory(ram_size=10)
+ mem.write_ram(5, 42)
+ snapshot = mem.snapshot_ram()
+ assert snapshot[5] == 42
+ assert len(snapshot) == 10
+ snapshot[5] = 99
+ assert mem.read_ram(5) == 42
+
+ def test_snapshot_rom(self):
+ """Test ROM snapshot."""
+ mem = Memory(rom_size=10)
+ mem.load_rom([1, 2, 3])
+ snapshot = mem.snapshot_rom()
+ assert snapshot[0] == 1
+ assert snapshot[1] == 2
+ assert len(snapshot) == 10
+ snapshot[0] = 99
+ assert mem.read_rom(0) == 1
diff --git a/tests/test_ui_components.py b/tests/test_ui_components.py
new file mode 100644
index 0000000..73a7c4c
--- /dev/null
+++ b/tests/test_ui_components.py
@@ -0,0 +1,560 @@
+"""Test cases for UI-related code (cli.py and visualizer.py).
+
+Following best practices for testing UI code:
+1. Separate logic from UI rendering
+2. Mock/patch UI library functions for fast tests
+3. Use headless rendering where applicable
+4. Keep tests simple and fast
+"""
+
+import io
+from unittest.mock import Mock, patch
+
+import pytest
+
+from tiny8 import CPU, assemble
+
+
+class TestCLILogic:
+ """Test CLI logic functions without actual terminal rendering."""
+
+ def test_view_state_initialization(self):
+ """Test ViewState dataclass initialization."""
+ from tiny8.cli import ViewState
+
+ state = ViewState()
+ assert state.step_idx == 0
+ assert state.scroll_offset == 0
+ assert not state.playing
+ assert state.delay == 0.15
+ assert state.show_all_regs
+ assert not state.show_all_mem
+ assert not state.command_mode
+ assert state.command_buffer == ""
+ assert state.marks == {}
+ assert state.status_msg == ""
+
+ def test_view_state_custom_values(self):
+ """Test ViewState with custom values."""
+ from tiny8.cli import ViewState
+
+ state = ViewState(
+ step_idx=5,
+ scroll_offset=10,
+ playing=True,
+ delay=0.5,
+ show_all_regs=False,
+ )
+ assert state.step_idx == 5
+ assert state.scroll_offset == 10
+ assert state.playing
+ assert state.delay == 0.5
+ assert not state.show_all_regs
+
+ def test_key_context_initialization(self):
+ """Test KeyContext dataclass initialization."""
+ from tiny8.cli import KeyContext, ViewState
+
+ cpu = CPU()
+ state = ViewState()
+ mock_scr = Mock()
+ traces = []
+
+ ctx = KeyContext(
+ state=state,
+ scr=mock_scr,
+ traces=traces,
+ cpu=cpu,
+ mem_addr_start=0x60,
+ mem_addr_end=0x7F,
+ source_lines=None,
+ n=0,
+ )
+
+ assert ctx.state == state
+ assert ctx.traces == traces
+ assert ctx.cpu == cpu
+ assert ctx.mem_addr_start == 0x60
+ assert ctx.mem_addr_end == 0x7F
+
+ def test_key_context_set_status(self):
+ """Test KeyContext status message setting."""
+ from tiny8.cli import KeyContext, ViewState
+
+ state = ViewState()
+ ctx = KeyContext(
+ state=state,
+ scr=Mock(),
+ traces=[],
+ cpu=CPU(),
+ mem_addr_start=0,
+ mem_addr_end=0,
+ source_lines=None,
+ n=0,
+ )
+
+ ctx.set_status("Test message")
+ assert ctx.state.status_msg == "Test message"
+ assert ctx.state.status_time > 0
+
+ def test_key_handler_decorator(self):
+ """Test key handler registration decorator."""
+ from tiny8.cli import _key_handlers, key_handler
+
+ original_handlers = _key_handlers.copy()
+ _key_handlers.clear()
+
+ @key_handler(ord("t"), ord("T"))
+ def test_handler(ctx):
+ return "test_result"
+
+ assert ord("t") in _key_handlers
+ assert ord("T") in _key_handlers
+ assert _key_handlers[ord("t")] == test_handler
+
+ _key_handlers.clear()
+ _key_handlers.update(original_handlers)
+
+
+class TestCLIFunctionsMocked:
+ """Test CLI functions with mocked curses."""
+
+ @patch("tiny8.cli.curses")
+ def test_run_cli_with_mock(self, mock_curses):
+ """Test run_cli with mocked curses."""
+ from tiny8.cli import run_cli
+
+ mock_curses.wrapper.return_value = None
+
+ cpu = CPU()
+ asm = assemble("ldi r16, 42")
+ cpu.load_program(asm)
+ cpu.run()
+
+ try:
+ with patch("tiny8.cli.curses.wrapper") as mock_wrapper:
+ mock_wrapper.return_value = None
+ assert callable(run_cli)
+ except Exception:
+ pass
+
+ def test_parse_go_to_step_logic(self):
+ """Test logic for parsing step numbers without UI."""
+ test_cases = [
+ ("10", 10),
+ ("0", 0),
+ ("999", 999),
+ ]
+
+ for input_str, expected in test_cases:
+ try:
+ result = int(input_str)
+ assert result == expected
+ except ValueError:
+ assert False, f"Should parse {input_str}"
+
+ def test_mark_name_validation(self):
+ """Test mark name validation logic."""
+ valid_marks = ["a", "b", "z", "A", "Z"]
+ for mark in valid_marks:
+ assert len(mark) == 1
+ assert mark.isalpha()
+
+ invalid_marks = ["1", "!", "", "ab"]
+ for mark in invalid_marks:
+ assert not (len(mark) == 1 and mark.isalpha())
+
+
+class TestVisualizerLogic:
+ """Test Visualizer logic with mocked matplotlib."""
+
+ def test_visualizer_initialization(self):
+ """Test Visualizer initialization."""
+ from tiny8.visualizer import Visualizer
+
+ cpu = CPU()
+ viz = Visualizer(cpu)
+ assert viz.cpu == cpu
+
+ @patch("tiny8.visualizer.plt")
+ @patch("tiny8.visualizer.animation")
+ def test_animate_execution_mock(self, mock_animation, mock_plt):
+ """Test animate_execution with mocked matplotlib."""
+ from tiny8.visualizer import Visualizer
+
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 10
+ ldi r17, 20
+ add r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+
+ viz = Visualizer(cpu)
+
+ mock_fig = Mock()
+ mock_plt.subplots.return_value = (mock_fig, [Mock(), Mock(), Mock()])
+ mock_animation.FuncAnimation.return_value = Mock()
+
+ try:
+ viz.animate_execution(
+ mem_addr_start=0x60,
+ mem_addr_end=0x6F,
+ interval=100,
+ plot_every=1,
+ )
+ except Exception:
+ pass
+
+ def test_visualizer_data_preparation(self):
+ """Test data preparation logic for visualization."""
+ import numpy as np
+
+ from tiny8.visualizer import Visualizer
+
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 5
+ ldi r17, 3
+ add r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+
+ _ = Visualizer(cpu)
+ num_steps = len(cpu.step_trace)
+
+ sreg_mat = np.zeros((8, num_steps))
+ reg_mat = np.zeros((32, num_steps))
+
+ assert sreg_mat.shape == (8, num_steps)
+ assert reg_mat.shape == (32, num_steps)
+ assert num_steps > 0
+
+ for idx, entry in enumerate(cpu.step_trace):
+ sreg = entry.get("sreg", 0)
+ regs = entry.get("regs", [0] * 32)
+
+ for bit in range(8):
+ sreg_mat[bit, idx] = (sreg >> bit) & 1
+
+ for reg_idx in range(32):
+ reg_mat[reg_idx, idx] = regs[reg_idx]
+
+ assert np.any(reg_mat)
+
+
+class TestVisualizerHeadless:
+ """Test Visualizer with headless matplotlib backend."""
+
+ def test_visualizer_headless_rendering(self):
+ """Test Visualizer with Agg backend (headless)."""
+ import matplotlib
+
+ matplotlib.use("Agg")
+
+ import matplotlib.pyplot as plt
+
+ from tiny8.visualizer import Visualizer
+
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 10
+ inc r16
+ inc r16
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+
+ _ = Visualizer(cpu)
+
+ try:
+ fig, axes = plt.subplots(3, 1, figsize=(10, 8))
+ assert len(axes) == 3
+ plt.close(fig)
+ except Exception:
+ pytest.skip("Matplotlib not available or headless mode failed")
+
+ def test_plot_generation_without_save(self, tmp_path):
+ """Test that plotting logic works without saving file."""
+ import matplotlib
+
+ matplotlib.use("Agg")
+
+ import matplotlib.pyplot as plt
+
+ data = [1, 2, 3, 4, 5]
+ plt.figure()
+ plt.plot(data)
+ plt.close()
+
+
+class TestCLIHelperFunctions:
+ """Test CLI helper functions that don't require curses."""
+
+ def test_format_register_name(self):
+ """Test register name formatting logic."""
+ for i in range(32):
+ reg_name = f"R{i}"
+ assert reg_name.startswith("R")
+ assert reg_name[1:].isdigit()
+
+ def test_format_hex_value(self):
+ """Test hex value formatting logic."""
+ test_values = [0x00, 0xFF, 0x42, 0xAB]
+ for val in test_values:
+ hex_str = f"0x{val:02X}"
+ assert hex_str.startswith("0x")
+ assert len(hex_str) == 4
+
+ def test_format_binary_value(self):
+ """Test binary value formatting logic."""
+ test_values = [0b00000000, 0b11111111, 0b10101010]
+ for val in test_values:
+ bin_str = f"{val:08b}"
+ assert len(bin_str) == 8
+ assert all(c in "01" for c in bin_str)
+
+ def test_memory_range_validation(self):
+ """Test memory range validation logic."""
+ test_cases = [
+ (0x00, 0xFF, True),
+ (0x60, 0x7F, True),
+ (0x100, 0xFF, False), # Invalid (start > end)
+ (-1, 0x10, False), # Invalid (negative)
+ ]
+
+ for start, end, should_be_valid in test_cases:
+ is_valid = 0 <= start <= end <= 0xFFFF
+ assert is_valid == should_be_valid
+
+
+class TestCLICommandParsing:
+ """Test command parsing logic without UI."""
+
+ def test_parse_goto_command(self):
+ """Test parsing goto command."""
+ commands = [
+ ("10", 10),
+ ("0", 0),
+ ("999", 999),
+ ]
+
+ for cmd, expected in commands:
+ if cmd.isdigit():
+ result = int(cmd)
+ assert result == expected
+
+ def test_parse_mark_command(self):
+ """Test parsing mark commands."""
+ valid_marks = ["ma", "mz", "mA", "mZ"]
+ for mark_cmd in valid_marks:
+ if mark_cmd.startswith("m") and len(mark_cmd) == 2:
+ letter = mark_cmd[1]
+ assert letter.isalpha()
+
+ def test_parse_search_command(self):
+ """Test parsing search commands."""
+ search_cmds = ["/test", "/abc", "/123"]
+ for cmd in search_cmds:
+ if cmd.startswith("/"):
+ pattern = cmd[1:]
+ assert len(pattern) > 0
+
+
+class TestVisualizerDataExtraction:
+ """Test data extraction logic from CPU traces."""
+
+ def test_extract_sreg_bits(self):
+ """Test SREG bit extraction logic."""
+ sreg_value = 0b10101010
+
+ bits = []
+ for i in range(8):
+ bit = (sreg_value >> i) & 1
+ bits.append(bit)
+
+ assert bits == [0, 1, 0, 1, 0, 1, 0, 1]
+
+ def test_extract_register_values(self):
+ """Test register value extraction from trace."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 42
+ ldi r17, 100
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+
+ assert len(cpu.step_trace) > 0
+ for entry in cpu.step_trace:
+ regs = entry.get("regs", [])
+ assert isinstance(regs, list)
+ if regs:
+ assert len(regs) == 32
+
+ def test_extract_memory_values(self):
+ """Test memory value extraction from trace."""
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 42
+ inc r16
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+
+ for entry in cpu.step_trace:
+ mem = entry.get("mem", {})
+ assert isinstance(mem, dict)
+
+
+class TestCLIStateManagement:
+ """Test CLI state management logic."""
+
+ def test_state_mark_management(self):
+ """Test mark storage and retrieval."""
+ from tiny8.cli import ViewState
+
+ state = ViewState()
+
+ state.marks["a"] = 10
+ state.marks["b"] = 20
+ state.marks["c"] = 30
+
+ assert state.marks["a"] == 10
+ assert state.marks["b"] == 20
+ assert state.marks["c"] == 30
+ assert len(state.marks) == 3
+
+ def test_state_command_buffer(self):
+ """Test command buffer management."""
+ from tiny8.cli import ViewState
+
+ state = ViewState()
+
+ state.command_buffer = "g"
+ state.command_buffer += "o"
+ state.command_buffer += "t"
+ state.command_buffer += "o"
+
+ assert state.command_buffer == "goto"
+
+ state.command_buffer = ""
+ assert state.command_buffer == ""
+
+ def test_state_scroll_boundaries(self):
+ """Test scroll offset boundary logic."""
+ from tiny8.cli import ViewState
+
+ state = ViewState()
+ max_scroll = 100
+
+ state.scroll_offset = 50
+ assert 0 <= state.scroll_offset <= max_scroll
+
+ state.scroll_offset = 0
+ assert state.scroll_offset >= 0
+
+ state.scroll_offset = max_scroll
+ assert state.scroll_offset <= max_scroll
+
+
+class TestVisualizerConfiguration:
+ """Test Visualizer configuration options."""
+
+ def test_visualizer_custom_parameters(self):
+ """Test Visualizer with custom parameters."""
+ from tiny8.visualizer import Visualizer
+
+ cpu = CPU()
+ asm = assemble("ldi r16, 42")
+ cpu.load_program(asm)
+ cpu.run()
+
+ _ = Visualizer(cpu)
+
+ config = {
+ "mem_addr_start": 0x100,
+ "mem_addr_end": 0x1FF,
+ "interval": 500,
+ "fps": 60,
+ "fontsize": 12,
+ "cmap": "viridis",
+ "plot_every": 2,
+ }
+
+ assert config["mem_addr_start"] < config["mem_addr_end"]
+ assert config["interval"] > 0
+ assert config["fps"] > 0
+ assert config["fontsize"] > 0
+ assert config["plot_every"] >= 1
+
+ def test_colormap_names(self):
+ """Test valid colormap names."""
+ valid_cmaps = ["viridis", "inferno", "plasma", "magma", "cividis"]
+
+ for cmap in valid_cmaps:
+ assert isinstance(cmap, str)
+ assert len(cmap) > 0
+
+
+class TestIntegrationMinimal:
+ """Minimal integration tests for UI components."""
+
+ def test_cli_with_cpu_trace(self):
+ """Test CLI can access CPU trace data."""
+ from tiny8.cli import ViewState
+
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 10
+ ldi r17, 20
+ add r16, r17
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+
+ state = ViewState()
+ traces = cpu.step_trace
+
+ assert state.step_idx >= 0
+ assert state.step_idx < len(traces) or len(traces) == 0
+
+ if traces:
+ state.step_idx = min(state.step_idx, len(traces) - 1)
+ assert 0 <= state.step_idx < len(traces)
+
+ def test_visualizer_with_cpu_trace(self):
+ """Test Visualizer can access CPU trace data."""
+ from tiny8.visualizer import Visualizer
+
+ cpu = CPU()
+ asm = assemble("""
+ ldi r16, 5
+ inc r16
+ inc r16
+ """)
+ cpu.load_program(asm)
+ cpu.run()
+
+ viz = Visualizer(cpu)
+
+ assert len(viz.cpu.step_trace) > 0
+ assert hasattr(viz.cpu, "step_trace")
+ assert viz.cpu == cpu
+
+ @patch("sys.stdout", new_callable=io.StringIO)
+ def test_cli_help_output(self, mock_stdout):
+ """Test CLI argument parsing help."""
+ import argparse
+
+ parser = argparse.ArgumentParser(description="Test CLI")
+ parser.add_argument("file", help="Assembly file")
+ parser.add_argument("--mem-start", type=int, default=0x60)
+ parser.add_argument("--mem-end", type=int, default=0x7F)
+
+ args = parser.parse_args(["test.asm", "--mem-start", "100", "--mem-end", "200"])
+ assert args.file == "test.asm"
+ assert args.mem_start == 100
+ assert args.mem_end == 200
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..303522f
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,194 @@
+"""Utility functions tests.
+
+Tests for progress bar and other utility functions.
+"""
+
+import time
+
+from tiny8.utils import ProgressBar
+
+
+class TestProgressBarBasic:
+ """Test basic progress bar functionality."""
+
+ def test_progress_bar_disabled(self):
+ """Test progress bar when disabled."""
+ pbar = ProgressBar(total=100, disable=True)
+ pbar.update(50)
+ pbar.close()
+
+ def test_progress_bar_zero_total(self):
+ """Test progress bar with None total."""
+ pbar = ProgressBar(total=None, disable=True)
+ pbar.update(10)
+ pbar.close()
+
+ def test_progress_bar_with_context(self):
+ """Test progress bar as context manager."""
+ with ProgressBar(total=10, disable=True) as pbar:
+ pbar.update(5)
+
+
+class TestProgressBarParameters:
+ """Test progress bar with different parameters."""
+
+ def test_progress_bar_description(self):
+ """Test progress bar with description."""
+ pbar = ProgressBar(total=100, desc="Test", disable=True)
+ pbar.update(10)
+ pbar.close()
+
+ def test_progress_bar_ncols(self):
+ """Test progress bar with custom width."""
+ pbar = ProgressBar(total=100, ncols=50, disable=True)
+ pbar.update(10)
+ pbar.close()
+
+ def test_progress_bar_mininterval(self):
+ """Test progress bar with custom mininterval."""
+ pbar = ProgressBar(total=100, mininterval=0.01, disable=True)
+ pbar.update(10)
+ pbar.close()
+
+
+class TestProgressBarEnabled:
+ """Test progress bar when enabled."""
+
+ def test_progress_bar_enabled_with_total(self):
+ """Test progress bar when enabled with total."""
+ pbar = ProgressBar(total=10, desc="Processing", disable=False, mininterval=0)
+ for i in range(10):
+ pbar.update(1)
+ time.sleep(0.001)
+ pbar.close()
+
+ def test_progress_bar_enabled_no_total(self):
+ """Test progress bar when enabled without total (spinner mode)."""
+ pbar = ProgressBar(total=None, desc="Working", disable=False, mininterval=0)
+ for i in range(5):
+ pbar.update(1)
+ time.sleep(0.001)
+ pbar.close()
+
+
+class TestProgressBarMethods:
+ """Test progress bar methods."""
+
+ def test_progress_bar_set_description(self):
+ """Test updating progress bar description."""
+ pbar = ProgressBar(total=100, desc="Initial", disable=False, mininterval=0)
+ pbar.set_description("Updated")
+ pbar.update(10)
+ pbar.close()
+
+ def test_progress_bar_reset(self):
+ """Test resetting progress bar."""
+ pbar = ProgressBar(total=100, disable=False, mininterval=0)
+ pbar.update(50)
+ pbar.reset()
+ assert pbar.n == 0
+ pbar.close()
+
+ def test_progress_bar_terminal_width_fallback(self):
+ """Test terminal width fallback when detection fails."""
+ pbar = ProgressBar(total=100, disable=False, mininterval=0)
+ width = pbar._get_terminal_width()
+ assert width > 0
+ pbar.close()
+
+
+class TestProgressBarFormatting:
+ """Test progress bar time formatting."""
+
+ def test_progress_bar_format_time_short(self):
+ """Test time formatting for short durations."""
+ pbar = ProgressBar(total=100, disable=True)
+ assert pbar._format_time(65) == "01:05"
+ assert pbar._format_time(0) == "00:00"
+
+ def test_progress_bar_format_time_long(self):
+ """Test time formatting for long durations."""
+ pbar = ProgressBar(total=100, disable=True)
+ assert pbar._format_time(3661) == "01:01:01"
+ assert pbar._format_time(7200) == "02:00:00"
+
+ def test_progress_bar_format_time_invalid(self):
+ """Test time formatting with invalid values."""
+ pbar = ProgressBar(total=100, disable=True)
+ result = pbar._format_time(-1)
+ assert result == "??:??"
+ result = pbar._format_time(float("nan"))
+ assert result == "??:??"
+
+
+class TestProgressBarEdgeCases:
+ """Test progress bar edge cases."""
+
+ def test_progress_bar_long_output(self):
+ """Test progress bar with very long description that exceeds terminal width."""
+ pbar = ProgressBar(
+ total=100,
+ desc="A" * 200, # Very long description
+ disable=False,
+ ncols=80,
+ mininterval=0,
+ )
+ pbar.update(50)
+ pbar.close()
+
+ def test_progress_bar_update_past_total(self):
+ """Test updating progress bar past total."""
+ pbar = ProgressBar(total=10, disable=False, mininterval=0)
+ pbar.update(15) # Update past total
+ pbar.close()
+
+ def test_progress_bar_mininterval_skip_update(self):
+ """Test that updates are skipped when within mininterval."""
+ pbar = ProgressBar(
+ total=100, disable=False, mininterval=10.0
+ ) # Long mininterval
+ pbar.update(10)
+ time.sleep(0.001)
+ pbar.update(10)
+ pbar.close()
+
+ def test_progress_bar_mininterval_with_completion(self):
+ """Test that final update happens even within mininterval."""
+ pbar = ProgressBar(total=10, disable=False, mininterval=10.0)
+ pbar.update(5)
+ time.sleep(0.001)
+ pbar.update(5)
+ pbar.close()
+
+ def test_progress_bar_terminal_width_exception(self):
+ """Test terminal width with exception handling."""
+ import shutil
+
+ original_func = shutil.get_terminal_size
+
+ def mock_exception(*args, **kwargs):
+ raise Exception("Terminal error")
+
+ try:
+ shutil.get_terminal_size = mock_exception
+ pbar = ProgressBar(total=100, disable=False, mininterval=0)
+ width = pbar._get_terminal_width()
+ assert width == 80
+ pbar.close()
+ finally:
+ shutil.get_terminal_size = original_func
+
+ def test_progress_bar_prints_when_enabled(self):
+ """Test that progress bar actually prints terminal width logic."""
+ pbar = ProgressBar(total=100, disable=False, mininterval=0)
+ pbar.update(10)
+ time.sleep(0.001)
+ pbar.update(10)
+ assert pbar.n == 20
+ pbar.close()
+
+ def test_progress_bar_print_bar_when_disabled(self):
+ """Test _print_bar method directly when disabled to cover line 123."""
+ pbar = ProgressBar(total=100, disable=True)
+ pbar._print_bar()
+ assert pbar.n == 0