op_verify now performs actual checksum validation against the header instead of raising NotImplemented. ZLexer is injected into ZCpu and sread tokenizes input into the parse buffer per the V3 spec.
667 lines
22 KiB
Python
667 lines
22 KiB
Python
"""
|
|
Unit tests for the Z-machine opcodes and object parser.
|
|
|
|
These tests verify the basic behavior of each opcode by mocking the
|
|
required dependencies (memory, stack, decoder, etc).
|
|
"""
|
|
|
|
from unittest import TestCase
|
|
from unittest.mock import Mock
|
|
|
|
from mudlib.zmachine.zcpu import ZCpu, ZCpuDivideByZero, ZCpuQuit, ZCpuRestart
|
|
|
|
|
|
class MockMemory:
|
|
"""Mock memory for testing."""
|
|
|
|
def __init__(self):
|
|
self.data = bytearray(65536)
|
|
self.version = 3
|
|
self.globals = {}
|
|
|
|
def __getitem__(self, addr):
|
|
return self.data[addr]
|
|
|
|
def __setitem__(self, addr, value):
|
|
self.data[addr] = value & 0xFF
|
|
|
|
def read_word(self, addr):
|
|
return (self.data[addr] << 8) | self.data[addr + 1]
|
|
|
|
def write_word(self, addr, value):
|
|
self.data[addr] = (value >> 8) & 0xFF
|
|
self.data[addr + 1] = value & 0xFF
|
|
|
|
def read_global(self, varnum):
|
|
return self.globals.get(varnum, 0)
|
|
|
|
def write_global(self, varnum, value):
|
|
self.globals[varnum] = value
|
|
|
|
|
|
class MockStackManager:
|
|
"""Mock stack manager for testing."""
|
|
|
|
def __init__(self):
|
|
self.stack = []
|
|
self.locals = [0] * 15
|
|
|
|
def push_stack(self, value):
|
|
self.stack.append(value)
|
|
|
|
def pop_stack(self):
|
|
return self.stack.pop()
|
|
|
|
def get_local_variable(self, index):
|
|
return self.locals[index]
|
|
|
|
def set_local_variable(self, index, value):
|
|
self.locals[index] = value
|
|
|
|
def finish_routine(self, return_value):
|
|
# Mock implementation - just return a PC value
|
|
return 0x1000
|
|
|
|
|
|
class MockOpDecoder:
|
|
"""Mock opcode decoder for testing."""
|
|
|
|
def __init__(self):
|
|
self.program_counter = 0x800
|
|
self.store_address = None
|
|
self.branch_condition = True
|
|
self.branch_offset = 2
|
|
|
|
def get_store_address(self):
|
|
return self.store_address
|
|
|
|
def get_branch_offset(self):
|
|
return (self.branch_condition, self.branch_offset)
|
|
|
|
|
|
class MockUI:
|
|
"""Mock UI for testing."""
|
|
|
|
def __init__(self):
|
|
self.screen = Mock()
|
|
self.screen.write = Mock()
|
|
self.keyboard = Mock()
|
|
|
|
|
|
class ZMachineOpcodeTests(TestCase):
|
|
"""Test suite for Z-machine opcodes."""
|
|
|
|
def setUp(self):
|
|
"""Create a minimal CPU for testing."""
|
|
self.memory = MockMemory()
|
|
self.stack = MockStackManager()
|
|
self.decoder = MockOpDecoder()
|
|
self.ui = MockUI()
|
|
|
|
# Create CPU with mocked dependencies
|
|
self.cpu = ZCpu(
|
|
self.memory,
|
|
self.decoder,
|
|
self.stack,
|
|
Mock(), # objects
|
|
Mock(), # string
|
|
Mock(), # stream manager
|
|
self.ui,
|
|
Mock(), # lexer
|
|
)
|
|
|
|
def test_op_nop(self):
|
|
"""Test NOP does nothing."""
|
|
# Should just return without error
|
|
self.cpu.op_nop()
|
|
|
|
def test_op_new_line(self):
|
|
"""Test new_line prints a newline."""
|
|
self.cpu.op_new_line()
|
|
self.ui.screen.write.assert_called_once_with("\n")
|
|
|
|
def test_op_ret_popped(self):
|
|
"""Test ret_popped pops stack and returns."""
|
|
self.stack.push_stack(42)
|
|
self.cpu.op_ret_popped()
|
|
# Should have popped the value and set PC
|
|
self.assertEqual(len(self.stack.stack), 0)
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, 0x1000)
|
|
|
|
def test_op_pop(self):
|
|
"""Test pop discards top of stack."""
|
|
self.stack.push_stack(100)
|
|
self.stack.push_stack(200)
|
|
self.cpu.op_pop()
|
|
self.assertEqual(len(self.stack.stack), 1)
|
|
self.assertEqual(self.stack.stack[0], 100)
|
|
|
|
def test_op_quit(self):
|
|
"""Test quit raises exception."""
|
|
with self.assertRaises(ZCpuQuit):
|
|
self.cpu.op_quit()
|
|
|
|
def test_op_dec(self):
|
|
"""Test decrement variable."""
|
|
# Set local variable 1 to 10
|
|
self.stack.set_local_variable(0, 10)
|
|
# Decrement it (variable 1 = local 0)
|
|
self.cpu.op_dec(1)
|
|
# Should be 9 now
|
|
self.assertEqual(self.stack.get_local_variable(0), 9)
|
|
|
|
def test_op_dec_wrapping(self):
|
|
"""Test decrement wraps at zero."""
|
|
# Set local variable 1 to 0
|
|
self.stack.set_local_variable(0, 0)
|
|
# Decrement it
|
|
self.cpu.op_dec(1)
|
|
# Should wrap to 65535
|
|
self.assertEqual(self.stack.get_local_variable(0), 65535)
|
|
|
|
def test_op_not(self):
|
|
"""Test bitwise NOT."""
|
|
self.decoder.store_address = 0 # Store to stack
|
|
self.cpu.op_not(0x00FF)
|
|
result = self.stack.pop_stack()
|
|
self.assertEqual(result, 0xFF00)
|
|
|
|
def test_op_not_all_ones(self):
|
|
"""Test NOT of all ones gives zero."""
|
|
self.decoder.store_address = 0
|
|
self.cpu.op_not(0xFFFF)
|
|
result = self.stack.pop_stack()
|
|
self.assertEqual(result, 0)
|
|
|
|
def test_op_load(self):
|
|
"""Test load reads variable."""
|
|
# Set local variable 2 to 42
|
|
self.stack.set_local_variable(1, 42)
|
|
self.decoder.store_address = 0 # Store to stack
|
|
# Load variable 2
|
|
self.cpu.op_load(2)
|
|
result = self.stack.pop_stack()
|
|
self.assertEqual(result, 42)
|
|
|
|
def test_op_mod_positive(self):
|
|
"""Test modulo with positive numbers."""
|
|
self.decoder.store_address = 0
|
|
self.cpu.op_mod(17, 5)
|
|
result = self.stack.pop_stack()
|
|
self.assertEqual(result, 2)
|
|
|
|
def test_op_mod_negative_dividend(self):
|
|
"""Test modulo with negative dividend."""
|
|
self.decoder.store_address = 0
|
|
# -17 mod 5 = -2 (z-machine uses C-style truncation toward zero)
|
|
self.cpu.op_mod(self.cpu._unmake_signed(-17), 5)
|
|
result = self.cpu._make_signed(self.stack.pop_stack())
|
|
self.assertEqual(result, -2)
|
|
|
|
def test_op_mod_divide_by_zero(self):
|
|
"""Test modulo by zero raises exception."""
|
|
self.decoder.store_address = 0
|
|
with self.assertRaises(ZCpuDivideByZero):
|
|
self.cpu.op_mod(10, 0)
|
|
|
|
def test_op_storeb(self):
|
|
"""Test store byte to memory."""
|
|
self.cpu.op_storeb(0x1000, 5, 0x42)
|
|
self.assertEqual(self.memory[0x1005], 0x42)
|
|
|
|
def test_op_storeb_truncates(self):
|
|
"""Test store byte truncates to 8 bits."""
|
|
self.cpu.op_storeb(0x2000, 10, 0x1FF)
|
|
self.assertEqual(self.memory[0x200A], 0xFF)
|
|
|
|
def test_op_jg_true(self):
|
|
"""Test jump if greater (signed) - true case."""
|
|
self.decoder.branch_condition = True
|
|
self.decoder.branch_offset = 100
|
|
old_pc = self.cpu._opdecoder.program_counter
|
|
self.cpu.op_jg(10, 5)
|
|
# Should have branched (offset - 2)
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 98)
|
|
|
|
def test_op_jg_false(self):
|
|
"""Test jump if greater (signed) - false case."""
|
|
self.decoder.branch_condition = True # Branch if true (but test is false)
|
|
self.decoder.branch_offset = 100
|
|
old_pc = self.cpu._opdecoder.program_counter
|
|
self.cpu.op_jg(5, 10)
|
|
# Should not have branched (test is false, condition is true)
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
|
|
|
|
def test_op_jg_signed(self):
|
|
"""Test jump if greater handles signed comparison."""
|
|
# -1 (as unsigned 65535) should NOT be greater than 1
|
|
self.decoder.branch_condition = False
|
|
old_pc = self.cpu._opdecoder.program_counter
|
|
self.cpu.op_jg(65535, 1)
|
|
# Should not branch (false condition matches)
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
|
|
|
|
def test_op_pull(self):
|
|
"""Test pull from stack to variable."""
|
|
# Push value onto stack
|
|
self.stack.push_stack(123)
|
|
# Pull into local variable 1
|
|
self.cpu.op_pull(1)
|
|
# Should have stored in local variable 0 (variable 1)
|
|
self.assertEqual(self.stack.get_local_variable(0), 123)
|
|
# Stack should be empty
|
|
self.assertEqual(len(self.stack.stack), 0)
|
|
|
|
def test_op_print_addr(self):
|
|
"""Test print_addr decodes and prints text at byte address."""
|
|
# Configure mock string decoder to return a known string
|
|
self.cpu._string.get = Mock(return_value="Hello, world!")
|
|
# Print text at address 0x5000
|
|
self.cpu.op_print_addr(0x5000)
|
|
# Should have called string decoder with the address
|
|
self.cpu._string.get.assert_called_once_with(0x5000)
|
|
# Should have written the decoded text
|
|
self.ui.screen.write.assert_called_once_with("Hello, world!")
|
|
|
|
def test_op_print_num_positive(self):
|
|
"""Test print_num prints positive number."""
|
|
self.cpu.op_print_num(42)
|
|
self.ui.screen.write.assert_called_once_with("42")
|
|
|
|
def test_op_print_num_negative(self):
|
|
"""Test print_num prints negative number."""
|
|
# -1 as unsigned 16-bit is 65535
|
|
self.cpu.op_print_num(65535)
|
|
self.ui.screen.write.assert_called_once_with("-1")
|
|
|
|
def test_op_print_num_zero(self):
|
|
"""Test print_num prints zero."""
|
|
self.cpu.op_print_num(0)
|
|
self.ui.screen.write.assert_called_once_with("0")
|
|
|
|
def test_op_ret(self):
|
|
"""Test ret returns from routine with value."""
|
|
self.cpu.op_ret(42)
|
|
# Should have set PC to caller's address (0x1000 from mock)
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, 0x1000)
|
|
|
|
def test_op_show_status(self):
|
|
"""Test show_status is a no-op (V3 only, not needed in MUD)."""
|
|
# Should just not raise an exception
|
|
self.cpu.op_show_status()
|
|
|
|
|
|
class MockObjectParser:
|
|
"""Mock object parser for testing CPU opcodes."""
|
|
|
|
def __init__(self):
|
|
self.attributes = {}
|
|
self.parents = {}
|
|
self.siblings = {}
|
|
self.children = {}
|
|
self.shortnames = {}
|
|
self.property_data_addresses = {}
|
|
self.next_properties = {}
|
|
|
|
def get_attribute(self, objnum, attrnum):
|
|
return self.attributes.get((objnum, attrnum), 0)
|
|
|
|
def set_attribute(self, objnum, attrnum):
|
|
self.attributes[(objnum, attrnum)] = 1
|
|
|
|
def clear_attribute(self, objnum, attrnum):
|
|
self.attributes[(objnum, attrnum)] = 0
|
|
|
|
def get_parent(self, objnum):
|
|
return self.parents.get(objnum, 0)
|
|
|
|
def get_sibling(self, objnum):
|
|
return self.siblings.get(objnum, 0)
|
|
|
|
def remove_object(self, objnum):
|
|
# Simple implementation - just clear parent
|
|
self.parents[objnum] = 0
|
|
|
|
def get_shortname(self, objnum):
|
|
return self.shortnames.get(objnum, "object")
|
|
|
|
def get_property_data_address(self, objnum, propnum):
|
|
return self.property_data_addresses.get((objnum, propnum), 0)
|
|
|
|
def get_next_property(self, objnum, propnum):
|
|
return self.next_properties.get((objnum, propnum), 0)
|
|
|
|
def get_property_length(self, data_address):
|
|
# Simple mock - return 2 for non-zero addresses
|
|
return 2 if data_address != 0 else 0
|
|
|
|
|
|
class ZMachineObjectOpcodeTests(TestCase):
|
|
"""Test suite for Z-machine object tree opcodes."""
|
|
|
|
def setUp(self):
|
|
"""Create a CPU with mocked object parser."""
|
|
self.memory = MockMemory()
|
|
self.stack = MockStackManager()
|
|
self.decoder = MockOpDecoder()
|
|
self.ui = MockUI()
|
|
self.objects = MockObjectParser()
|
|
self.string = Mock()
|
|
self.string.get = Mock(return_value="test object")
|
|
|
|
self.cpu = ZCpu(
|
|
self.memory,
|
|
self.decoder,
|
|
self.stack,
|
|
self.objects,
|
|
self.string,
|
|
Mock(), # stream manager
|
|
self.ui,
|
|
Mock(), # lexer
|
|
)
|
|
|
|
def test_op_get_sibling_with_sibling(self):
|
|
"""Test get_sibling stores sibling and branches if nonzero."""
|
|
self.objects.siblings[5] = 7
|
|
self.decoder.store_address = 0
|
|
self.decoder.branch_condition = True
|
|
self.decoder.branch_offset = 10
|
|
old_pc = self.cpu._opdecoder.program_counter
|
|
|
|
self.cpu.op_get_sibling(5)
|
|
|
|
# Should store 7
|
|
self.assertEqual(self.stack.pop_stack(), 7)
|
|
# Should branch (offset - 2)
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 8)
|
|
|
|
def test_op_get_sibling_no_sibling(self):
|
|
"""Test get_sibling with no sibling doesn't branch."""
|
|
self.objects.siblings[5] = 0
|
|
self.decoder.store_address = 0
|
|
self.decoder.branch_condition = True
|
|
old_pc = self.cpu._opdecoder.program_counter
|
|
|
|
self.cpu.op_get_sibling(5)
|
|
|
|
# Should store 0
|
|
self.assertEqual(self.stack.pop_stack(), 0)
|
|
# Should not branch
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
|
|
|
|
def test_op_test_attr_true(self):
|
|
"""Test test_attr branches when attribute is set."""
|
|
self.objects.attributes[(10, 5)] = 1
|
|
self.decoder.branch_condition = True
|
|
self.decoder.branch_offset = 20
|
|
old_pc = self.cpu._opdecoder.program_counter
|
|
|
|
self.cpu.op_test_attr(10, 5)
|
|
|
|
# Should branch
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 18)
|
|
|
|
def test_op_test_attr_false(self):
|
|
"""Test test_attr doesn't branch when attribute is clear."""
|
|
self.objects.attributes[(10, 5)] = 0
|
|
self.decoder.branch_condition = True
|
|
old_pc = self.cpu._opdecoder.program_counter
|
|
|
|
self.cpu.op_test_attr(10, 5)
|
|
|
|
# Should not branch
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
|
|
|
|
def test_op_set_attr(self):
|
|
"""Test set_attr sets attribute on object."""
|
|
self.cpu.op_set_attr(15, 3)
|
|
# Should have called set_attribute
|
|
self.assertEqual(self.objects.attributes.get((15, 3)), 1)
|
|
|
|
def test_op_clear_attr(self):
|
|
"""Test clear_attr clears attribute on object."""
|
|
self.objects.attributes[(15, 3)] = 1
|
|
self.cpu.op_clear_attr(15, 3)
|
|
# Should have called clear_attribute
|
|
self.assertEqual(self.objects.attributes.get((15, 3)), 0)
|
|
|
|
def test_op_jin_true(self):
|
|
"""Test jin branches when obj1 parent equals obj2."""
|
|
self.objects.parents[5] = 10
|
|
self.decoder.branch_condition = True
|
|
self.decoder.branch_offset = 15
|
|
old_pc = self.cpu._opdecoder.program_counter
|
|
|
|
self.cpu.op_jin(5, 10)
|
|
|
|
# Should branch
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc + 13)
|
|
|
|
def test_op_jin_false(self):
|
|
"""Test jin doesn't branch when obj1 parent not equal to obj2."""
|
|
self.objects.parents[5] = 8
|
|
self.decoder.branch_condition = True
|
|
old_pc = self.cpu._opdecoder.program_counter
|
|
|
|
self.cpu.op_jin(5, 10)
|
|
|
|
# Should not branch
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
|
|
|
|
def test_op_remove_obj(self):
|
|
"""Test remove_obj removes object from parent."""
|
|
self.objects.parents[7] = 3
|
|
self.cpu.op_remove_obj(7)
|
|
# Parent should be cleared
|
|
self.assertEqual(self.objects.parents.get(7), 0)
|
|
|
|
def test_op_print_obj(self):
|
|
"""Test print_obj prints object's short name."""
|
|
self.objects.shortnames[12] = "brass lantern"
|
|
self.cpu.op_print_obj(12)
|
|
self.ui.screen.write.assert_called_once_with("brass lantern")
|
|
|
|
def test_op_get_prop_addr_found(self):
|
|
"""Test get_prop_addr stores data address when property exists."""
|
|
self.objects.property_data_addresses[(20, 5)] = 0x5000
|
|
self.decoder.store_address = 0
|
|
|
|
self.cpu.op_get_prop_addr(20, 5)
|
|
|
|
# Should store the data address
|
|
self.assertEqual(self.stack.pop_stack(), 0x5000)
|
|
|
|
def test_op_get_prop_addr_not_found(self):
|
|
"""Test get_prop_addr stores 0 when property doesn't exist."""
|
|
self.decoder.store_address = 0
|
|
|
|
self.cpu.op_get_prop_addr(20, 99)
|
|
|
|
# Should store 0
|
|
self.assertEqual(self.stack.pop_stack(), 0)
|
|
|
|
def test_op_get_next_prop_first(self):
|
|
"""Test get_next_prop with propnum=0 returns first property."""
|
|
self.objects.next_properties[(25, 0)] = 15
|
|
self.decoder.store_address = 0
|
|
|
|
self.cpu.op_get_next_prop(25, 0)
|
|
|
|
# Should store first property number
|
|
self.assertEqual(self.stack.pop_stack(), 15)
|
|
|
|
def test_op_get_next_prop_next(self):
|
|
"""Test get_next_prop with propnum>0 returns next property."""
|
|
self.objects.next_properties[(25, 10)] = 8
|
|
self.decoder.store_address = 0
|
|
|
|
self.cpu.op_get_next_prop(25, 10)
|
|
|
|
# Should store next property number
|
|
self.assertEqual(self.stack.pop_stack(), 8)
|
|
|
|
def test_op_get_prop_len(self):
|
|
"""Test get_prop_len returns property data length."""
|
|
self.decoder.store_address = 0
|
|
|
|
self.cpu.op_get_prop_len(0x6000)
|
|
|
|
# Should store 2 (from mock)
|
|
self.assertEqual(self.stack.pop_stack(), 2)
|
|
|
|
def test_op_get_prop_len_zero_addr(self):
|
|
"""Test get_prop_len with address 0 returns 0."""
|
|
self.decoder.store_address = 0
|
|
|
|
self.cpu.op_get_prop_len(0)
|
|
|
|
# Should store 0
|
|
self.assertEqual(self.stack.pop_stack(), 0)
|
|
|
|
|
|
class ZMachineComplexOpcodeTests(TestCase):
|
|
"""Test suite for complex Z-machine opcodes (input, save/restore, restart)."""
|
|
|
|
def setUp(self):
|
|
"""Create a CPU with all necessary mocks."""
|
|
self.memory = MockMemory()
|
|
self.stack = MockStackManager()
|
|
self.decoder = MockOpDecoder()
|
|
self.ui = MockUI()
|
|
|
|
# Create CPU with mocked dependencies
|
|
self.cpu = ZCpu(
|
|
self.memory,
|
|
self.decoder,
|
|
self.stack,
|
|
Mock(), # objects
|
|
Mock(), # string
|
|
Mock(), # stream manager
|
|
self.ui,
|
|
Mock(), # lexer
|
|
)
|
|
|
|
def test_op_sread_v3_basic_input(self):
|
|
"""Test sread (V3) reads text into buffer and null-terminates."""
|
|
# Setup: text buffer at 0x1000, max length 20
|
|
text_buffer_addr = 0x1000
|
|
self.memory[text_buffer_addr] = 20 # max length
|
|
|
|
# Mock keyboard input
|
|
self.ui.keyboard.readline = Mock(return_value="hello world\n")
|
|
|
|
# Call sread with text buffer only (no parse buffer)
|
|
self.cpu.op_sread(text_buffer_addr, 0)
|
|
|
|
# Verify text is stored lowercased starting at offset 1
|
|
expected = "hello world"
|
|
for i, ch in enumerate(expected):
|
|
self.assertEqual(self.memory[text_buffer_addr + 1 + i], ord(ch))
|
|
|
|
# Verify null termination
|
|
self.assertEqual(self.memory[text_buffer_addr + 1 + len(expected)], 0)
|
|
|
|
def test_op_sread_v3_truncates_to_max_length(self):
|
|
"""Test sread respects max length in buffer."""
|
|
text_buffer_addr = 0x1000
|
|
self.memory[text_buffer_addr] = 5 # max length of 5
|
|
|
|
self.ui.keyboard.readline = Mock(return_value="hello world\n")
|
|
|
|
self.cpu.op_sread(text_buffer_addr, 0)
|
|
|
|
# Should only store first 5 characters
|
|
expected = "hello"
|
|
for i, ch in enumerate(expected):
|
|
self.assertEqual(self.memory[text_buffer_addr + 1 + i], ord(ch))
|
|
self.assertEqual(self.memory[text_buffer_addr + 1 + 5], 0)
|
|
|
|
def test_op_sread_v3_lowercases_input(self):
|
|
"""Test sread converts input to lowercase."""
|
|
text_buffer_addr = 0x1000
|
|
self.memory[text_buffer_addr] = 20
|
|
|
|
self.ui.keyboard.readline = Mock(return_value="HELLO World\n")
|
|
|
|
self.cpu.op_sread(text_buffer_addr, 0)
|
|
|
|
expected = "hello world"
|
|
for i, ch in enumerate(expected):
|
|
self.assertEqual(self.memory[text_buffer_addr + 1 + i], ord(ch))
|
|
|
|
def test_op_sread_v3_strips_newlines(self):
|
|
"""Test sread strips newlines and carriage returns."""
|
|
text_buffer_addr = 0x1000
|
|
self.memory[text_buffer_addr] = 20
|
|
|
|
self.ui.keyboard.readline = Mock(return_value="test\r\n")
|
|
|
|
self.cpu.op_sread(text_buffer_addr, 0)
|
|
|
|
expected = "test"
|
|
for i, ch in enumerate(expected):
|
|
self.assertEqual(self.memory[text_buffer_addr + 1 + i], ord(ch))
|
|
self.assertEqual(self.memory[text_buffer_addr + 1 + 4], 0)
|
|
|
|
def test_op_sread_v3_calls_show_status(self):
|
|
"""Test sread calls op_show_status for V3."""
|
|
text_buffer_addr = 0x1000
|
|
self.memory[text_buffer_addr] = 20
|
|
self.ui.keyboard.readline = Mock(return_value="test\n")
|
|
|
|
# Track if show_status was called
|
|
call_count = [0]
|
|
original = self.cpu.op_show_status
|
|
|
|
def counted_show_status(*args):
|
|
call_count[0] += 1
|
|
return original(*args)
|
|
|
|
self.cpu.op_show_status = counted_show_status # type: ignore
|
|
|
|
self.cpu.op_sread(text_buffer_addr, 0)
|
|
|
|
# Should have called show_status once
|
|
self.assertEqual(call_count[0], 1)
|
|
|
|
def test_op_save_v3_branches_false(self):
|
|
"""Test save (V3) branches false (QuetzalWriter not functional)."""
|
|
self.decoder.branch_condition = True
|
|
self.decoder.branch_offset = 100
|
|
old_pc = self.cpu._opdecoder.program_counter
|
|
|
|
self.cpu.op_save()
|
|
|
|
# Should not have branched (test is false)
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
|
|
|
|
def test_op_restore_v3_branches_false(self):
|
|
"""Test restore (V3) branches false (no valid save files)."""
|
|
self.decoder.branch_condition = True
|
|
self.decoder.branch_offset = 100
|
|
old_pc = self.cpu._opdecoder.program_counter
|
|
|
|
self.cpu.op_restore()
|
|
|
|
# Should not have branched (test is false)
|
|
self.assertEqual(self.cpu._opdecoder.program_counter, old_pc)
|
|
|
|
def test_op_input_stream_is_noop(self):
|
|
"""Test input_stream is a no-op stub."""
|
|
# Should not raise
|
|
self.cpu.op_input_stream(1)
|
|
|
|
def test_op_sound_effect_is_noop(self):
|
|
"""Test sound_effect is a no-op stub."""
|
|
# Should not raise
|
|
self.cpu.op_sound_effect(1, 2, 3)
|
|
|
|
def test_op_restart_raises_exception(self):
|
|
"""Test restart raises ZCpuRestart exception for run loop to handle."""
|
|
with self.assertRaises(ZCpuRestart):
|
|
self.cpu.op_restart()
|
|
|
|
|
|
# Note: ZObjectParser methods are tested through integration tests
|
|
# with real story files, not unit tests with mock memory, as the
|
|
# interaction with ZStringFactory makes mocking complex.
|