From 0ee6f008fde2c5984a4cd4113a101c0fb2c8b6e9 Mon Sep 17 00:00:00 2001 From: Mateusz Cholewka Date: Sun, 16 Aug 2026 14:13:55 +0200 Subject: [PATCH] Implement unit tests --- tests/Unit/EventBusTest.php | 145 ++++++++++++++ tests/Unit/MessageFormatterTest.php | 124 ++++++++++++ tests/Unit/PhpHighlighterTest.php | 98 ++++++++++ tests/Unit/Rpc/JsonRpcRequestTest.php | 32 ++++ tests/Unit/State/AppStateTest.php | 261 ++++++++++++++++++++++++++ 5 files changed, 660 insertions(+) create mode 100644 tests/Unit/EventBusTest.php create mode 100644 tests/Unit/MessageFormatterTest.php create mode 100644 tests/Unit/PhpHighlighterTest.php create mode 100644 tests/Unit/Rpc/JsonRpcRequestTest.php create mode 100644 tests/Unit/State/AppStateTest.php diff --git a/tests/Unit/EventBusTest.php b/tests/Unit/EventBusTest.php new file mode 100644 index 0000000..fc1647b --- /dev/null +++ b/tests/Unit/EventBusTest.php @@ -0,0 +1,145 @@ +listen('custom', function ($data) use (&$received) { + $received = $data; + }); + + $bus->emit('custom', ['foo' => 'bar']); + + expect($received)->toBe(['foo' => 'bar']); + }); + + it('does nothing when emitting an event with no listeners', function () { + $bus = new EventBus; + + $bus->emit('nobody-listens'); + + expect(true)->toBeTrue(); + }); + + it('calls every listener registered for the same event, in order', function () { + $bus = new EventBus; + $calls = []; + $bus->listen('tick', function () use (&$calls) { + $calls[] = 'first'; + }); + $bus->listen('tick', function () use (&$calls) { + $calls[] = 'second'; + }); + + $bus->emit('tick'); + + expect($calls)->toBe(['first', 'second']); + }); +}); + +describe('KeyCode/MouseEventKind registration', function () { + it('registers a KeyCode listener under its enum name', function () { + $bus = new EventBus; + $fired = false; + $bus->listen(KeyCode::Enter, function () use (&$fired) { + $fired = true; + }); + + $bus->emit('Enter'); + + expect($fired)->toBeTrue(); + }); + + it('registers a MouseEventKind listener under a "Mouse"-prefixed name', function () { + $bus = new EventBus; + $fired = false; + $bus->listen(MouseEventKind::Down, function () use (&$fired) { + $fired = true; + }); + + $bus->emit('MouseDown'); + + expect($fired)->toBeTrue(); + }); +}); + +describe('CharKeyEvent dispatch', function () { + it('dispatches keyed by the typed character and merges modifiers into the data', function () { + $bus = new EventBus; + $received = null; + $bus->listen('a', function ($data) use (&$received) { + $received = $data; + }); + + $bus->emit(CharKeyEvent::new('a', KeyModifiers::SHIFT)); + + expect($received)->toBe(['modifiers' => KeyModifiers::SHIFT]); + }); + + it('lets extra data passed to emit override the default modifiers key', function () { + $bus = new EventBus; + $received = null; + $bus->listen('b', function ($data) use (&$received) { + $received = $data; + }); + + $bus->emit(CharKeyEvent::new('b'), ['modifiers' => 'overridden']); + + expect($received)->toBe(['modifiers' => 'overridden']); + }); +}); + +describe('CodedKeyEvent dispatch', function () { + it('dispatches keyed by the KeyCode enum name', function () { + $bus = new EventBus; + $fired = false; + $bus->listen(KeyCode::Esc->name, function () use (&$fired) { + $fired = true; + }); + + $bus->emit(CodedKeyEvent::new(KeyCode::Esc)); + + expect($fired)->toBeTrue(); + }); +}); + +describe('FunctionKeyEvent dispatch', function () { + it('dispatches keyed by "F{number}"', function () { + $bus = new EventBus; + $fired = false; + $bus->listen('F5', function () use (&$fired) { + $fired = true; + }); + + $bus->emit(FunctionKeyEvent::new(5)); + + expect($fired)->toBeTrue(); + }); +}); + +describe('MouseEvent dispatch', function () { + it('dispatches keyed by "Mouse{kind}" and passes the event back in the data', function () { + $bus = new EventBus; + $received = null; + $bus->listen('MouseScrollUp', function ($data) use (&$received) { + $received = $data; + }); + + $event = MouseEvent::new(MouseEventKind::ScrollUp, MouseButton::None, column: 3, row: 4, modifiers: 0); + $bus->emit($event); + + expect($received)->toBe(['event' => $event]); + }); +}); diff --git a/tests/Unit/MessageFormatterTest.php b/tests/Unit/MessageFormatterTest.php new file mode 100644 index 0000000..a784089 --- /dev/null +++ b/tests/Unit/MessageFormatterTest.php @@ -0,0 +1,124 @@ + $s->content, $spans)); +} + +function formattedJoined(array $lines): string +{ + return implode("\n", array_map( + fn (Line $line): string => implode('', array_map(fn (Span $s): string => $s->content, $line->spans)), + $lines, + )); +} + +describe('colorizeInlineJson', function () { + it('renders a JSON string as quoted spans', function () { + $spans = MessageFormatter::colorizeInlineJson('"hello"'); + + expect(inlineJoined($spans))->toBe('"hello"'); + }); + + it('renders a JSON number', function () { + $spans = MessageFormatter::colorizeInlineJson('42'); + + expect(inlineJoined($spans))->toBe('42'); + }); + + it('renders JSON booleans as bare true/false', function () { + expect(inlineJoined(MessageFormatter::colorizeInlineJson('true')))->toBe('true') + ->and(inlineJoined(MessageFormatter::colorizeInlineJson('false')))->toBe('false'); + }); + + it('renders JSON null as the bare word null', function () { + $spans = MessageFormatter::colorizeInlineJson('null'); + + expect(inlineJoined($spans))->toBe('null'); + }); + + it('renders a JSON list with brackets and comma separators', function () { + $spans = MessageFormatter::colorizeInlineJson('[1,2,3]'); + + expect(inlineJoined($spans))->toBe('[1, 2, 3]'); + }); + + it('renders a JSON object with braces and quoted keys', function () { + $spans = MessageFormatter::colorizeInlineJson('{"a":1,"b":2}'); + + expect(inlineJoined($spans))->toBe('{"a": 1, "b": 2}'); + }); + + it('renders nested structures recursively', function () { + $spans = MessageFormatter::colorizeInlineJson('{"list":[1,{"x":"y"}]}'); + + expect(inlineJoined($spans))->toBe('{"list": [1, {"x": "y"}]}'); + }); + + it('falls back to a single error-styled span for invalid JSON', function () { + $spans = MessageFormatter::colorizeInlineJson('{not valid json'); + + expect($spans)->toHaveCount(1) + ->and($spans[0]->content)->toBe('{not valid json') + ->and($spans[0]->style->fg->toHex())->toBe('#'.Palette::ERROR); + }); +}); + +describe('colorizeFormattedJson', function () { + it('renders a scalar as a single line', function () { + $lines = MessageFormatter::colorizeFormattedJson('"hi"'); + + expect($lines)->toHaveCount(1) + ->and(formattedJoined($lines))->toBe('"hi"'); + }); + + it('renders an object across multiple indented lines with closing brace', function () { + $lines = MessageFormatter::colorizeFormattedJson('{"a":1,"b":2}'); + + expect(formattedJoined($lines))->toBe( + "{\n" + .' "a": 1'."\n" + .' "b": 2'."\n" + .'}' + ); + }); + + it('renders a list using square brackets without keys', function () { + $lines = MessageFormatter::colorizeFormattedJson('[1,2]'); + + expect(formattedJoined($lines))->toBe( + "[\n" + .' 1'."\n" + .' 2'."\n" + .']' + ); + }); + + it('indents nested objects one level deeper than their parent', function () { + $lines = MessageFormatter::colorizeFormattedJson('{"a":{"b":1}}'); + + expect(formattedJoined($lines))->toBe( + "{\n" + .' "a": '."\n" + .' {'."\n" + .' "b": 1'."\n" + .' }'."\n" + .'}' + ); + }); + + it('falls back to a single error-styled line for invalid JSON', function () { + $lines = MessageFormatter::colorizeFormattedJson('{broken'); + + expect($lines)->toHaveCount(1) + ->and(formattedJoined($lines))->toBe('{broken') + ->and($lines[0]->spans[0]->style->fg->toHex())->toBe('#'.Palette::ERROR); + }); +}); diff --git a/tests/Unit/PhpHighlighterTest.php b/tests/Unit/PhpHighlighterTest.php new file mode 100644 index 0000000..ef6216e --- /dev/null +++ b/tests/Unit/PhpHighlighterTest.php @@ -0,0 +1,98 @@ +content === $content) { + return $span; + } + } + + return null; +} + +describe('highlightLine', function () { + it('reconstructs the original line when spans are joined back together', function () { + $line = 'function foo($bar) { return $bar; }'; + + $spans = PhpHighlighter::highlightLine($line); + + expect(implode('', array_map(fn ($s) => $s->content, $spans)))->toBe($line); + }); + + it('colors a language keyword', function () { + $spans = PhpHighlighter::highlightLine('return $x;'); + + $span = spanFor($spans, 'return'); + + expect($span)->not->toBeNull() + ->and($span->style->fg->toHex())->toBe('#bb9af7'); + }); + + it('colors a variable', function () { + $spans = PhpHighlighter::highlightLine('$foo = 1;'); + + $span = spanFor($spans, '$foo'); + + expect($span)->not->toBeNull() + ->and($span->style->fg->toHex())->toBe('#7dcfff'); + }); + + it('colors a string literal', function () { + $spans = PhpHighlighter::highlightLine("\$x = 'hello';"); + + $span = spanFor($spans, "'hello'"); + + expect($span)->not->toBeNull() + ->and($span->style->fg->toHex())->toBe('#9ece6a'); + }); + + it('colors an integer literal', function () { + $spans = PhpHighlighter::highlightLine('$x = 42;'); + + $span = spanFor($spans, '42'); + + expect($span)->not->toBeNull() + ->and($span->style->fg->toHex())->toBe('#ff9e64'); + }); + + it('colors a comment', function () { + $spans = PhpHighlighter::highlightLine('$x = 1; // note'); + + $span = spanFor($spans, '// note'); + + expect($span)->not->toBeNull() + ->and($span->style->fg->toHex())->toBe('#565f89'); + }); + + it('colors bare keywords like true/false/null as keywords, not plain identifiers', function () { + $spans = PhpHighlighter::highlightLine('$x = true;'); + + $span = spanFor($spans, 'true'); + + expect($span)->not->toBeNull() + ->and($span->style->fg->toHex())->toBe('#bb9af7'); + }); + + it('colors a function call name differently from a bare identifier', function () { + $spans = PhpHighlighter::highlightLine('strlen($x);'); + + $span = spanFor($spans, 'strlen'); + + expect($span)->not->toBeNull() + ->and($span->style->fg->toHex())->toBe('#7aa2f7'); + }); + + it('falls back to the default text color for a plain identifier that is not a call', function () { + $spans = PhpHighlighter::highlightLine('$x = SOME_CONST;'); + + $span = spanFor($spans, 'SOME_CONST'); + + expect($span)->not->toBeNull() + ->and($span->style->fg->toHex())->toBe('#c0caf5'); + }); +}); diff --git a/tests/Unit/Rpc/JsonRpcRequestTest.php b/tests/Unit/Rpc/JsonRpcRequestTest.php new file mode 100644 index 0000000..d1468a4 --- /dev/null +++ b/tests/Unit/Rpc/JsonRpcRequestTest.php @@ -0,0 +1,32 @@ + 'hi']); + + $payload = $request->payload(); + + expect($payload['jsonrpc'])->toBe('2.0') + ->and($payload['method'])->toBe('log') + ->and($payload['params'])->toBe(['message' => 'hi']); + }); + + it('generates a unique id when none is given', function () { + $a = (new JsonRpcRequest('log', []))->payload(); + $b = (new JsonRpcRequest('log', []))->payload(); + + expect($a['id'])->toBeString() + ->and($a['id'])->not->toBe('') + ->and($a['id'])->not->toBe($b['id']); + }); + + it('uses the given id instead of generating one', function () { + $payload = (new JsonRpcRequest('wait', [], id: 'fixed-id'))->payload(); + + expect($payload['id'])->toBe('fixed-id'); + }); +}); diff --git a/tests/Unit/State/AppStateTest.php b/tests/Unit/State/AppStateTest.php new file mode 100644 index 0000000..a8b6b29 --- /dev/null +++ b/tests/Unit/State/AppStateTest.php @@ -0,0 +1,261 @@ +version)->toBe('1.2.3') + ->and($state->port)->toBe(4321); + }); + + it('writes properties through __set and notifies the change callback', function () { + $state = new AppState; + $calls = 0; + $state->setOnChange(function () use (&$calls) { + $calls++; + }); + + $state->cursor = 5; + + expect($state->cursor)->toBe(5) + ->and($calls)->toBe(1); + }); +}); + +describe('observe', function () { + it('invokes the observer with the new value when the field changes', function () { + $state = new AppState; + $seen = null; + $state->observe('cursor', function ($value) use (&$seen) { + $seen = $value; + }); + + $state->cursor = 3; + + expect($seen)->toBe(3); + }); + + it('supports multiple observers on the same field', function () { + $state = new AppState; + $calls = []; + $state->observe('offset', function ($v) use (&$calls) { + $calls[] = "a:{$v}"; + }); + $state->observe('offset', function ($v) use (&$calls) { + $calls[] = "b:{$v}"; + }); + + $state->offset = 2; + + expect($calls)->toBe(['a:2', 'b:2']); + }); + + it('throws when observing a field that does not exist', function () { + $state = new AppState; + + $state->observe('doesNotExist', fn () => null); + })->throws(RuntimeException::class, 'doesNotExist is not defined.'); +}); + +describe('appendLog', function () { + it('appends a new log entry and returns true', function () { + $state = new AppState; + + $result = $state->appendLog(makeLogItem('first')); + + expect($result)->toBeTrue() + ->and($state->logs())->toHaveCount(1) + ->and($state->logs()[0]->message)->toBe('first'); + }); + + it('merges consecutive identical entries and bumps the repeat counter instead of appending', function () { + $state = new AppState; + $state->appendLog(makeLogItem('same', 'log', 'a.php:1')); + + $result = $state->appendLog(makeLogItem('same', 'log', 'a.php:1')); + + expect($result)->toBeFalse() + ->and($state->logs())->toHaveCount(1) + ->and($state->logs()[0]->repeatCount)->toBe(2); + }); + + it('does not merge entries that differ in kind, message, or caller', function () { + $state = new AppState; + $state->appendLog(makeLogItem('same', 'log', 'a.php:1')); + $state->appendLog(makeLogItem('same', 'error', 'a.php:1')); + $state->appendLog(makeLogItem('different', 'log', 'a.php:1')); + $state->appendLog(makeLogItem('same', 'log', 'b.php:2')); + + expect($state->logs())->toHaveCount(4); + }); + + it('only compares against the immediately preceding entry, not the whole history', function () { + $state = new AppState; + $state->appendLog(makeLogItem('a')); + $state->appendLog(makeLogItem('b')); + $result = $state->appendLog(makeLogItem('a')); + + expect($result)->toBeTrue() + ->and($state->logs())->toHaveCount(3); + }); + + it('notifies the "logs" observer when a log is appended', function () { + $state = new AppState; + $notified = false; + $state->observe('logs', function () use (&$notified) { + $notified = true; + }); + + $state->appendLog(makeLogItem()); + + expect($notified)->toBeTrue(); + }); +}); + +describe('filteredLogs', function () { + it('returns all logs unfiltered when filter is empty', function () { + $state = new AppState; + $state->appendLog(makeLogItem('alpha')); + $state->appendLog(makeLogItem('beta')); + + expect($state->filteredLogs())->toHaveCount(2); + }); + + it('filters logs by case-insensitive substring match on the message', function () { + $state = new AppState; + $state->appendLog(makeLogItem('Alpha Version')); + $state->appendLog(makeLogItem('beta version')); + $state->filter = 'ALPHA'; + + $result = $state->filteredLogs(); + + expect($result)->toHaveCount(1) + ->and($result[0]->message)->toBe('Alpha Version'); + }); + + it('returns an empty array when nothing matches', function () { + $state = new AppState; + $state->appendLog(makeLogItem('alpha')); + $state->filter = 'zzz'; + + expect($state->filteredLogs())->toBe([]); + }); +}); + +describe('deffer/commit batching', function () { + it('suppresses the change callback and observers while batching', function () { + $state = new AppState; + $changeCalls = 0; + $observerCalls = 0; + $state->setOnChange(function () use (&$changeCalls) { + $changeCalls++; + }); + $state->observe('cursor', function () use (&$observerCalls) { + $observerCalls++; + }); + + $state->deffer(); + $state->cursor = 1; + $state->cursor = 2; + $state->cursor = 3; + + expect($changeCalls)->toBe(0) + ->and($observerCalls)->toBe(0) + ->and($state->cursor)->toBe(3); + }); + + it('replays each changed field observer exactly once on commit, regardless of write count', function () { + $state = new AppState; + $observerCalls = 0; + $state->observe('cursor', function () use (&$observerCalls) { + $observerCalls++; + }); + + $state->deffer(); + $state->cursor = 1; + $state->cursor = 2; + $state->cursor = 3; + $state->commit(); + + expect($observerCalls)->toBe(1); + }); + + it('fires the change callback exactly once on commit', function () { + $state = new AppState; + $changeCalls = 0; + $state->setOnChange(function () use (&$changeCalls) { + $changeCalls++; + }); + + $state->deffer(); + $state->cursor = 1; + $state->offset = 1; + $state->commit(); + + expect($changeCalls)->toBe(1); + }); + + it('does not replay observers for fields that were not touched while batching', function () { + $state = new AppState; + $offsetCalls = 0; + $state->observe('offset', function () use (&$offsetCalls) { + $offsetCalls++; + }); + + $state->deffer(); + $state->cursor = 1; + $state->commit(); + + expect($offsetCalls)->toBe(0); + }); + + it('batches appendLog under the "logs" key like any other field', function () { + $state = new AppState; + $logsCalls = 0; + $state->observe('logs', function () use (&$logsCalls) { + $logsCalls++; + }); + + $state->deffer(); + $state->appendLog(makeLogItem('a')); + $state->appendLog(makeLogItem('b')); + $state->commit(); + + expect($logsCalls)->toBe(1) + ->and($state->logs())->toHaveCount(2); + }); + + it('resumes immediate notification after commit', function () { + $state = new AppState; + $observerCalls = 0; + $state->observe('cursor', function () use (&$observerCalls) { + $observerCalls++; + }); + + $state->deffer(); + $state->cursor = 1; + $state->commit(); + $state->cursor = 2; + + expect($observerCalls)->toBe(2); + }); +});