diff --git a/README.md b/README.md index a02b15b..2fde72e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml) TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel. -It allows you to manage TRMNL devices, generate screens using native plugins, recipes (45+ from the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)), or the API, and can also act as a proxy for the native cloud service (Core). With over 15k downloads and 100+ stars, it’s the most popular community-driven BYOS. +It allows you to manage TRMNL devices, generate screens using native plugins, recipes (45+ from the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)), or the API, and can also act as a proxy for the native cloud service (Core). ![Screenshot](README_byos-screenshot.png) ![Screenshot](README_byos-screenshot-dark.png) @@ -28,7 +28,6 @@ It allows you to manage TRMNL devices, generate screens using native plugins, re * This enables a hybrid setup – for example, you can update your custom Train Monitor every 5 minutes in the morning, while displaying native TRMNL plugins throughout the day. * πŸŒ™ Dark Mode – Switch between light and dark mode. * 🐳 Deployment – Dockerized setup for easier hosting (Dockerfile, docker-compose). -* πŸ’Ύ Flexible Database configuration – uses SQLite by default, also compatible with MySQL or PostgreSQL * πŸ› οΈ Devcontainer support for easier development. ![Devices](README_byos-devices.jpeg) @@ -44,6 +43,8 @@ or [GitHub Sponsors](https://github.com/sponsors/bnussbau/) +If you are looking for a Laravel package designed to streamline the development of both public and private TRMNL plugins, check out [bnussbau/trmnl-laravel](https://github.com/bnussbau/laravel-trmnl). + ### Hosting Run everywhere, where Docker is supported: Raspberry Pi, VPS, NAS, Container Cloud Service (Cloud Run, ...). diff --git a/app/Liquid/Filters/Data.php b/app/Liquid/Filters/Data.php index dd81ad8..3fb695a 100644 --- a/app/Liquid/Filters/Data.php +++ b/app/Liquid/Filters/Data.php @@ -2,7 +2,6 @@ namespace App\Liquid\Filters; -use App\Liquid\Utils\ExpressionUtils; use Keepsuit\Liquid\Filters\FiltersProvider; /** @@ -90,47 +89,4 @@ class Data extends FiltersProvider { return json_decode($json, true); } - - /** - * Filter a collection using an expression - * - * @param mixed $input The collection to filter - * @param string $variable The variable name to use in the expression - * @param string $expression The expression to evaluate - * @return array The filtered collection - */ - public function where_exp(mixed $input, string $variable, string $expression): array - { - // Return input as-is if it's not an array or doesn't have values method - if (! is_array($input)) { - return is_string($input) ? [$input] : []; - } - - // Convert hash to array of values if needed - if (ExpressionUtils::isAssociativeArray($input)) { - $input = array_values($input); - } - - $condition = ExpressionUtils::parseCondition($expression); - $result = []; - - foreach ($input as $object) { - if (ExpressionUtils::evaluateCondition($condition, $variable, $object)) { - $result[] = $object; - } - } - - return $result; - } - - /** - * Convert array of strings to integers - * - * @param array $input Array of string numbers - * @return array Array of integers - */ - public function map_to_i(array $input): array - { - return array_map('intval', $input); - } } diff --git a/app/Liquid/Utils/ExpressionUtils.php b/app/Liquid/Utils/ExpressionUtils.php deleted file mode 100644 index 402719c..0000000 --- a/app/Liquid/Utils/ExpressionUtils.php +++ /dev/null @@ -1,159 +0,0 @@ - 'and', - 'left' => self::parseCondition(mb_trim($parts[0])), - 'right' => self::parseCondition(mb_trim($parts[1])), - ]; - } - - if (str_contains($expression, ' or ')) { - $parts = explode(' or ', $expression, 2); - - return [ - 'type' => 'or', - 'left' => self::parseCondition(mb_trim($parts[0])), - 'right' => self::parseCondition(mb_trim($parts[1])), - ]; - } - - // Handle comparison operators - $operators = ['>=', '<=', '!=', '==', '>', '<', '=']; - - foreach ($operators as $operator) { - if (str_contains($expression, $operator)) { - $parts = explode($operator, $expression, 2); - - return [ - 'type' => 'comparison', - 'left' => mb_trim($parts[0]), - 'operator' => $operator === '=' ? '==' : $operator, - 'right' => mb_trim($parts[1]), - ]; - } - } - - // If no operator found, treat as a simple expression - return [ - 'type' => 'simple', - 'expression' => $expression, - ]; - } - - /** - * Evaluate a condition against an object - */ - public static function evaluateCondition(array $condition, string $variable, mixed $object): bool - { - switch ($condition['type']) { - case 'and': - return self::evaluateCondition($condition['left'], $variable, $object) && - self::evaluateCondition($condition['right'], $variable, $object); - - case 'or': - return self::evaluateCondition($condition['left'], $variable, $object) || - self::evaluateCondition($condition['right'], $variable, $object); - - case 'comparison': - $leftValue = self::resolveValue($condition['left'], $variable, $object); - $rightValue = self::resolveValue($condition['right'], $variable, $object); - - return match ($condition['operator']) { - '==' => $leftValue === $rightValue, - '!=' => $leftValue !== $rightValue, - '>' => $leftValue > $rightValue, - '<' => $leftValue < $rightValue, - '>=' => $leftValue >= $rightValue, - '<=' => $leftValue <= $rightValue, - default => false, - }; - - case 'simple': - $value = self::resolveValue($condition['expression'], $variable, $object); - - return (bool) $value; - - default: - return false; - } - } - - /** - * Resolve a value from an expression, variable, or literal - */ - public static function resolveValue(string $expression, string $variable, mixed $object): mixed - { - $expression = mb_trim($expression); - - // If it's the variable name, return the object - if ($expression === $variable) { - return $object; - } - - // If it's a property access (e.g., "n.age"), resolve it - if (str_starts_with($expression, $variable.'.')) { - $property = mb_substr($expression, mb_strlen($variable) + 1); - if (is_array($object) && array_key_exists($property, $object)) { - return $object[$property]; - } - if (is_object($object) && property_exists($object, $property)) { - return $object->$property; - } - - return null; - } - - // Try to parse as a number - if (is_numeric($expression)) { - return str_contains($expression, '.') ? (float) $expression : (int) $expression; - } - - // Try to parse as boolean - if (in_array(mb_strtolower($expression), ['true', 'false'])) { - return mb_strtolower($expression) === 'true'; - } - - // Try to parse as null - if (mb_strtolower($expression) === 'null') { - return null; - } - - // Return as string (remove quotes if present) - if ((str_starts_with($expression, '"') && str_ends_with($expression, '"')) || - (str_starts_with($expression, "'") && str_ends_with($expression, "'"))) { - return mb_substr($expression, 1, -1); - } - - return $expression; - } -} diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index ab42b67..bcecfc9 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -19,8 +19,6 @@ new class extends Component { public array $plugins; public $zipFile; - public string $sortBy = 'date_asc'; - public array $native_plugins = [ 'markup' => ['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'], @@ -41,47 +39,7 @@ new class extends Component { public function refreshPlugins(): void { $userPlugins = auth()->user()?->plugins?->makeHidden(['render_markup', 'data_payload'])->toArray(); - $allPlugins = array_merge($this->native_plugins, $userPlugins ?? []); - $allPlugins = array_values($allPlugins); - $allPlugins = $this->sortPlugins($allPlugins); - $this->plugins = $allPlugins; - } - - protected function sortPlugins(array $plugins): array - { - $pluginsToSort = array_values($plugins); - - switch ($this->sortBy) { - case 'name_asc': - usort($pluginsToSort, function($a, $b) { - return strcasecmp($a['name'] ?? '', $b['name'] ?? ''); - }); - break; - - case 'name_desc': - usort($pluginsToSort, function($a, $b) { - return strcasecmp($b['name'] ?? '', $a['name'] ?? ''); - }); - break; - - case 'date_desc': - usort($pluginsToSort, function($a, $b) { - $aDate = $a['created_at'] ?? '1970-01-01'; - $bDate = $b['created_at'] ?? '1970-01-01'; - return strcmp($bDate, $aDate); - }); - break; - - case 'date_asc': - usort($pluginsToSort, function($a, $b) { - $aDate = $a['created_at'] ?? '1970-01-01'; - $bDate = $b['created_at'] ?? '1970-01-01'; - return strcmp($aDate, $bDate); - }); - break; - } - - return $pluginsToSort; + $this->plugins = array_merge($this->native_plugins, $userPlugins ?? []); } public function mount(): void @@ -89,18 +47,6 @@ new class extends Component { $this->refreshPlugins(); } - public function updatedSortBy(): void - { - $this->refreshPlugins(); - } - - public function getListeners(): array - { - return [ - 'plugin-installed' => 'refreshPlugins', - ]; - } - public function addPlugin(): void { abort_unless(auth()->user() !== null, 403); @@ -128,6 +74,7 @@ new class extends Component { { Artisan::call(ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]); $this->refreshPlugins(); + } @@ -154,14 +101,7 @@ new class extends Component { }; ?> -
+

Plugins & Recipes

@@ -184,30 +124,8 @@ new class extends Component { -
-
-
- -
-
- - - - - - -
-
- @@ -276,7 +194,7 @@ new class extends Component { Browse and install Recipes from the community. Add yours here.
- +
@@ -347,16 +265,9 @@ new class extends Component {
- @php - $allPlugins = $this->plugins; - @endphp -
- @foreach($allPlugins as $index => $plugin) + @foreach($plugins as $plugin)
diff --git a/tests/Feature/Console/FirmwareCheckCommandTest.php b/tests/Feature/Console/FirmwareCheckCommandTest.php index 459a035..e0ed205 100644 --- a/tests/Feature/Console/FirmwareCheckCommandTest.php +++ b/tests/Feature/Console/FirmwareCheckCommandTest.php @@ -3,8 +3,6 @@ declare(strict_types=1); use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Storage; uses(RefreshDatabase::class); @@ -16,57 +14,16 @@ test('firmware check command has correct signature', function (): void { }); test('firmware check command runs without errors', function (): void { - // Mock the firmware API response - Http::fake([ - 'https://usetrmnl.com/api/firmware/latest' => Http::response([ - 'version' => '1.0.0', - 'url' => 'https://example.com/firmware.bin', - ], 200), - ]); - $this->artisan('trmnl:firmware:check') ->assertExitCode(0); - - // Verify that the firmware was created - expect(App\Models\Firmware::where('version_tag', '1.0.0')->exists())->toBeTrue(); }); test('firmware check command runs with download flag', function (): void { - // Mock the firmware API response - Http::fake([ - 'https://usetrmnl.com/api/firmware/latest' => Http::response([ - 'version' => '1.0.0', - 'url' => 'https://example.com/firmware.bin', - ], 200), - 'https://example.com/firmware.bin' => Http::response('fake firmware content', 200), - ]); - - // Mock storage to prevent actual file operations - Storage::fake('public'); - $this->artisan('trmnl:firmware:check', ['--download' => true]) ->assertExitCode(0); - - // Verify that the firmware was created and marked as latest - expect(App\Models\Firmware::where('version_tag', '1.0.0')->exists())->toBeTrue(); - - // Verify that the firmware was downloaded (storage_location should be set) - $firmware = App\Models\Firmware::where('version_tag', '1.0.0')->first(); - expect($firmware->storage_location)->toBe('firmwares/FW1.0.0.bin'); }); test('firmware check command can run successfully', function (): void { - // Mock the firmware API response - Http::fake([ - 'https://usetrmnl.com/api/firmware/latest' => Http::response([ - 'version' => '1.0.0', - 'url' => 'https://example.com/firmware.bin', - ], 200), - ]); - $this->artisan('trmnl:firmware:check') ->assertExitCode(0); - - // Verify that the firmware was created - expect(App\Models\Firmware::where('version_tag', '1.0.0')->exists())->toBeTrue(); }); diff --git a/tests/Feature/GenerateDefaultImagesTest.php b/tests/Feature/GenerateDefaultImagesTest.php index dba668d..6c084c9 100644 --- a/tests/Feature/GenerateDefaultImagesTest.php +++ b/tests/Feature/GenerateDefaultImagesTest.php @@ -3,21 +3,8 @@ use App\Models\Device; use App\Models\DeviceModel; use App\Services\ImageGenerationService; -use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Illuminate\Support\Facades\Storage; -beforeEach(function (): void { - TrmnlPipeline::fake(); - Storage::fake('public'); - - Storage::disk('public')->makeDirectory('/images/default-screens'); - Storage::disk('public')->makeDirectory('/images/generated'); - - // Create fallback image files that the service expects - Storage::disk('public')->put('/images/setup-logo.bmp', 'fake-bmp-content'); - Storage::disk('public')->put('/images/sleep.bmp', 'fake-bmp-content'); -}); - test('command transforms default images for all device models', function () { // Ensure we have device models $deviceModels = DeviceModel::all(); @@ -47,23 +34,14 @@ test('getDeviceSpecificDefaultImage returns correct path for device with model', $deviceModel = DeviceModel::first(); expect($deviceModel)->not->toBeNull(); - $extension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png'; - $filename = "{$deviceModel->width}_{$deviceModel->height}_{$deviceModel->bit_depth}_{$deviceModel->rotation}.{$extension}"; - - $setupPath = "images/default-screens/setup-logo_{$filename}"; - $sleepPath = "images/default-screens/sleep_{$filename}"; - - Storage::disk('public')->put($setupPath, 'fake-device-specific-setup'); - Storage::disk('public')->put($sleepPath, 'fake-device-specific-sleep'); - $device = new Device(); $device->deviceModel = $deviceModel; $setupImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'setup-logo'); $sleepImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'sleep'); - expect($setupImage)->toBe($setupPath); - expect($sleepImage)->toBe($sleepPath); + expect($setupImage)->toContain('images/default-screens/setup-logo_'); + expect($sleepImage)->toContain('images/default-screens/sleep_'); }); test('getDeviceSpecificDefaultImage falls back to original images for device without model', function () { @@ -87,12 +65,10 @@ test('generateDefaultScreenImage creates images from Blade templates', function expect($sleepUuid)->not->toBeEmpty(); expect($setupUuid)->not->toBe($sleepUuid); + // Check that the generated images exist $setupPath = "images/generated/{$setupUuid}.png"; $sleepPath = "images/generated/{$sleepUuid}.png"; - Storage::disk('public')->put($setupPath, 'fake-generated-setup-image'); - Storage::disk('public')->put($sleepPath, 'fake-generated-sleep-image'); - expect(Storage::disk('public')->exists($setupPath))->toBeTrue(); expect(Storage::disk('public')->exists($sleepPath))->toBeTrue(); }); diff --git a/tests/Feature/PluginLiquidFilterTest.php b/tests/Feature/PluginLiquidFilterTest.php index d571341..bc0fc18 100644 --- a/tests/Feature/PluginLiquidFilterTest.php +++ b/tests/Feature/PluginLiquidFilterTest.php @@ -29,6 +29,7 @@ assign collection = json_string | parse_json {{ tide | json }} {%- endfor %} LIQUID + , ]); $result = $plugin->render('full'); @@ -54,6 +55,7 @@ assign collection = json_string | parse_json {{ tide | json }} {%- endfor %} LIQUID + , ]); $result = $plugin->render('full'); @@ -79,6 +81,7 @@ assign collection = json_string | parse_json {{ tide | json }} {%- endfor %} LIQUID + , ]); $result = $plugin->render('full'); @@ -119,58 +122,3 @@ it('keeps scalar url_encode behavior intact', function (): void { expect($output)->toBe('hello+world'); }); - -test('where_exp filter works in liquid template', function (): void { - $plugin = Plugin::factory()->create([ - 'markup_language' => 'liquid', - 'render_markup' => <<<'LIQUID' -{% liquid -assign nums = "1, 2, 3, 4, 5" | split: ", " | map_to_i -assign filtered = nums | where_exp: "n", "n >= 3" -%} - -{% for num in filtered %} - {{ num }} -{%- endfor %} -LIQUID - ]); - - $result = $plugin->render('full'); - - // Debug: Let's see what the actual output is - // The issue might be that the HTML contains "1" in other places - // Let's check if the filtered numbers are actually in the content - $this->assertStringContainsString('3', $result); - $this->assertStringContainsString('4', $result); - $this->assertStringContainsString('5', $result); - - // Instead of checking for absence of 1 and 2, let's verify the count - // The filtered result should only contain 3, 4, 5 - $filteredContent = strip_tags($result); - $this->assertStringNotContainsString('1', $filteredContent); - $this->assertStringNotContainsString('2', $filteredContent); -}); - -test('where_exp filter works with object properties', function (): void { - $plugin = Plugin::factory()->create([ - 'markup_language' => 'liquid', - 'render_markup' => <<<'LIQUID' -{% liquid -assign users = '[{"name":"Alice","age":25},{"name":"Bob","age":30},{"name":"Charlie","age":35}]' | parse_json -assign adults = users | where_exp: "user", "user.age >= 30" -%} - -{% for user in adults %} - {{ user.name }} ({{ user.age }}) -{%- endfor %} -LIQUID - ]); - - $result = $plugin->render('full'); - - // Should output users >= 30 - $this->assertStringContainsString('Bob (30)', $result); - $this->assertStringContainsString('Charlie (35)', $result); - // Should not contain users < 30 - $this->assertStringNotContainsString('Alice (25)', $result); -}); diff --git a/tests/Feature/TransformDefaultImagesTest.php b/tests/Feature/TransformDefaultImagesTest.php index 9a27c03..041c708 100644 --- a/tests/Feature/TransformDefaultImagesTest.php +++ b/tests/Feature/TransformDefaultImagesTest.php @@ -3,20 +3,8 @@ use App\Models\Device; use App\Models\DeviceModel; use App\Services\ImageGenerationService; -use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Illuminate\Support\Facades\Storage; -beforeEach(function (): void { - TrmnlPipeline::fake(); - Storage::fake('public'); - Storage::disk('public')->makeDirectory('/images/default-screens'); - Storage::disk('public')->makeDirectory('/images/generated'); - - // Create fallback image files that the service expects - Storage::disk('public')->put('/images/setup-logo.bmp', 'fake-bmp-content'); - Storage::disk('public')->put('/images/sleep.bmp', 'fake-bmp-content'); -}); - test('command transforms default images for all device models', function () { // Ensure we have device models $deviceModels = DeviceModel::all(); @@ -42,6 +30,20 @@ test('command transforms default images for all device models', function () { } }); +test('getDeviceSpecificDefaultImage returns correct path for device with model', function () { + $deviceModel = DeviceModel::first(); + expect($deviceModel)->not->toBeNull(); + + $device = new Device(); + $device->deviceModel = $deviceModel; + + $setupImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'setup-logo'); + $sleepImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'sleep'); + + expect($setupImage)->toContain('images/default-screens/setup-logo_'); + expect($sleepImage)->toContain('images/default-screens/sleep_'); +}); + test('getDeviceSpecificDefaultImage falls back to original images for device without model', function () { $device = new Device(); $device->deviceModel = null; diff --git a/tests/Unit/Liquid/Filters/DataTest.php b/tests/Unit/Liquid/Filters/DataTest.php index 1200b6f..abd4114 100644 --- a/tests/Unit/Liquid/Filters/DataTest.php +++ b/tests/Unit/Liquid/Filters/DataTest.php @@ -325,173 +325,3 @@ test('parse_json filter handles primitive values', function (): void { expect($filter->parse_json('false'))->toBe(false); expect($filter->parse_json('null'))->toBe(null); }); - -test('map_to_i filter converts string numbers to integers', function (): void { - $filter = new Data(); - $input = ['1', '2', '3', '4', '5']; - - expect($filter->map_to_i($input))->toBe([1, 2, 3, 4, 5]); -}); - -test('map_to_i filter handles mixed string numbers', function (): void { - $filter = new Data(); - $input = ['5', '4', '3', '2', '1']; - - expect($filter->map_to_i($input))->toBe([5, 4, 3, 2, 1]); -}); - -test('map_to_i filter handles decimal strings', function (): void { - $filter = new Data(); - $input = ['1.5', '2.7', '3.0']; - - expect($filter->map_to_i($input))->toBe([1, 2, 3]); -}); - -test('map_to_i filter handles empty array', function (): void { - $filter = new Data(); - $input = []; - - expect($filter->map_to_i($input))->toBe([]); -}); - -test('where_exp filter returns string as array when input is string', function (): void { - $filter = new Data(); - $input = 'just a string'; - - expect($filter->where_exp($input, 'la', 'le'))->toBe(['just a string']); -}); - -test('where_exp filter filters numbers with comparison', function (): void { - $filter = new Data(); - $input = [1, 2, 3, 4, 5]; - - expect($filter->where_exp($input, 'n', 'n >= 3'))->toBe([3, 4, 5]); -}); - -test('where_exp filter filters numbers with greater than', function (): void { - $filter = new Data(); - $input = [1, 2, 3, 4, 5]; - - expect($filter->where_exp($input, 'n', 'n > 2'))->toBe([3, 4, 5]); -}); - -test('where_exp filter filters numbers with less than', function (): void { - $filter = new Data(); - $input = [1, 2, 3, 4, 5]; - - expect($filter->where_exp($input, 'n', 'n < 4'))->toBe([1, 2, 3]); -}); - -test('where_exp filter filters numbers with equality', function (): void { - $filter = new Data(); - $input = [1, 2, 3, 4, 5]; - - expect($filter->where_exp($input, 'n', 'n == 3'))->toBe([3]); -}); - -test('where_exp filter filters numbers with not equal', function (): void { - $filter = new Data(); - $input = [1, 2, 3, 4, 5]; - - expect($filter->where_exp($input, 'n', 'n != 3'))->toBe([1, 2, 4, 5]); -}); - -test('where_exp filter filters objects by property', function (): void { - $filter = new Data(); - $input = [ - ['name' => 'Alice', 'age' => 25], - ['name' => 'Bob', 'age' => 30], - ['name' => 'Charlie', 'age' => 35], - ]; - - expect($filter->where_exp($input, 'person', 'person.age >= 30'))->toBe([ - ['name' => 'Bob', 'age' => 30], - ['name' => 'Charlie', 'age' => 35], - ]); -}); - -test('where_exp filter filters objects by string property', function (): void { - $filter = new Data(); - $input = [ - ['name' => 'Alice', 'role' => 'admin'], - ['name' => 'Bob', 'role' => 'user'], - ['name' => 'Charlie', 'role' => 'admin'], - ]; - - expect($filter->where_exp($input, 'user', 'user.role == "admin"'))->toBe([ - ['name' => 'Alice', 'role' => 'admin'], - ['name' => 'Charlie', 'role' => 'admin'], - ]); -}); - -test('where_exp filter handles and operator', function (): void { - $filter = new Data(); - $input = [ - ['name' => 'Alice', 'age' => 25, 'active' => true], - ['name' => 'Bob', 'age' => 30, 'active' => false], - ['name' => 'Charlie', 'age' => 35, 'active' => true], - ]; - - expect($filter->where_exp($input, 'person', 'person.age >= 30 and person.active == true'))->toBe([ - ['name' => 'Charlie', 'age' => 35, 'active' => true], - ]); -}); - -test('where_exp filter handles or operator', function (): void { - $filter = new Data(); - $input = [ - ['name' => 'Alice', 'age' => 25, 'role' => 'admin'], - ['name' => 'Bob', 'age' => 30, 'role' => 'user'], - ['name' => 'Charlie', 'age' => 35, 'role' => 'user'], - ]; - - expect($filter->where_exp($input, 'person', 'person.age < 30 or person.role == "admin"'))->toBe([ - ['name' => 'Alice', 'age' => 25, 'role' => 'admin'], - ]); -}); - -test('where_exp filter handles simple boolean expressions', function (): void { - $filter = new Data(); - $input = [ - ['name' => 'Alice', 'active' => true], - ['name' => 'Bob', 'active' => false], - ['name' => 'Charlie', 'active' => true], - ]; - - expect($filter->where_exp($input, 'person', 'person.active'))->toBe([ - ['name' => 'Alice', 'active' => true], - ['name' => 'Charlie', 'active' => true], - ]); -}); - -test('where_exp filter handles empty array', function (): void { - $filter = new Data(); - $input = []; - - expect($filter->where_exp($input, 'n', 'n >= 3'))->toBe([]); -}); - -test('where_exp filter handles associative array', function (): void { - $filter = new Data(); - $input = [ - 'a' => 1, - 'b' => 2, - 'c' => 3, - ]; - - expect($filter->where_exp($input, 'n', 'n >= 2'))->toBe([2, 3]); -}); - -test('where_exp filter handles non-array input', function (): void { - $filter = new Data(); - $input = 123; - - expect($filter->where_exp($input, 'n', 'n >= 3'))->toBe([]); -}); - -test('where_exp filter handles null input', function (): void { - $filter = new Data(); - $input = null; - - expect($filter->where_exp($input, 'n', 'n >= 3'))->toBe([]); -}); diff --git a/tests/Unit/Liquid/Utils/ExpressionUtilsTest.php b/tests/Unit/Liquid/Utils/ExpressionUtilsTest.php deleted file mode 100644 index ee4d2fd..0000000 --- a/tests/Unit/Liquid/Utils/ExpressionUtilsTest.php +++ /dev/null @@ -1,201 +0,0 @@ - 1, 'b' => 2, 'c' => 3]; - - expect(ExpressionUtils::isAssociativeArray($array))->toBeTrue(); -}); - -test('isAssociativeArray returns false for indexed array', function (): void { - $array = [1, 2, 3, 4, 5]; - - expect(ExpressionUtils::isAssociativeArray($array))->toBeFalse(); -}); - -test('isAssociativeArray returns false for empty array', function (): void { - $array = []; - - expect(ExpressionUtils::isAssociativeArray($array))->toBeFalse(); -}); - -test('parseCondition handles simple comparison', function (): void { - $result = ExpressionUtils::parseCondition('n >= 3'); - - expect($result)->toBe([ - 'type' => 'comparison', - 'left' => 'n', - 'operator' => '>=', - 'right' => '3', - ]); -}); - -test('parseCondition handles equality comparison', function (): void { - $result = ExpressionUtils::parseCondition('user.role == "admin"'); - - expect($result)->toBe([ - 'type' => 'comparison', - 'left' => 'user.role', - 'operator' => '==', - 'right' => '"admin"', - ]); -}); - -test('parseCondition handles and operator', function (): void { - $result = ExpressionUtils::parseCondition('user.age >= 30 and user.active == true'); - - expect($result)->toBe([ - 'type' => 'and', - 'left' => [ - 'type' => 'comparison', - 'left' => 'user.age', - 'operator' => '>=', - 'right' => '30', - ], - 'right' => [ - 'type' => 'comparison', - 'left' => 'user.active', - 'operator' => '==', - 'right' => 'true', - ], - ]); -}); - -test('parseCondition handles or operator', function (): void { - $result = ExpressionUtils::parseCondition('user.age < 30 or user.role == "admin"'); - - expect($result)->toBe([ - 'type' => 'or', - 'left' => [ - 'type' => 'comparison', - 'left' => 'user.age', - 'operator' => '<', - 'right' => '30', - ], - 'right' => [ - 'type' => 'comparison', - 'left' => 'user.role', - 'operator' => '==', - 'right' => '"admin"', - ], - ]); -}); - -test('parseCondition handles simple expression', function (): void { - $result = ExpressionUtils::parseCondition('user.active'); - - expect($result)->toBe([ - 'type' => 'simple', - 'expression' => 'user.active', - ]); -}); - -test('evaluateCondition handles comparison with numbers', function (): void { - $condition = ExpressionUtils::parseCondition('n >= 3'); - - expect(ExpressionUtils::evaluateCondition($condition, 'n', 5))->toBeTrue(); - expect(ExpressionUtils::evaluateCondition($condition, 'n', 2))->toBeFalse(); - expect(ExpressionUtils::evaluateCondition($condition, 'n', 3))->toBeTrue(); -}); - -test('evaluateCondition handles comparison with strings', function (): void { - $condition = ExpressionUtils::parseCondition('user.role == "admin"'); - $user = ['role' => 'admin']; - - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue(); - - $user = ['role' => 'user']; - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse(); -}); - -test('evaluateCondition handles and operator', function (): void { - $condition = ExpressionUtils::parseCondition('user.age >= 30 and user.active == true'); - $user = ['age' => 35, 'active' => true]; - - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue(); - - $user = ['age' => 25, 'active' => true]; - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse(); - - $user = ['age' => 35, 'active' => false]; - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse(); -}); - -test('evaluateCondition handles or operator', function (): void { - $condition = ExpressionUtils::parseCondition('user.age < 30 or user.role == "admin"'); - $user = ['age' => 25, 'role' => 'user']; - - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue(); - - $user = ['age' => 35, 'role' => 'admin']; - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue(); - - $user = ['age' => 35, 'role' => 'user']; - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse(); -}); - -test('evaluateCondition handles simple boolean expression', function (): void { - $condition = ExpressionUtils::parseCondition('user.active'); - $user = ['active' => true]; - - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeTrue(); - - $user = ['active' => false]; - expect(ExpressionUtils::evaluateCondition($condition, 'user', $user))->toBeFalse(); -}); - -test('resolveValue returns object when expression matches variable', function (): void { - $object = ['name' => 'Alice', 'age' => 25]; - - expect(ExpressionUtils::resolveValue('user', 'user', $object))->toBe($object); -}); - -test('resolveValue resolves property access for arrays', function (): void { - $object = ['name' => 'Alice', 'age' => 25]; - - expect(ExpressionUtils::resolveValue('user.name', 'user', $object))->toBe('Alice'); - expect(ExpressionUtils::resolveValue('user.age', 'user', $object))->toBe(25); -}); - -test('resolveValue resolves property access for objects', function (): void { - $object = new stdClass(); - $object->name = 'Alice'; - $object->age = 25; - - expect(ExpressionUtils::resolveValue('user.name', 'user', $object))->toBe('Alice'); - expect(ExpressionUtils::resolveValue('user.age', 'user', $object))->toBe(25); -}); - -test('resolveValue returns null for non-existent properties', function (): void { - $object = ['name' => 'Alice']; - - expect(ExpressionUtils::resolveValue('user.age', 'user', $object))->toBeNull(); -}); - -test('resolveValue parses numeric values', function (): void { - expect(ExpressionUtils::resolveValue('123', 'user', []))->toBe(123); - expect(ExpressionUtils::resolveValue('45.67', 'user', []))->toBe(45.67); -}); - -test('resolveValue parses boolean values', function (): void { - expect(ExpressionUtils::resolveValue('true', 'user', []))->toBeTrue(); - expect(ExpressionUtils::resolveValue('false', 'user', []))->toBeFalse(); - expect(ExpressionUtils::resolveValue('TRUE', 'user', []))->toBeTrue(); - expect(ExpressionUtils::resolveValue('FALSE', 'user', []))->toBeFalse(); -}); - -test('resolveValue parses null value', function (): void { - expect(ExpressionUtils::resolveValue('null', 'user', []))->toBeNull(); - expect(ExpressionUtils::resolveValue('NULL', 'user', []))->toBeNull(); -}); - -test('resolveValue removes quotes from strings', function (): void { - expect(ExpressionUtils::resolveValue('"hello"', 'user', []))->toBe('hello'); - expect(ExpressionUtils::resolveValue("'world'", 'user', []))->toBe('world'); -}); - -test('resolveValue returns expression as-is for unquoted strings', function (): void { - expect(ExpressionUtils::resolveValue('hello', 'user', []))->toBe('hello'); - expect(ExpressionUtils::resolveValue('world', 'user', []))->toBe('world'); -});