From 1122de18a55b5428c31157f9d841d2e44d8dfd1c Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Sat, 15 Aug 2026 12:03:22 +0200 Subject: [PATCH] added some more unit tests --- test/unit/testbaseplatform.ts | 660 ++++++++++++++++++++++++++++++++++ test/unit/testcommon.ts | 256 +++++++++++++ test/unit/testcommon_ext.ts | 361 +++++++++++++++++++ test/unit/testemu.ts | 522 +++++++++++++++++++++++++++ 4 files changed, 1799 insertions(+) create mode 100644 test/unit/testbaseplatform.ts create mode 100644 test/unit/testcommon.ts create mode 100644 test/unit/testcommon_ext.ts create mode 100644 test/unit/testemu.ts diff --git a/test/unit/testbaseplatform.ts b/test/unit/testbaseplatform.ts new file mode 100644 index 00000000..cf12ac45 --- /dev/null +++ b/test/unit/testbaseplatform.ts @@ -0,0 +1,660 @@ + +import assert from "assert"; +import { describe, it } from "mocha"; +import { + DebugSymbols, + isDebuggable, + Debuggable, + BreakpointList, + BasePlatform, + BaseDebugPlatform, + Base6502Platform, + BaseZ80Platform, + Base6809Platform, + EmuState, + CpuState, + OpcodeMetadata, + Platform, + getToolForFilename_6502, + getToolForFilename_z80, + getToolForFilename_6809, + getOpcodeMetadata_6502, + cpuStateToLongString_6502, + cpuStateToLongString_Z80, + cpuStateToLongString_6809, + inspectSymbol, + lookupSymbol, + dumpStackToString, +} from "../../src/common/baseplatform"; + +describe('DebugSymbols', function () { + it('should create symbol map with correct structure', function () { + const symbolmap = { main: 0x0000, loop: 0x0010, end: 0x0100 }; + const debuginfo = { platform: 'test' }; + const symbols = new DebugSymbols(symbolmap, debuginfo); + + assert.strictEqual(symbols.symbolmap['main'], 0x0000); + assert.strictEqual(symbols.symbolmap['loop'], 0x0010); + assert.strictEqual(symbols.symbolmap['end'], 0x0100); + }); + + it('should create inverted address-to-symbol map', function () { + const symbolmap = { main: 0x0000, loop: 0x0010, end: 0x0100 }; + const debuginfo = {}; + const symbols = new DebugSymbols(symbolmap, debuginfo); + + assert.strictEqual(symbols.addr2symbol[0x0000], 'main'); + assert.strictEqual(symbols.addr2symbol[0x0010], 'loop'); + assert.strictEqual(symbols.addr2symbol[0x0100], 'end'); + }); + + it('should ensure sentinel values in address map', function () { + const symbolmap = { loop: 0x1000 }; + const debuginfo = {}; + const symbols = new DebugSymbols(symbolmap, debuginfo); + + // Empty symbol map gets $00 sentinel + assert.strictEqual(symbols.addr2symbol[0x0], '$00'); + assert.strictEqual(symbols.addr2symbol[0x10000], '__END__'); + }); + + it('should store debug info', function () { + const debuginfo = { platform: 'c64', version: '1.0' }; + const symbols = new DebugSymbols({}, debuginfo); + + assert.deepStrictEqual(symbols.debuginfo, debuginfo); + }); + + it('should handle empty symbol map', function () { + const symbols = new DebugSymbols({}, {}); + + assert.deepStrictEqual(symbols.symbolmap, {}); + assert.ok(symbols.addr2symbol[0x0] !== undefined); + }); + + it('should handle duplicate addresses with last symbol winning', function () { + const symbolmap = { sym1: 0x100, sym2: 0x100 }; + const symbols = new DebugSymbols(symbolmap, {}); + + // One of them should be in the inverted map + assert.ok(symbols.addr2symbol[0x100] === 'sym1' || symbols.addr2symbol[0x100] === 'sym2'); + }); +}); + +describe('isDebuggable type guard', function () { + it('should identify debuggable objects', function () { + const debuggable: Debuggable = { + getDebugCategories: () => ['CPU', 'Memory'], + getDebugInfo: (category: string) => 'debug info' + }; + + assert.ok(isDebuggable(debuggable)); + }); + + it('should reject non-debuggable objects', function () { + assert.ok(!isDebuggable({})); + assert.ok(!isDebuggable(null)); + assert.ok(!isDebuggable(undefined)); + assert.ok(!isDebuggable({ foo: 'bar' })); + }); + + it('should accept partial implementations', function () { + const partial = { + getDebugCategories: () => ['CPU'] + }; + + assert.ok(isDebuggable(partial)); + }); +}); + +describe('BreakpointList', function () { + it('should return null when no breakpoints exist', function () { + const list = new BreakpointList(); + const cond = list.getDebugCondition(); + + assert.strictEqual(cond, null); + }); + + it('should create debug condition for single breakpoint', function () { + const list = new BreakpointList(); + let hitCount = 0; + list.id2bp['bp1'] = { cond: () => { hitCount++; return true; } }; + + const cond = list.getDebugCondition(); + assert.ok(cond !== null); + assert.ok(cond()); + assert.strictEqual(hitCount, 1); + }); + + it('should evaluate multiple breakpoints with OR logic', function () { + const list = new BreakpointList(); + let hits1 = 0, hits2 = 0; + + list.id2bp['bp1'] = { cond: () => { hits1++; return false; } }; + list.id2bp['bp2'] = { cond: () => { hits2++; return true; } }; + + const cond = list.getDebugCondition(); + const result = cond(); + + assert.ok(result); + assert.strictEqual(hits1, 1); + assert.strictEqual(hits2, 1); + }); + + it('should remove breakpoint', function () { + const list = new BreakpointList(); + list.id2bp['bp1'] = { cond: () => true }; + + delete list.id2bp['bp1']; + const cond = list.getDebugCondition(); + + assert.strictEqual(cond, null); + }); +}); + +describe('Tool Filename Detection', function () { + describe('6502 tools', function () { + it('should detect C language', function () { + assert.strictEqual(getToolForFilename_6502('test.c'), 'cc65'); + assert.strictEqual(getToolForFilename_6502('program.c'), 'cc65'); + }); + + it('should detect assembly language', function () { + assert.strictEqual(getToolForFilename_6502('test.s'), 'ca65'); + assert.strictEqual(getToolForFilename_6502('code.ca65'), 'ca65'); + assert.strictEqual(getToolForFilename_6502('test.dasm'), 'dasm'); + }); + + it('should detect various assembler formats', function () { + assert.strictEqual(getToolForFilename_6502('test.acme'), 'acme'); + assert.strictEqual(getToolForFilename_6502('test.xa'), 'xa'); + assert.strictEqual(getToolForFilename_6502('test.wiz'), 'wiz'); + }); + + it('should detect special formats', function () { + assert.strictEqual(getToolForFilename_6502('test.ecs'), 'ecs'); + assert.strictEqual(getToolForFilename_6502('test.cpp'), 'oscar64'); + assert.strictEqual(getToolForFilename_6502('test.cc'), 'oscar64'); + assert.strictEqual(getToolForFilename_6502('test.o64'), 'oscar64'); + }); + + it('should default to dasm for unknown extensions', function () { + assert.strictEqual(getToolForFilename_6502('test.a'), 'dasm'); + assert.strictEqual(getToolForFilename_6502('test.asm'), 'dasm'); + assert.strictEqual(getToolForFilename_6502('unknown'), 'dasm'); + }); + + it('should detect llvm-mos for special C files', function () { + assert.strictEqual(getToolForFilename_6502('test-llvm.c'), 'remote:llvm-mos'); + }); + }); + + describe('Z80 tools', function () { + it('should detect C language', function () { + assert.strictEqual(getToolForFilename_z80('test.c'), 'sdcc'); + assert.strictEqual(getToolForFilename_z80('code.h'), 'sdcc'); + }); + + it('should detect assembly language', function () { + assert.strictEqual(getToolForFilename_z80('test.s'), 'sdasz80'); + assert.strictEqual(getToolForFilename_z80('test.sgb'), 'sdasgb'); + assert.strictEqual(getToolForFilename_z80('test.ns'), 'naken'); + }); + + it('should detect special formats', function () { + assert.strictEqual(getToolForFilename_z80('test.scc'), 'sccz80'); + assert.strictEqual(getToolForFilename_z80('test.z'), 'zmac'); + assert.strictEqual(getToolForFilename_z80('test.wiz'), 'wiz'); + }); + + it('should default to zmac for unknown extensions', function () { + assert.strictEqual(getToolForFilename_z80('unknown'), 'zmac'); + }); + }); + + describe('6809 tools', function () { + it('should detect C language', function () { + assert.strictEqual(getToolForFilename_6809('test.c'), 'cmoc'); + assert.strictEqual(getToolForFilename_6809('code.h'), 'cmoc'); + }); + + it('should detect assembly language', function () { + assert.strictEqual(getToolForFilename_6809('test.xasm'), 'xasm6809'); + assert.strictEqual(getToolForFilename_6809('test.lwasm'), 'lwasm'); + }); + + it('should default to cmoc', function () { + assert.strictEqual(getToolForFilename_6809('unknown'), 'cmoc'); + }); + }); +}); + +describe('Opcode Metadata', function () { + it('should return valid metadata for 6502 opcodes', function () { + // Test NOP (0xEA) + const nop = getOpcodeMetadata_6502(0xEA, 0); + assert.strictEqual(nop.opcode, 0xEA); + assert.ok(nop.minCycles > 0); + assert.ok(nop.maxCycles >= nop.minCycles); + assert.ok(nop.insnlength > 0); + }); + + it('should return consistent metadata for same opcode', function () { + const meta1 = getOpcodeMetadata_6502(0x69, 0x1000); // ADC immediate + const meta2 = getOpcodeMetadata_6502(0x69, 0x2000); + + assert.strictEqual(meta1.opcode, meta2.opcode); + assert.strictEqual(meta1.minCycles, meta2.minCycles); + assert.strictEqual(meta1.maxCycles, meta2.maxCycles); + assert.strictEqual(meta1.insnlength, meta2.insnlength); + }); + + it('should handle all 256 6502 opcodes', function () { + for (let op = 0; op < 256; op++) { + const meta = getOpcodeMetadata_6502(op, 0); + assert.ok(meta.minCycles >= 0); + assert.ok(meta.maxCycles >= meta.minCycles); + assert.ok(meta.insnlength >= 0); + } + }); +}); + +describe('CPU State Formatting', function () { + describe('6502 CPU state', function () { + it('should format basic CPU state', function () { + const state: any = { + PC: 0x1234, + SP: 0xFF, + A: 0x42, + X: 0x10, + Y: 0x20, + N: false, V: false, D: false, Z: false, C: false, I: false, R: true + }; + + const output = cpuStateToLongString_6502(state); + assert.ok(output.includes('1234')); // PC + assert.ok(output.includes('FF')); // SP + }); + + it('should show flag status', function () { + const state1: any = { + PC: 0, + SP: 0, + N: true, V: false, D: false, Z: true, C: false, I: false, R: true + }; + + const output = cpuStateToLongString_6502(state1); + assert.ok(output.includes('N')); + assert.ok(output.includes('Z')); + }); + + it('should show BUSY status when R is false', function () { + const state: any = { PC: 0, SP: 0, R: false }; + const output = cpuStateToLongString_6502(state); + assert.ok(output.includes('BUSY')); + }); + }); + + describe('Z80 CPU state', function () { + it('should format Z80 state', function () { + const state: any = { + PC: 0x4000, + SP: 0x9000, + AF: 0x1234, + BC: 0x5678, + DE: 0x9ABC, + HL: 0xDEF0, + IX: 0x0001, + IY: 0x0002, + IR: 0x3344, + iff1: true, + iff2: false + }; + + const output = cpuStateToLongString_Z80(state); + assert.ok(output.includes('4000')); // PC + assert.ok(output.includes('9000')); // SP + assert.ok(output.includes('1234')); // AF + }); + }); + + describe('6809 CPU state', function () { + it('should format 6809 state', function () { + const state: any = { + PC: 0x1000, + SP: 0x2000, + DP: 0x00, + A: 0x11, + B: 0x22, + X: 0x3333, + Y: 0x4444, + U: 0x5555, + CC: 0xF0 + }; + + const output = cpuStateToLongString_6809(state); + assert.ok(output.includes('1000')); // PC + assert.ok(output.includes('2000')); // SP + assert.ok(output.includes('11')); // A + }); + }); +}); + +describe('Symbol lookup', function () { + it('should find symbol at address', function () { + const platform: any = { + debugSymbols: new DebugSymbols({ main: 0x1000, loop: 0x1010 }, {}), + readAddress: (addr: number) => addr & 0xFF + }; + + const result = lookupSymbol(platform, 0x1010, false); + assert.ok(result.includes('loop')); + }); + + it('should find symbol before address when extra is true', function () { + const platform: any = { + debugSymbols: new DebugSymbols({ main: 0x1000 }, {}), + readAddress: (addr: number) => 0 + }; + + const result = lookupSymbol(platform, 0x1005, true); + assert.ok(result.includes('main')); + assert.ok(result.includes('$05')); + }); + + it('should return empty string when no symbol found', function () { + const platform: any = { + debugSymbols: new DebugSymbols({}, {}), + readAddress: (addr: number) => 0 + }; + + const result = lookupSymbol(platform, 0x5000, false); + assert.strictEqual(result, ''); + }); + + it('should handle missing debug symbols', function () { + const platform: any = { debugSymbols: null }; + + const result = lookupSymbol(platform, 0x1000, false); + assert.strictEqual(result, ''); + }); +}); + +describe('Stack dumping', function () { + it('should dump stack memory', function () { + const mem = new Uint8Array([ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x20, 0x10, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, + ]); + + const platform: any = { + debugSymbols: new DebugSymbols({ func: 0x100E }, {}), + readAddress: (addr: number) => addr < mem.length ? mem[addr] : 0 + }; + + const result = dumpStackToString(platform, mem, 0, 15, 9, 0x20); + assert.ok(result.includes('$')); + assert.ok(result.length > 0); + }); + + it('should handle big endian byte order', function () { + const mem = new Uint8Array([0x10, 0x20, 0x30, 0x40]); + const platform: any = { + debugSymbols: new DebugSymbols({}, {}), + readAddress: (addr: number) => mem[addr] + }; + + const result = dumpStackToString(platform, mem, 0, 3, 2, 0, true); + assert.ok(result.includes('$')); + }); + + it('should show JSR addresses', function () { + const mem = new Uint8Array(256); + // Set up JSR at 0x100 (6502 JSR is 0x20) + mem[0x100] = 0x20; + // Stack has return address pointing near 0x100 + mem[0x50] = 0xFE; + mem[0x51] = 0x01; + + const platform: any = { + debugSymbols: new DebugSymbols({ func: 0x0100 }, {}), + readAddress: (addr: number) => { + if (addr < mem.length) return mem[addr]; + return 0; + } + }; + + const result = dumpStackToString(platform, mem, 0, 255, 0x50, 0x20); + // The function returns a string with stack content + assert.ok(result.includes('$')); + }); +}); + +describe('BasePlatform', function () { + class TestPlatform extends BasePlatform { + loadState(state: EmuState): void { } + saveState(): EmuState { return {}; } + pause(): void { } + resume(): void { } + advance(novideo?: boolean): number { return 0; } + } + + it('should initialize with empty internal files', function () { + const platform = new TestPlatform(); + assert.deepStrictEqual(platform.internalFiles, {}); + }); + + it('should set and get recorder', function () { + const platform = new TestPlatform(); + const recorder: any = { frameRequested: () => false }; + platform.setRecorder(recorder); + assert.strictEqual(platform.recorder, recorder); + }); + + it('should read and write files', function () { + const platform = new TestPlatform(); + const data: any = { data: [1, 2, 3] }; + + platform.writeFile('test.txt', data); + const read = platform.readFile('test.txt'); + + assert.deepStrictEqual(read, data); + }); + + it('should return undefined for non-existent file', function () { + const platform = new TestPlatform(); + const read = platform.readFile('nonexistent.txt'); + assert.strictEqual(read, undefined); + }); + + it('should generate debug tree', function () { + const platform = new TestPlatform(); + platform.debugSymbols = new DebugSymbols({ test: 0x1000 }, { info: 'test' }); + + const tree: any = platform.getDebugTree(); + assert.ok(tree.state); + assert.deepStrictEqual(tree.debuginfo, { info: 'test' }); + }); +}); + +describe('BaseDebugPlatform', function () { + class TestDebugPlatform extends BaseDebugPlatform { + loadState(state: EmuState): void { } + saveState(): EmuState { return { c: { PC: 0 } }; } + pause(): void { } + resume(): void { } + advance(novideo?: boolean): number { return 0; } + getCPUState(): CpuState { return { PC: 0x1000, SP: 0x100 }; } + getSP(): number { return 0x100; } + getPC(): number { return 0x1000; } + isStable(): boolean { return true; } + } + + it('should manage breakpoints', function () { + const platform = new TestDebugPlatform(); + const cond = () => true; + + platform.setBreakpoint('bp1', cond); + assert.ok(platform.hasBreakpoint('bp1')); + + platform.clearBreakpoint('bp1'); + assert.ok(!platform.hasBreakpoint('bp1')); + }); + + it('should clear all debug state', function () { + const platform = new TestDebugPlatform(); + platform.debugSavedState = { c: { PC: 0 } }; + platform.debugBreakState = { c: { PC: 100 } }; + platform.onBreakpointHit = () => { }; + + platform.clearDebug(); + + assert.strictEqual(platform.debugSavedState, null); + assert.strictEqual(platform.debugBreakState, null); + assert.strictEqual(platform.onBreakpointHit, null); + }); + + it('should track frame count', function () { + const platform = new TestDebugPlatform(); + assert.strictEqual(platform.frameCount, 0); + + platform.postFrame(); + assert.strictEqual(platform.frameCount, 1); + + platform.postFrame(); + assert.strictEqual(platform.frameCount, 2); + }); + + it('should detect breakpoint hits', function () { + const platform = new TestDebugPlatform(); + assert.ok(!platform.wasBreakpointHit()); + + platform.debugBreakState = { c: { PC: 0 } }; + assert.ok(platform.wasBreakpointHit()); + }); +}); + +describe('Base6502Platform', function () { + class TestNES6502 extends Base6502Platform { + loadState(state: EmuState): void { } + saveState(): EmuState { return { c: { PC: 0, SP: 0 } }; } + pause(): void { } + resume(): void { } + advance(novideo?: boolean): number { return 0; } + getCPUState(): CpuState { return { PC: 0x8000, SP: 0xFF }; } + getSP(): number { return 0xFF; } + getPC(): number { return 0x8000; } + isStable(): boolean { return true; } + readAddress(addr: number): number { return 0; } + } + + it('should get default extensions', function () { + const platform = new TestNES6502(); + const exts = platform.getDefaultExtensions(); + assert.ok(exts.includes('.c')); + assert.ok(exts.includes('.ca65')); + assert.ok(exts.includes('.dasm')); + }); + + it('should get 6502 opcode metadata', function () { + const platform = new TestNES6502(); + const meta = platform.getOpcodeMetadata(0x69, 0); + assert.ok(meta.minCycles > 0); + }); + + it('should get origin PC from reset vector', function () { + const platform = new TestNES6502(); + const originPC = platform.getOriginPC(); + assert.ok(typeof originPC === 'number'); + }); + + it('should apply PC delta correction', function () { + const platform = new TestNES6502(); + const state: CpuState = { PC: 0x1000 }; + + const fixed = platform.fixPC(state); + assert.strictEqual(fixed.PC, 0x0FFF); // PC - 1 + + const unfixed = platform.unfixPC(fixed); + assert.strictEqual(unfixed.PC, 0x1000); + }); + + it('should handle PC wrapping at 16-bit boundary', function () { + const platform = new TestNES6502(); + const state: CpuState = { PC: 0x0000 }; + + const fixed = platform.fixPC(state); + assert.strictEqual(fixed.PC, 0xFFFF); + }); + + it('should get debug categories', function () { + const platform = new TestNES6502(); + const cats = platform.getDebugCategories(); + assert.ok(cats.includes('CPU')); + assert.ok(cats.includes('Stack')); + }); +}); + +describe('inspectSymbol', function () { + it('should inspect symbol value at address', function () { + const platform: any = { + debugSymbols: new DebugSymbols({ myvar: 0x100 }, {}), + readAddress: (addr: number) => addr === 0x100 ? 0x42 : 0x00 + }; + + const result = inspectSymbol(platform, 'myvar'); + assert.ok(result.includes('100')); + assert.ok(result.includes('42')); + }); + + it('should handle two-byte values', function () { + const platform: any = { + debugSymbols: new DebugSymbols({ word: 0x200 }, {}), + readAddress: (addr: number) => { + if (addr === 0x200) return 0x34; + if (addr === 0x201) return 0x12; + return 0; + } + }; + + const result = inspectSymbol(platform, 'word'); + assert.ok(result.includes('200')); + }); + + it('should look for C and asm symbol variants', function () { + const platform: any = { + debugSymbols: new DebugSymbols({ _myvar: 0x300 }, {}), + readAddress: (addr: number) => addr === 0x300 ? 0x99 : 0 + }; + + const result = inspectSymbol(platform, 'myvar'); + assert.ok(result.includes('99')); + }); + + it('should return undefined for missing symbol', function () { + const platform: any = { + debugSymbols: new DebugSymbols({}, {}), + readAddress: (addr: number) => 0 + }; + + const result = inspectSymbol(platform, 'nonexistent'); + assert.ok(result === undefined || result === null); + }); + + it('should handle missing debug symbols', function () { + const platform: any = { debugSymbols: null }; + const result = inspectSymbol(platform, 'symbol'); + assert.strictEqual(result, undefined); + }); + + it('should handle missing readAddress method', function () { + const platform: any = { + debugSymbols: new DebugSymbols({ test: 0x100 }, {}), + readAddress: undefined + }; + + const result = inspectSymbol(platform, 'test'); + assert.ok(result === null); + }); +}); + diff --git a/test/unit/testcommon.ts b/test/unit/testcommon.ts new file mode 100644 index 00000000..b6287458 --- /dev/null +++ b/test/unit/testcommon.ts @@ -0,0 +1,256 @@ + +import assert from "assert"; +import { describe, it } from "mocha"; +import { + lpad, rpad, byte2signed, getFilenameForPath, getFolderForPath, + getFilenamePrefix, hex, tobin, toradix, arrayCompare, invertMap, + stringToByteArray, byteArrayToString, byteArrayToUTF8, removeBOM, + isProbablyBinary, clamp, safeident, rgb2bgr, RGBA, findIntegerFactors, + replaceAll, highlightDifferences, decodeQueryString, parseBool, + safe_extend, printFlags, byteToASCII, coerceToArray +} from "../../src/common/util"; + +describe('String Padding Functions', function () { + it('lpad should pad strings on the left', function () { + assert.strictEqual(lpad('5', 3), ' 5'); + assert.strictEqual(lpad('hello', 8), ' hello'); + assert.strictEqual(lpad('x', 1), 'x'); + assert.strictEqual(lpad('123', 5), ' 123'); + }); + + it('rpad should pad strings on the right', function () { + assert.strictEqual(rpad('5', 3), '5 '); + assert.strictEqual(rpad('hello', 8), 'hello '); + assert.strictEqual(rpad('x', 1), 'x'); + assert.strictEqual(rpad('123', 5), '123 '); + }); +}); + +describe('Number Conversion Functions', function () { + it('hex should format numbers in hexadecimal', function () { + assert.strictEqual(hex(255), 'FF'); + assert.strictEqual(hex(0), '00'); + assert.strictEqual(hex(255, 4), '00FF'); + assert.strictEqual(hex(65535, 4), 'FFFF'); + assert.strictEqual(hex(0x12345678, 8), '12345678'); + assert.strictEqual(hex(15, 2), '0F'); + }); + + it('tobin should format numbers in binary', function () { + assert.strictEqual(tobin(0), '00000000'); + assert.strictEqual(tobin(255), '11111111'); + assert.strictEqual(tobin(5), '00000101'); + assert.strictEqual(tobin(15, 4), '1111'); + assert.strictEqual(tobin(128), '10000000'); + }); + + it('toradix should convert to various bases', function () { + assert.strictEqual(toradix(10, 4, 16), '000A'); + assert.strictEqual(toradix(255, 8, 16), '000000FF'); + assert.strictEqual(toradix(7, 3, 2), '111'); + assert.strictEqual(toradix(100, 4, 10), '0100'); + }); + + it('byte2signed should convert unsigned to signed bytes', function () { + assert.strictEqual(byte2signed(0), 0); + assert.strictEqual(byte2signed(127), 127); + assert.strictEqual(byte2signed(128), -128); + assert.strictEqual(byte2signed(255), -1); + assert.strictEqual(byte2signed(200), -56); + assert.strictEqual(byte2signed(0x80), -128); + }); +}); + +describe('File Path Functions', function () { + it('getFilenameForPath should extract filename from path', function () { + assert.strictEqual(getFilenameForPath('path/to/file.txt'), 'file.txt'); + assert.strictEqual(getFilenameForPath('file.txt'), 'file.txt'); + assert.strictEqual(getFilenameForPath('/a/b/c/d.js'), 'd.js'); + assert.strictEqual(getFilenameForPath('a/b'), 'b'); + }); + + it('getFolderForPath should extract folder from path', function () { + assert.strictEqual(getFolderForPath('path/to/file.txt'), 'path/to'); + assert.strictEqual(getFolderForPath('file.txt'), ''); + assert.strictEqual(getFolderForPath('/a/b/c/d.js'), '/a/b/c'); + assert.strictEqual(getFolderForPath('a/b/c'), 'a/b'); + }); + + it('getFilenamePrefix should extract name without extension', function () { + assert.strictEqual(getFilenamePrefix('file.txt'), 'file'); + assert.strictEqual(getFilenamePrefix('archive.tar.gz'), 'archive.tar'); + assert.strictEqual(getFilenamePrefix('noext'), 'noext'); + assert.strictEqual(getFilenamePrefix('.hidden'), '.hidden'); + }); +}); + +describe('Array/Collection Functions', function () { + it('arrayCompare should compare arrays', function () { + assert.ok(arrayCompare([1, 2, 3], [1, 2, 3])); + assert.ok(!arrayCompare([1, 2, 3], [1, 2, 4])); + assert.ok(!arrayCompare([1, 2], [1, 2, 3])); + assert.ok(arrayCompare([], [])); + assert.ok(!arrayCompare([1], [])); + assert.ok(arrayCompare(null, null)); + assert.ok(!arrayCompare(null, [1])); + assert.ok(!arrayCompare([1], null)); + const arr1 = new Uint8Array([1, 2, 3]); + const arr2 = new Uint8Array([1, 2, 3]); + assert.ok(arrayCompare(arr1, arr2)); + }); + + it('invertMap should invert key-value pairs', function () { + const map = { a: '1', b: '2', c: '3' }; + const inv = invertMap(map); + assert.strictEqual(inv['1'], 'a'); + assert.strictEqual(inv['2'], 'b'); + assert.strictEqual(inv['3'], 'c'); + }); + + it('coerceToArray should convert various types to arrays', function () { + assert.deepStrictEqual(coerceToArray([1, 2, 3]), [1, 2, 3]); + assert.deepStrictEqual(coerceToArray({ a: 1, b: 2 }), [1, 2]); + }); +}); + +describe('Byte Array Conversion Functions', function () { + it('stringToByteArray should convert string to bytes', function () { + const bytes = stringToByteArray('hello'); + assert.strictEqual(bytes.length, 5); + assert.strictEqual(bytes[0], 104); // 'h' + assert.strictEqual(bytes[4], 111); // 'o' + }); + + it('byteArrayToString should convert bytes to string', function () { + const bytes = [72, 101, 108, 108, 111]; // "Hello" + assert.strictEqual(byteArrayToString(bytes), 'Hello'); + assert.strictEqual(byteArrayToString(new Uint8Array([65, 66, 67])), 'ABC'); + }); + + it('byteArrayToUTF8 should decode UTF-8', function () { + const ascii = byteArrayToUTF8([72, 101, 108, 108, 111]); + assert.strictEqual(ascii, 'Hello'); + }); + + it('removeBOM should strip BOM if present', function () { + assert.strictEqual(removeBOM('hello'), 'hello'); + assert.strictEqual(removeBOM('hello'), 'hello'); + }); +}); + +describe('Binary Detection', function () { + it('isProbablyBinary should detect binary data', function () { + assert.ok(!isProbablyBinary('test.txt', [104, 101, 108, 108, 111])); // "hello" + assert.ok(isProbablyBinary('test.bin', [1, 2, 3])); + assert.ok(isProbablyBinary('test.chr', [255, 255])); + assert.ok(isProbablyBinary('test.dat', [0])); + assert.ok(!isProbablyBinary('test.c', [47, 47, 105, 102])); // "//if" + assert.ok(isProbablyBinary(null, [0, 0, 0, 0])); // null bytes + }); +}); + +describe('Color Functions', function () { + it('rgb2bgr should swap red and blue channels', function () { + assert.strictEqual(rgb2bgr(0xFF0000), 0x0000FF); + assert.strictEqual(rgb2bgr(0x00FF00), 0x00FF00); + assert.strictEqual(rgb2bgr(0x0000FF), 0xFF0000); + assert.strictEqual(rgb2bgr(0xFFFFFF), 0xFFFFFF); + }); + + it('RGBA should create color value', function () { + const white = RGBA(255, 255, 255); + assert.strictEqual(white & 0xFFFFFF, 0xFFFFFF); + const black = RGBA(0, 0, 0); + assert.strictEqual(black & 0xFFFFFF, 0x000000); + }); + + it('clamp should constrain values', function () { + assert.strictEqual(clamp(0, 10, 5), 5); + assert.strictEqual(clamp(0, 10, -5), 0); + assert.strictEqual(clamp(0, 10, 15), 10); + assert.strictEqual(clamp(1, 1, 5), 1); + }); +}); + +describe('String Manipulation', function () { + it('safeident should create valid identifiers', function () { + assert.strictEqual(safeident('valid_name'), 'valid_name'); + assert.strictEqual(safeident('123abc'), '_123abc'); + assert.strictEqual(safeident('-name'), '__name'); + assert.strictEqual(safeident('name-with-dashes'), 'name_with_dashes'); + assert.strictEqual(safeident('name@#$%'), 'name_'); + }); + + it('replaceAll should replace all occurrences', function () { + assert.strictEqual(replaceAll('aaa', 'a', 'b'), 'bbb'); + assert.strictEqual(replaceAll('hello world', ' ', '-'), 'hello-world'); + assert.strictEqual(replaceAll('test', 'x', 'y'), 'test'); + assert.strictEqual(replaceAll('', 'a', 'b'), ''); + }); + + it('byteToASCII should convert bytes to readable chars', function () { + assert.strictEqual(byteToASCII(65), 'A'); + assert.strictEqual(byteToASCII(32), ' '); + assert.strictEqual(byteToASCII(0), '␀'); // NULL control char + }); +}); + +describe('Query String Functions', function () { + it('decodeQueryString should parse query parameters', function () { + const qs1 = decodeQueryString('?key=value&foo=bar'); + assert.strictEqual(qs1['key'], 'value'); + assert.strictEqual(qs1['foo'], 'bar'); + + const qs2 = decodeQueryString('a=1&b=2'); + assert.strictEqual(qs2['a'], '1'); + assert.strictEqual(qs2['b'], '2'); + + const qs3 = decodeQueryString('key=value+with+spaces'); + assert.ok(qs3['key'].includes(' ')); + }); + + it('parseBool should parse boolean strings', function () { + assert.ok(parseBool('true')); + assert.ok(parseBool('1')); + assert.ok(parseBool('yes')); + assert.ok(!parseBool('false')); + assert.ok(!parseBool('0')); + assert.ok(!parseBool('')); + }); +}); + +describe('Complex Utility Functions', function () { + it('findIntegerFactors should find factor pairs', function () { + const f1 = findIntegerFactors(64, 1, 1, 1); + assert.ok(f1.a >= f1.b); + assert.strictEqual(f1.a * f1.b >= 64, true); + + const f2 = findIntegerFactors(100, 5, 5, 1); + assert.ok(f2.a >= 5); + assert.ok(f2.b >= 5); + }); + + it('safe_extend should copy primitive values and arrays', function () { + const dest = { x: 1 }; + const src = { y: 2, arr: [1, 2, 3] }; + safe_extend(false, dest, src); + assert.strictEqual(dest['x'], 1); + assert.strictEqual(dest['y'], 2); + assert.deepStrictEqual(dest['arr'], [1, 2, 3]); + assert.ok(dest['arr'] !== src['arr']); // should be copy + }); + + it('printFlags should format flag values', function () { + const names = ['CARRY', 'ZERO', 'IRQ_DISABLE', 'DECIMAL']; + const result = printFlags(0x01, names, false); + assert.ok(result.includes('CARRY')); + const result2 = printFlags(0x00, names, false); + assert.ok(result2.includes('-')); + }); + + it('highlightDifferences should mark changes in text', function () { + const diff = highlightDifferences('hello world', 'hello there'); + assert.ok(diff.includes('hilite')); + }); +}); + diff --git a/test/unit/testcommon_ext.ts b/test/unit/testcommon_ext.ts new file mode 100644 index 00000000..ec3186e8 --- /dev/null +++ b/test/unit/testcommon_ext.ts @@ -0,0 +1,361 @@ + +import assert from "assert"; +import { describe, it } from "mocha"; +import { + rgbToPixel, DMG_PALETTE, DMG_PALETTE_RGB, DMG_PALETTE_RGB24, dmgShadeFromRgb +} from "../../src/common/gbpalette"; +import { SourceFile } from "../../src/common/workertypes"; +import { ProbeRecorder, ProbeFlags } from "../../src/common/probe"; + +describe('Game Boy Palette Functions', function () { + it('rgbToPixel should convert RGB to packed pixel format', function () { + const white = rgbToPixel(255, 255, 255); + assert.strictEqual(white & 0xFFFFFF, 0xFFFFFF); + assert.strictEqual((white >> 24) & 0xFF, 0xFF); // alpha + + const black = rgbToPixel(0, 0, 0); + assert.strictEqual(black & 0xFFFFFF, 0x000000); + + const red = rgbToPixel(255, 0, 0); + assert.strictEqual(red & 0xFF, 255); // red channel + assert.strictEqual((red >> 8) & 0xFF, 0); // green channel + assert.strictEqual((red >> 16) & 0xFF, 0); // blue channel + }); + + it('DMG_PALETTE should contain 4 shades', function () { + assert.strictEqual(DMG_PALETTE.length, 4); + // Should be in light to dark order + assert.ok(DMG_PALETTE[0] > DMG_PALETTE[3]); // lightest > darkest numerically + }); + + it('DMG_PALETTE_RGB should match DMG_PALETTE', function () { + assert.strictEqual(DMG_PALETTE_RGB.length, 4); + for (let i = 0; i < 4; i++) { + const [r, g, b] = DMG_PALETTE_RGB[i]; + const expected = rgbToPixel(r, g, b); + assert.strictEqual(DMG_PALETTE[i], expected); + } + }); + + it('DMG_PALETTE_RGB24 should be RGB values without alpha', function () { + assert.strictEqual(DMG_PALETTE_RGB24.length, 4); + assert.strictEqual(DMG_PALETTE_RGB24[0], 0xd0d884); // lightest + assert.strictEqual(DMG_PALETTE_RGB24[3], 0x2d3122); // darkest + }); + + it('dmgShadeFromRgb should map greyscale to shade index', function () { + // Very light colors should map to shade 0 + assert.strictEqual(dmgShadeFromRgb(255, 255, 255), 0); + assert.strictEqual(dmgShadeFromRgb(200, 200, 200), 0); + + // Medium-light should be shade 1 + assert.strictEqual(dmgShadeFromRgb(128, 128, 128), 1); + + // Medium-dark should be shade 2 + assert.strictEqual(dmgShadeFromRgb(80, 80, 80), 2); + + // Very dark should be shade 3 + assert.strictEqual(dmgShadeFromRgb(0, 0, 0), 3); + assert.strictEqual(dmgShadeFromRgb(30, 30, 30), 3); + }); + + it('dmgShadeFromRgb should work with non-greyscale colors', function () { + // Red (255,0,0) avg=85 → 2 + assert.strictEqual(dmgShadeFromRgb(255, 0, 0), 2); + // Red (128,0,0) avg≈43 → 3 + assert.strictEqual(dmgShadeFromRgb(128, 0, 0), 3); + + // Green (0,255,0) avg=85 → 2 + assert.strictEqual(dmgShadeFromRgb(0, 255, 0), 2); + // Green (0,128,0) avg≈43 → 3 + assert.strictEqual(dmgShadeFromRgb(0, 128, 0), 3); + + // Blue (0,0,255) avg=85 → 2 + assert.strictEqual(dmgShadeFromRgb(0, 0, 255), 2); + // Blue (0,0,128) avg≈43 → 3 + assert.strictEqual(dmgShadeFromRgb(0, 0, 128), 3); + }); + + it('dmgShadeFromRgb thresholds should be accurate', function () { + // Test boundary at 192 + assert.strictEqual(dmgShadeFromRgb(192, 192, 192), 0); + assert.strictEqual(dmgShadeFromRgb(191, 191, 191), 1); + + // Test boundary at 128 + assert.strictEqual(dmgShadeFromRgb(128, 128, 128), 1); + assert.strictEqual(dmgShadeFromRgb(127, 127, 127), 2); + + // Test boundary at 64 + assert.strictEqual(dmgShadeFromRgb(64, 64, 64), 2); + assert.strictEqual(dmgShadeFromRgb(63, 63, 63), 3); + }); +}); + +describe('SourceFile Class', function () { + it('should create SourceFile with lines and text', function () { + const lines = [ + { line: 1, offset: 0, insns: 'LDA #$00' }, + { line: 2, offset: 2, insns: 'STA $80' }, + { line: 3, offset: 4, insns: 'RTS' }, + ]; + const text = 'LDA #$00\nSTA $80\nRTS\n'; + const sf = new SourceFile(lines, text); + + assert.strictEqual(sf.lines.length, 3); + assert.strictEqual(sf.text, text); + }); + + it('should build offset to line mapping', function () { + const lines = [ + { line: 1, offset: 0, insns: 'LDA #$00' }, + { line: 2, offset: 2, insns: 'STA $80' }, + { line: 3, offset: 4, insns: 'RTS' }, + ]; + const sf = new SourceFile(lines, ''); + + assert.ok(sf.offset2loc[0]); + assert.ok(sf.offset2loc[2]); + assert.ok(sf.offset2loc[4]); + }); + + it('should build line to offset mapping', function () { + const lines = [ + { line: 1, offset: 0 }, + { line: 2, offset: 2 }, + { line: 3, offset: 4 }, + ]; + const sf = new SourceFile(lines, ''); + + assert.strictEqual(sf.line2offset[1], 0); + assert.strictEqual(sf.line2offset[2], 2); + assert.strictEqual(sf.line2offset[3], 4); + }); + + it('should handle negative offsets gracefully', function () { + const lines = [ + { line: 1, offset: -1 }, + { line: 2, offset: 0 }, + { line: 3, offset: 2 }, + ]; + const sf = new SourceFile(lines, ''); + + // Negative offsets should not be added to mapping + assert.ok(!sf.offset2loc[-1]); + assert.ok(sf.offset2loc[0]); + }); + + it('findLineForOffset should locate source line by address', function () { + const lines = [ + { line: 1, offset: 0, insns: 'LDA #$00' }, + { line: 2, offset: 2, insns: 'STA $80' }, + { line: 3, offset: 4, insns: 'RTS' }, + ]; + const sf = new SourceFile(lines, ''); + + const line0 = sf.findLineForOffset(0, 0); + assert.strictEqual(line0.line, 1); + assert.strictEqual(line0.offset, 0); + + const line2 = sf.findLineForOffset(2, 0); + assert.strictEqual(line2.line, 2); + assert.strictEqual(line2.offset, 2); + }); + + it('findLineForOffset should look behind for nearest line', function () { + const lines = [ + { line: 1, offset: 0, insns: 'LDA #$00' }, + { line: 2, offset: 2, insns: 'STA $80' }, + { line: 3, offset: 4, insns: 'RTS' }, + ]; + const sf = new SourceFile(lines, ''); + + // Looking for offset 3 with lookbehind=1 should find offset 2 + const line = sf.findLineForOffset(3, 1); + assert.strictEqual(line.line, 2); + assert.strictEqual(line.offset, 2); + }); + + it('findLineForOffset should return null if not found', function () { + const lines = [ + { line: 1, offset: 0 }, + { line: 2, offset: 2 }, + ]; + const sf = new SourceFile(lines, ''); + + // Looking for offset 10 should not find anything + const line = sf.findLineForOffset(10, 0); + assert.strictEqual(line, null); + }); + + it('lineCount should return number of lines', function () { + const lines = [ + { line: 1, offset: 0 }, + { line: 2, offset: 2 }, + { line: 3, offset: 4 }, + ]; + const sf = new SourceFile(lines, ''); + + assert.strictEqual(sf.lineCount(), 3); + }); + + it('should handle duplicate offsets (first wins)', function () { + const lines = [ + { line: 1, offset: 0, insns: 'LDA #$00' }, + { line: 2, offset: 0, insns: 'STA $80' }, // Same offset + { line: 3, offset: 2, insns: 'RTS' }, + ]; + const sf = new SourceFile(lines, ''); + + // First line at offset 0 should be returned + const line = sf.findLineForOffset(0, 0); + assert.strictEqual(line.line, 1); + }); +}); + +describe('ProbeRecorder Class', function () { + class MockMachine { + probe: any; + connectProbe(probe) { + this.probe = probe; + } + } + + it('should create ProbeRecorder with buffer', function () { + const machine = new MockMachine(); + const pr = new ProbeRecorder(machine, 1024); + + assert.ok(pr.buf); + assert.strictEqual(pr.buf.length, 1024); + assert.strictEqual(pr.idx, 0); + }); + + it('should log data to buffer', function () { + const machine = new MockMachine(); + const pr = new ProbeRecorder(machine, 100); + + pr.log(0x12000080); // MEM_READ at address 0x80 + assert.strictEqual(pr.idx, 1); + assert.strictEqual(pr.buf[0], 0x12000080); + + pr.log(0x13000081); // MEM_WRITE at address 0x81 + assert.strictEqual(pr.idx, 2); + assert.strictEqual(pr.buf[1], 0x13000081); + }); + + it('should not exceed buffer length', function () { + const machine = new MockMachine(); + const pr = new ProbeRecorder(machine, 2); + + pr.log(0x12000080); + pr.log(0x13000081); + pr.log(0x14000082); // Should not write (buffer full) + + assert.strictEqual(pr.idx, 2); + assert.strictEqual(pr.buf[0], 0x12000080); + assert.strictEqual(pr.buf[1], 0x13000081); + }); + + it('should retrieve last operation', function () { + const machine = new MockMachine(); + const pr = new ProbeRecorder(machine, 100); + + pr.log(0x12000080); + pr.log(0x13000081); + + const lastOp = pr.lastOp(); + assert.strictEqual(lastOp, 0x13000000); // MEM_WRITE flag + }); + + it('should retrieve last address', function () { + const machine = new MockMachine(); + const pr = new ProbeRecorder(machine, 100); + + pr.log(0x12000080); + pr.log(0x13AABBCC); + + const lastAddr = pr.lastAddr(); + assert.strictEqual(lastAddr, 0xAABBCC); + }); + + it('should return -1 for lastOp on empty buffer', function () { + const machine = new MockMachine(); + const pr = new ProbeRecorder(machine, 100); + + const lastOp = pr.lastOp(); + assert.strictEqual(lastOp, -1); + }); + + it('should clear buffer', function () { + const machine = new MockMachine(); + const pr = new ProbeRecorder(machine, 100); + + pr.log(0x12000080); + pr.log(0x13000081); + assert.strictEqual(pr.idx, 2); + + pr.clear(); + assert.strictEqual(pr.idx, 0); + }); + + it('should reset buffer', function () { + const machine = new MockMachine(); + const pr = new ProbeRecorder(machine, 100); + + pr.log(0x12000080); + assert.strictEqual(pr.buf.length, 100); + + pr.reset(200); + assert.strictEqual(pr.buf.length, 200); + assert.strictEqual(pr.idx, 0); + }); + + it('should relog last entry', function () { + const machine = new MockMachine(); + const pr = new ProbeRecorder(machine, 100); + + pr.log(0x12000080); + pr.relog(0x13000080); + + assert.strictEqual(pr.idx, 1); + assert.strictEqual(pr.buf[0], 0x13000080); + }); + + it('should track scanline', function () { + const machine = new MockMachine(); + const pr = new ProbeRecorder(machine, 100); + + assert.strictEqual(pr.sl, 0); + pr.sl = 100; + assert.strictEqual(pr.sl, 100); + }); + + it('should track stack pointer', function () { + const machine = new MockMachine(); + const pr = new ProbeRecorder(machine, 100); + + assert.strictEqual(pr.cur_sp, -1); + pr.cur_sp = 0xFF; + assert.strictEqual(pr.cur_sp, 0xFF); + }); +}); + +describe('ProbeFlags Enumeration', function () { + it('should have correct flag values', function () { + assert.strictEqual(ProbeFlags.CLOCKS, 0x00000000); + assert.strictEqual(ProbeFlags.EXECUTE, 0x01000000); + assert.strictEqual(ProbeFlags.INTERRUPT, 0x08000000); + assert.strictEqual(ProbeFlags.MEM_READ, 0x12000000); + assert.strictEqual(ProbeFlags.MEM_WRITE, 0x13000000); + assert.strictEqual(ProbeFlags.FRAME, 0x7f000000); + }); + + it('should extract operation from probe value', function () { + const probeValue = 0x12000080; + const opcode = probeValue & 0xFF000000; + assert.strictEqual(opcode, ProbeFlags.MEM_READ); + + const address = probeValue & 0xFFFFFF; + assert.strictEqual(address, 0x80); + }); +}); + diff --git a/test/unit/testemu.ts b/test/unit/testemu.ts new file mode 100644 index 00000000..5bc32daa --- /dev/null +++ b/test/unit/testemu.ts @@ -0,0 +1,522 @@ +import assert from "assert"; +import { describe, it } from "mocha"; +import { + noise, getNoiseSeed, setNoiseSeed, KeyFlags, RAM, EmuHalt, + AnimationTimer, dumpRAM, Keys, makeKeycodeMap, newKeyboardHandler, + _setKeyboardEvents, padBytes, AddressDecoder, newAddressDecoder, + getMousePos, drawCrosshair, gtia_ntsc_to_rgb, +} from "../../src/common/emu"; + +describe('Noise PRNG', function () { + it('should produce a deterministic sequence from seed 1', function () { + setNoiseSeed(1); + const seq = []; + for (let i = 0; i < 8; i++) seq.push(noise()); + assert.deepStrictEqual(seq, [33, 1, 34, 25, 250, 201, 8, 180]); + }); + + it('should track and restore the seed', function () { + setNoiseSeed(0x1234); + assert.strictEqual(getNoiseSeed(), 0x1234); + noise(); + assert.notStrictEqual(getNoiseSeed(), 0x1234); + }); + + it('should return 0 forever for seed 0', function () { + setNoiseSeed(0); + assert.strictEqual(noise(), 0); + assert.strictEqual(noise(), 0); + assert.strictEqual(noise(), 0); + assert.strictEqual(getNoiseSeed(), 0); + }); + + it('should always return a byte', function () { + setNoiseSeed(1); + for (let i = 0; i < 100; i++) { + const n = noise(); + assert.ok(n >= 0 && n <= 255); + } + }); +}); + +describe('KeyFlags Enumeration', function () { + it('should have correct flag values', function () { + assert.strictEqual(KeyFlags.KeyDown, 1); + assert.strictEqual(KeyFlags.Shift, 2); + assert.strictEqual(KeyFlags.Ctrl, 4); + assert.strictEqual(KeyFlags.Alt, 8); + assert.strictEqual(KeyFlags.Meta, 16); + assert.strictEqual(KeyFlags.KeyUp, 64); + assert.strictEqual(KeyFlags.KeyPress, 128); + }); +}); + +describe('RAM Class', function () { + it('should allocate zero-initialized memory', function () { + const ram = new RAM(16); + assert.ok(ram.mem instanceof Uint8Array); + assert.strictEqual(ram.mem.length, 16); + assert.strictEqual(ram.mem[0], 0); + assert.strictEqual(ram.mem[15], 0); + }); + + it('should allow reading and writing', function () { + const ram = new RAM(4); + ram.mem[2] = 0xAA; + assert.strictEqual(ram.mem[2], 0xAA); + }); +}); + +describe('EmuHalt', function () { + it('should be an Error subclass with default properties', function () { + const e = new EmuHalt("Halt!"); + assert.ok(e instanceof Error); + assert.ok(e instanceof EmuHalt); + assert.strictEqual(e.message, "Halt!"); + assert.ok(e.squelchError); + assert.strictEqual(e.$loc, undefined); + }); + + it('should carry a source location', function () { + const e = new EmuHalt("bad opcode", { line: 42 } as any); + assert.deepStrictEqual(e.$loc, { line: 42 }); + }); + + it('should be catchable via instanceof', function () { + let caught = null; + try { + throw new EmuHalt("boom"); + } catch (err) { + caught = err; + } + assert.ok(caught instanceof EmuHalt); + assert.ok(caught instanceof Error); + assert.strictEqual(caught.message, "boom"); + }); +}); + +describe('dumpRAM', function () { + it('should format a Uint8Array dump', function () { + const ram = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]); + assert.strictEqual(dumpRAM(ram, 0, 16), + "$00: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F\n"); + }); + + it('should show the base offset in addresses', function () { + const ram = new Uint8Array([1, 2, 3]); + assert.strictEqual(dumpRAM(ram, 0x8000, 8), + "$8000: 01 02 03\n"); + }); + + it('should format a Uint16Array dump', function () { + const ram = new Uint16Array([0x1234, 0x5678, 0x9ABC, 0xDEF0, 0x1111, 0x2222, 0x3333, 0x4444]); + assert.strictEqual(dumpRAM(ram, 0, 16), + "$00: 1234 5678 9ABC DEF0 1111 2222 3333 4444\n$08:\n"); + }); + + it('should format a Float32Array dump with precision', function () { + const ram = new Float32Array([1.5, 2.25, 3.125, 0.5]); + assert.strictEqual(dumpRAM(ram, 0, 8), + "$00: 1.5000000 2.2500000 3.1250000 0.50000000\n$04:\n"); + }); + + it('should handle plain arrays (BYTES_PER_ELEMENT fallback)', function () { + assert.strictEqual(dumpRAM([0x0A, 0x0B], 0, 2), "$00: 0A 0B\n"); + }); +}); + +describe('Keys Table', function () { + it('should define directional and action keys', function () { + assert.strictEqual(Keys.UP.c, 38); + assert.strictEqual(Keys.DOWN.c, 40); + assert.strictEqual(Keys.LEFT.c, 37); + assert.strictEqual(Keys.RIGHT.c, 39); + assert.strictEqual(Keys.A.c, 32); // Space + assert.strictEqual(Keys.B.c, 16); // Shift + assert.strictEqual(Keys.SELECT.c, 220); + assert.strictEqual(Keys.START.c, 13); + assert.strictEqual(Keys.ANYKEY.c, 0); + }); + + it('should annotate gamepad mapping metadata', function () { + assert.strictEqual(Keys.UP.plyr, 0); + assert.strictEqual(Keys.UP.button, 12); + assert.strictEqual(Keys.UP.yaxis, -1); + assert.strictEqual(Keys.RIGHT.xaxis, 1); + assert.strictEqual(Keys.A.button, 0); + assert.strictEqual(Keys.P2_UP.plyr, 1); + assert.strictEqual(Keys.P2_UP.c, 87); // W + }); + + it('should define function keys and misc keys', function () { + assert.strictEqual(Keys.VK_ESCAPE.c, 27); + assert.strictEqual(Keys.VK_F1.c, 112); + assert.strictEqual(Keys.VK_F12.c, 123); + assert.strictEqual(Keys.VK_ENTER.c, 13); + assert.strictEqual(Keys.VK_SPACE.c, 32); + }); + + it('should give every key a name and numeric code', function () { + const entries = Object.keys(Keys); + assert.ok(entries.length >= 100); + for (const name of entries) { + assert.ok(name.length > 0); + const def = Keys[name]; + assert.strictEqual(typeof def.c, 'number'); + assert.ok(def.n.length > 0, `key ${name} should have a name`); + } + }); +}); + +describe('makeKeycodeMap', function () { + it('should build a map keyed by keycode', function () { + const map = makeKeycodeMap([[Keys.A, 0, 0x01], [Keys.B, 0, 0x02]]); + assert.ok(map instanceof Map); + assert.strictEqual(map[32].index, 0); + assert.strictEqual(map[32].mask, 0x01); + assert.strictEqual(map[32].def, Keys.A); + assert.strictEqual(map[16].index, 0); + assert.strictEqual(map[16].mask, 0x02); + assert.strictEqual(map[9999], undefined); + }); +}); + +describe('newKeyboardHandler', function () { + it('should set switches on keydown and clear on keyup', function () { + const map = makeKeycodeMap([[Keys.A, 0, 0x01]]); + const switches = new Uint8Array(2); + const handler = newKeyboardHandler(switches, map); + handler(32, 0, KeyFlags.KeyDown); // A (Space) + assert.strictEqual(switches[0], 0x01); + handler(32, 0, KeyFlags.KeyDown); // idempotent + assert.strictEqual(switches[0], 0x01); + handler(32, 0, KeyFlags.KeyUp); + assert.strictEqual(switches[0], 0x00); + }); + + it('should support multiple keys and switch indices', function () { + const map = makeKeycodeMap([ + [Keys.A, 0, 0x01], + [Keys.B, 0, 0x02], + [Keys.UP, 1, 0x80], + ]); + const switches = new Uint8Array(2); + const handler = newKeyboardHandler(switches, map); + handler(32, 0, KeyFlags.KeyDown); + handler(16, 0, KeyFlags.KeyDown); + handler(38, 0, KeyFlags.KeyDown); + assert.strictEqual(switches[0], 0x03); + assert.strictEqual(switches[1], 0x80); + handler(32, 0, KeyFlags.KeyUp); + assert.strictEqual(switches[0], 0x02); + }); + + it('should support active-low (negative mask) switches', function () { + const map = makeKeycodeMap([[Keys.START, 0, -0x01]]); + const switches = new Uint8Array(1); + const handler = newKeyboardHandler(switches, map); + switches[0] = 0x01; + handler(13, 0, KeyFlags.KeyDown); // active-low press clears the bit + assert.strictEqual(switches[0], 0x00); + handler(13, 0, KeyFlags.KeyUp); // release sets the bit + assert.strictEqual(switches[0], 0x01); + }); + + it('should fall back to ANYKEY entry for unknown keys', function () { + const map = makeKeycodeMap([[Keys.ANYKEY, 0, 0x40]]); + const switches = new Uint8Array(1); + const handler = newKeyboardHandler(switches, map); + handler(9999, 0, KeyFlags.KeyDown); + assert.strictEqual(switches[0], 0x40); + handler(9999, 0, KeyFlags.KeyUp); + assert.strictEqual(switches[0], 0x00); + }); + + it('should ignore unmapped keys when no ANYKEY entry exists', function () { + const map = makeKeycodeMap([[Keys.A, 0, 0x01]]); + const switches = new Uint8Array(1); + const handler = newKeyboardHandler(switches, map); + handler(9999, 0, KeyFlags.KeyDown); + assert.strictEqual(switches[0], 0x00); + }); + + it('should invoke the callback function with key info', function () { + const map = makeKeycodeMap([[Keys.A, 0, 0x01]]); + const switches = new Uint8Array(1); + const calls = []; + const handler = newKeyboardHandler(switches, map, (o, key, code, flags) => { + calls.push([o && o.def.n, key, code, flags]); + }); + handler(32, 65, KeyFlags.KeyDown | KeyFlags.Shift); + assert.deepStrictEqual(calls, [['Space', 32, 65, KeyFlags.KeyDown | KeyFlags.Shift]]); + }); + + it('should invoke callback for unmapped keys only with alwaysfunc', function () { + const map = makeKeycodeMap([[Keys.A, 0, 0x01]]); + const switches = new Uint8Array(1); + const calls = []; + const handler = newKeyboardHandler(switches, map, (o) => { + calls.push(o ? o.def.n : null); + }, true); + handler(9999, 0, KeyFlags.KeyDown); + assert.deepStrictEqual(calls, [null]); + assert.strictEqual(switches[0], 0x00); + }); + + it('should pass through to callback when no map is given', function () { + const switches = new Uint8Array(1); + const calls = []; + const handler = newKeyboardHandler(switches, null, (o, key, code, flags) => { + calls.push([o, key, code, flags]); + }); + handler(65, 65, KeyFlags.KeyDown); + assert.deepStrictEqual(calls, [[null, 65, 65, KeyFlags.KeyDown]]); + }); +}); + +describe('_setKeyboardEvents', function () { + it('should wire keydown and keyup handlers', function () { + const calls = []; + const canvas: any = { onkeydown: null, onkeyup: null }; + _setKeyboardEvents(canvas, (which, keyCode, flags) => { + calls.push([which, keyCode, flags]); + }); + canvas.onkeydown({ which: 65, keyCode: 65, shiftKey: false, ctrlKey: false, altKey: false, metaKey: false, preventDefault() { } }); + assert.deepStrictEqual(calls[0], [65, 65, KeyFlags.KeyDown]); + canvas.onkeyup({ which: 65, keyCode: 65, shiftKey: true, ctrlKey: false, altKey: false, metaKey: false, preventDefault() { } }); + assert.deepStrictEqual(calls[1], [65, 65, KeyFlags.KeyUp | KeyFlags.Shift]); + }); + + it('should pass through modifier flags', function () { + const calls = []; + const canvas: any = { onkeydown: null, onkeyup: null }; + _setKeyboardEvents(canvas, (which, keyCode, flags) => { + calls.push(flags); + }); + canvas.onkeydown({ which: 13, keyCode: 13, shiftKey: true, ctrlKey: true, altKey: true, metaKey: true, preventDefault() { } }); + assert.strictEqual(calls[0], KeyFlags.KeyDown | KeyFlags.Shift | KeyFlags.Ctrl | KeyFlags.Alt | KeyFlags.Meta); + }); + + it('should preventDefault on unmodified keydown only', function () { + const prevented = []; + const canvas: any = { onkeydown: null, onkeyup: null }; + _setKeyboardEvents(canvas, () => { }); + canvas.onkeydown({ which: 32, keyCode: 32, shiftKey: false, ctrlKey: false, altKey: false, metaKey: false, preventDefault() { prevented.push('plain'); } }); + canvas.onkeydown({ which: 32, keyCode: 32, shiftKey: true, ctrlKey: false, altKey: false, metaKey: false, preventDefault() { prevented.push('shift'); } }); + assert.deepStrictEqual(prevented, ['plain']); + }); +}); + +describe('padBytes', function () { + it('should pad at the end by default', function () { + const out = padBytes([1, 2, 3], 8); + assert.ok(out instanceof Uint8Array); + assert.deepStrictEqual(Array.from(out), [1, 2, 3, 0, 0, 0, 0, 0]); + }); + + it('should pad at the start when requested', function () { + const out = padBytes(new Uint8Array([1, 2, 3]), 8, true); + assert.deepStrictEqual(Array.from(out), [0, 0, 0, 0, 0, 1, 2, 3]); + }); + + it('should return unchanged data when length matches', function () { + const out = padBytes([0xAA, 0xBB], 2); + assert.deepStrictEqual(Array.from(out), [0xAA, 0xBB]); + }); + + it('should throw when data is too long', function () { + assert.throws(() => padBytes([1, 2, 3], 2), /Data too long, 3 > 2/); + }); +}); + +describe('AddressDecoder', function () { + it('should decode reads from a table', function () { + const ram = new Uint8Array(256); + ram[0x10] = 0x5A; + const decode = newAddressDecoder([[0x0000, 0x00FF, 0, (a) => ram[a]]]); + assert.strictEqual(decode(0x10), 0x5A); + assert.strictEqual(decode(0x99), 0x00); + }); + + it('should return default value for out-of-range addresses', function () { + const ram = new Uint8Array(256); + const decode = newAddressDecoder([[0x0000, 0x00FF, 0, (a) => ram[a]]]); + assert.strictEqual(decode(0x1000), 0); + }); + + it('should apply address masks', function () { + const ram = new Uint8Array(0x800); + ram[0x000] = 0x11; + ram[0x7FF] = 0x22; + const decode = newAddressDecoder([[0x0000, 0x0FFF, 0x07FF, (a) => ram[a]]]); + assert.strictEqual(decode(0x0000), 0x11); + assert.strictEqual(decode(0x0800), 0x11); // 0x0800 & 0x07FF = 0x0000 + assert.strictEqual(decode(0x0FFF), 0x22); // 0x0FFF & 0x07FF = 0x07FF + }); + + it('should support write functions and mask the result', function () { + const ram = new Uint8Array(0x800); + const decode = newAddressDecoder([[0x0000, 0x0FFF, 0x07FF, + (a, v) => { if (v !== undefined) ram[a] = v; return ram[a]; }]]); + decode(0x0801, 0x99); // writes to 0x0801 & 0x07FF = 0x0001 + assert.strictEqual(ram[0x001], 0x99); + assert.strictEqual(ram[0x801], undefined); + assert.strictEqual(decode(0x0801), 0x99); + }); + + it('should apply a global mask before decoding', function () { + const ram = new Uint8Array(0x100); + ram[0x00] = 0x11; + ram[0x20] = 0x22; + const decode = newAddressDecoder([[0x0000, 0x00FF, 0, (a) => ram[a]]], + { gmask: 0x0FFF, defaultval: 0xFF }); + assert.strictEqual(decode(0x1000), 0x11); // 0x1000 & 0x0FFF = 0x0000 + assert.strictEqual(decode(0x0020), 0x22); + assert.strictEqual(decode(0x1234), 0xFF); // masked to 0x0234, out of range + }); + + it('should handle an empty table with a default value', function () { + const decode = newAddressDecoder([], { defaultval: 0xEE }); + assert.strictEqual(decode(0x0000), 0xEE); + const decode2 = newAddressDecoder([]); + assert.strictEqual(decode2(0x0000), 0); + }); + + it('should be usable as a constructor directly', function () { + const decode = new (AddressDecoder as any)([[0x0000, 0xFFFF, 0, () => 0x42]]); + assert.strictEqual(decode(0x1234), 0x42); + }); +}); + +describe('AnimationTimer', function () { + it('should invoke callback at scheduled intervals', function () { + let count = 0; + const timer = new AnimationTimer(100, () => count++); // 10ms per frame + timer.running = true; + timer.nextFrame(50); // ts > nextts(0) + assert.strictEqual(count, 1); + timer.nextFrame(60); // ts(60) < nextts(100) + assert.strictEqual(count, 2); + timer.nextFrame(150); // ts > nextts(100) + assert.strictEqual(count, 3); // + timer.running = false; + }); + + it('should not invoke callback when not running', function () { + let count = 0; + const timer = new AnimationTimer(100, () => count++); + timer.nextFrame(50); + assert.strictEqual(count, 0); + timer.running = true; + timer.nextFrame(60); + assert.strictEqual(count, 1); + timer.running = false; + }); + + it('should catch up after large gaps', function () { + let count = 0; + const timer = new AnimationTimer(100, () => count++); + timer.running = true; + timer.nextts = 500; + timer.nextFrame(3000); // > 1s gap: should catch up + assert.strictEqual(count, 1); + assert.strictEqual(timer.nextts, 3000); + timer.running = false; + }); + + it('should set frame timing properties', function () { + const timer = new AnimationTimer(60, () => { }); + assert.strictEqual(timer.frameRate, 60); + assert.strictEqual(timer.intervalMsec, 1000 / 60); + }); + + it('should start/stop and report running state', function () { + const timer = new AnimationTimer(60, () => { }); + assert.strictEqual(timer.isRunning(), false); + timer.start(); + assert.strictEqual(timer.isRunning(), true); + timer.stop(); + assert.strictEqual(timer.isRunning(), false); + }); +}); + +describe('drawCrosshair', function () { + it('should no-op without setLineDash (unit testing support)', function () { + let called = false; + const ctx: any = { fillRect: () => { called = true; } }; + drawCrosshair(ctx, 10, 20, 2); + assert.strictEqual(called, false); + }); + + it('should draw vertical and horizontal lines', function () { + const calls: any[][] = []; + const ctx: any = { + fillStyle: '', strokeStyle: '', lineWidth: 0, + setLineDash: (d) => calls.push(['setLineDash', String(d)]), + fillRect: (x, y, w, h) => calls.push(['fillRect', x, y, w, h]), + beginPath: () => calls.push(['beginPath']), + moveTo: (x, y) => calls.push(['moveTo', x, y]), + lineTo: (x, y) => calls.push(['lineTo', x, y]), + stroke: () => calls.push(['stroke']), + }; + drawCrosshair(ctx, 10, 20, 2); + assert.ok(calls.some(c => c[0] === 'fillRect' && c[1] === 8), 'vertical bar at x-2'); + assert.ok(calls.some(c => c[0] === 'fillRect' && c[2] === 18), 'horizontal bar at y-2'); + assert.ok(calls.some(c => c[0] === 'moveTo' && c[1] === 10), 'vertical line at x'); + assert.ok(calls.some(c => c[0] === 'moveTo' && c[2] === 20), 'horizontal line at y'); + assert.ok(calls.some(c => c[0] === 'setLineDash' && c[1] === '4,4'), 'dash pattern 2*width'); + assert.ok(calls.some(c => c[0] === 'stroke'), 'stroke called'); + }); +}); + +describe('getMousePos', function () { + const realWindow = (global as any).window; + + after(function () { + if (realWindow === undefined) delete (global as any).window; + else (global as any).window = realWindow; + }); + + function stubWindow() { + (global as any).window = { + getComputedStyle: () => ({ + borderLeftWidth: '0px', borderRightWidth: '0px', + borderTopWidth: '0px', borderBottomWidth: '0px', + paddingLeft: '0px', paddingRight: '0px', + paddingTop: '0px', paddingBottom: '0px', + }) + }; + } + + it('should clamp out-of-bounds coordinates', function () { + stubWindow(); + const canvas: any = { + width: 100, height: 100, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }) + }; + const pos = getMousePos(canvas, { clientX: 10000, clientY: -100 }); + assert.deepStrictEqual(pos, { x: 100, y: 0 }); + }); + + it('should account for borders and padding', function () { + (global as any).window = { + getComputedStyle: () => ({ + borderLeftWidth: '2px', borderRightWidth: '2px', + borderTopWidth: '2px', borderBottomWidth: '2px', + paddingLeft: '2px', paddingRight: '2px', + paddingTop: '2px', paddingBottom: '2px', + }) + }; + const canvas: any = { + width: 100, height: 100, + getBoundingClientRect: () => ({ left: 10, top: 10, width: 108, height: 108 }) + }; + // content origin = rect.left + border(2) + padding(2) = 14 + let pos = getMousePos(canvas, { clientX: 14, clientY: 14 }); + assert.deepStrictEqual(pos, { x: 0, y: 0 }); + // content far edge + pos = getMousePos(canvas, { clientX: 14 + 99, clientY: 14 + 99 }); + assert.ok(pos.x >= 99 && pos.x <= 100); + }); +}); +