');
+});
+
+it('imports plugin with only shared.blade.php file', function (): void {
+ $user = User::factory()->create();
+
+ $zipContent = createMockZipFile([
+ 'src/settings.yml' => getValidSettingsYaml(),
+ 'src/shared.blade.php' => '
',
+ ]);
+
+ $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
+
+ $pluginImportService = new PluginImportService();
+ $plugin = $pluginImportService->importFromZip($zipFile, $user);
+
+ expect($plugin)->toBeInstanceOf(Plugin::class)
+ ->and($plugin->markup_language)->toBe('blade')
+ ->and($plugin->render_markup)->toBe('
');
});
// Helper methods
function createMockZipFile(array $files): string
{
$zip = new ZipArchive();
- $tempFile = tempnam(sys_get_temp_dir(), 'test_zip_');
+
+ $tempFileName = 'test_zip_'.uniqid().'.zip';
+ $tempFile = Storage::path($tempFileName);
$zip->open($tempFile, ZipArchive::CREATE);
@@ -147,7 +556,8 @@ function createMockZipFile(array $files): string
$zip->close();
$content = file_get_contents($tempFile);
- unlink($tempFile);
+
+ Storage::delete($tempFileName);
return $content;
}
diff --git a/tests/Feature/PluginInlineTemplatesTest.php b/tests/Feature/PluginInlineTemplatesTest.php
index fb35344..76b29d7 100644
--- a/tests/Feature/PluginInlineTemplatesTest.php
+++ b/tests/Feature/PluginInlineTemplatesTest.php
@@ -5,7 +5,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
-test('renders plugin with inline templates', function () {
+test('renders plugin with inline templates', function (): void {
$plugin = Plugin::factory()->create([
'name' => 'Test Plugin',
'markup_language' => 'liquid',
@@ -61,16 +61,16 @@ LIQUID
// Should render both templates
// Check for any of the facts (since random number generation is non-deterministic)
$this->assertTrue(
- str_contains($result, 'Fact 1') ||
- str_contains($result, 'Fact 2') ||
- str_contains($result, 'Fact 3')
+ str_contains((string) $result, 'Fact 1') ||
+ str_contains((string) $result, 'Fact 2') ||
+ str_contains((string) $result, 'Fact 3')
);
$this->assertStringContainsString('Test Plugin', $result);
$this->assertStringContainsString('Please try to enjoy each fact equally', $result);
$this->assertStringContainsString('class="view view--full"', $result);
});
-test('renders plugin with inline templates using with syntax', function () {
+test('renders plugin with inline templates using with syntax', function (): void {
$plugin = Plugin::factory()->create([
'name' => 'Test Plugin',
'markup_language' => 'liquid',
@@ -127,16 +127,16 @@ LIQUID
// Should render both templates
// Check for any of the facts (since random number generation is non-deterministic)
$this->assertTrue(
- str_contains($result, 'Fact 1') ||
- str_contains($result, 'Fact 2') ||
- str_contains($result, 'Fact 3')
+ str_contains((string) $result, 'Fact 1') ||
+ str_contains((string) $result, 'Fact 2') ||
+ str_contains((string) $result, 'Fact 3')
);
$this->assertStringContainsString('Test Plugin', $result);
$this->assertStringContainsString('Please try to enjoy each fact equally', $result);
$this->assertStringContainsString('class="view view--full"', $result);
});
-test('renders plugin with simple inline template', function () {
+test('renders plugin with simple inline template', function (): void {
$plugin = Plugin::factory()->create([
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
@@ -162,7 +162,7 @@ LIQUID
$this->assertStringContainsString('class="simple"', $result);
});
-test('renders plugin with liquid filter find_by', function () {
+test('renders plugin with liquid filter find_by', function (): void {
$plugin = Plugin::factory()->create([
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
@@ -194,7 +194,7 @@ LIQUID
$this->assertStringContainsString('class="user"', $result);
});
-test('renders plugin with liquid filter find_by and fallback', function () {
+test('renders plugin with liquid filter find_by and fallback', function (): void {
$plugin = Plugin::factory()->create([
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
@@ -216,7 +216,7 @@ LIQUID
$this->assertStringContainsString('Not Found', $result);
});
-test('renders plugin with liquid filter group_by', function () {
+test('renders plugin with liquid filter group_by', function (): void {
$plugin = Plugin::factory()->create([
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
diff --git a/tests/Feature/PluginLiquidWhereFilterTest.php b/tests/Feature/PluginLiquidFilterTest.php
similarity index 51%
rename from tests/Feature/PluginLiquidWhereFilterTest.php
rename to tests/Feature/PluginLiquidFilterTest.php
index 22a2fa5..e6272c7 100644
--- a/tests/Feature/PluginLiquidWhereFilterTest.php
+++ b/tests/Feature/PluginLiquidFilterTest.php
@@ -2,7 +2,9 @@
declare(strict_types=1);
+use App\Liquid\Filters\StandardFilters;
use App\Models\Plugin;
+use Keepsuit\Liquid\Environment;
/**
* Tests for the Liquid where filter functionality
@@ -12,8 +14,7 @@ use App\Models\Plugin;
* to:
* {% assign _temp_xxx = collection | filter: "key", "value" %}{% for item in _temp_xxx %}
*/
-
-test('where filter works when assigned to variable first', function () {
+test('where filter works when assigned to variable first', function (): void {
$plugin = Plugin::factory()->create([
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
@@ -28,7 +29,6 @@ assign collection = json_string | parse_json
{{ tide | json }}
{%- endfor %}
LIQUID
- ,
]);
$result = $plugin->render('full');
@@ -41,7 +41,7 @@ LIQUID
$this->assertStringNotContainsString('"type":"L"', $result);
});
-test('where filter works directly in for loop with preprocessing', function () {
+test('where filter works directly in for loop with preprocessing', function (): void {
$plugin = Plugin::factory()->create([
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
@@ -54,7 +54,6 @@ assign collection = json_string | parse_json
{{ tide | json }}
{%- endfor %}
LIQUID
- ,
]);
$result = $plugin->render('full');
@@ -67,7 +66,7 @@ LIQUID
$this->assertStringNotContainsString('"type":"L"', $result);
});
-test('where filter works directly in for loop with multiple matches', function () {
+test('where filter works directly in for loop with multiple matches', function (): void {
$plugin = Plugin::factory()->create([
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
@@ -80,7 +79,6 @@ assign collection = json_string | parse_json
{{ tide | json }}
{%- endfor %}
LIQUID
- ,
]);
$result = $plugin->render('full');
@@ -93,3 +91,86 @@ LIQUID
// Should not contain the low tide data
$this->assertStringNotContainsString('"type":"L"', $result);
});
+
+it('encodes arrays for url_encode as JSON with spaces after commas and then percent-encodes', function (): void {
+ /** @var Environment $env */
+ $env = app('liquid.environment');
+ $env->filterRegistry->register(StandardFilters::class);
+
+ $template = $env->parseString('{{ categories | url_encode }}');
+
+ $output = $template->render($env->newRenderContext([
+ 'categories' => ['common', 'obscure'],
+ ]));
+
+ expect($output)->toBe('%5B%22common%22%2C%22obscure%22%5D');
+});
+
+it('keeps scalar url_encode behavior intact', function (): void {
+ /** @var Environment $env */
+ $env = app('liquid.environment');
+ $env->filterRegistry->register(StandardFilters::class);
+
+ $template = $env->parseString('{{ text | url_encode }}');
+
+ $output = $template->render($env->newRenderContext([
+ 'text' => 'hello world',
+ ]));
+
+ 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((string) $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/PluginRequiredConfigurationTest.php b/tests/Feature/PluginRequiredConfigurationTest.php
index 552b996..51e1b76 100644
--- a/tests/Feature/PluginRequiredConfigurationTest.php
+++ b/tests/Feature/PluginRequiredConfigurationTest.php
@@ -6,7 +6,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
-test('hasMissingRequiredConfigurationFields returns true when required field is null', function () {
+test('hasMissingRequiredConfigurationFields returns true when required field is null', function (): void {
$user = User::factory()->create();
$configurationTemplate = [
@@ -39,7 +39,7 @@ test('hasMissingRequiredConfigurationFields returns true when required field is
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
-test('hasMissingRequiredConfigurationFields returns false when all required fields are set', function () {
+test('hasMissingRequiredConfigurationFields returns false when all required fields are set', function (): void {
$user = User::factory()->create();
$configurationTemplate = [
@@ -73,7 +73,7 @@ test('hasMissingRequiredConfigurationFields returns false when all required fiel
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});
-test('hasMissingRequiredConfigurationFields returns false when no custom fields exist', function () {
+test('hasMissingRequiredConfigurationFields returns false when no custom fields exist', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->create([
@@ -85,7 +85,7 @@ test('hasMissingRequiredConfigurationFields returns false when no custom fields
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});
-test('hasMissingRequiredConfigurationFields returns true when explicitly required field is null', function () {
+test('hasMissingRequiredConfigurationFields returns true when explicitly required field is null', function (): void {
$user = User::factory()->create();
$configurationTemplate = [
@@ -111,7 +111,7 @@ test('hasMissingRequiredConfigurationFields returns true when explicitly require
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
-test('hasMissingRequiredConfigurationFields returns true when required field is empty string', function () {
+test('hasMissingRequiredConfigurationFields returns true when required field is empty string', function (): void {
$user = User::factory()->create();
$configurationTemplate = [
@@ -137,7 +137,7 @@ test('hasMissingRequiredConfigurationFields returns true when required field is
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
-test('hasMissingRequiredConfigurationFields returns true when required array field is empty', function () {
+test('hasMissingRequiredConfigurationFields returns true when required array field is empty', function (): void {
$user = User::factory()->create();
$configurationTemplate = [
@@ -164,7 +164,7 @@ test('hasMissingRequiredConfigurationFields returns true when required array fie
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
-test('hasMissingRequiredConfigurationFields returns false when author_bio field is present but other required field is set', function () {
+test('hasMissingRequiredConfigurationFields returns false when author_bio field is present but other required field is set', function (): void {
$user = User::factory()->create();
$configurationTemplate = [
@@ -193,7 +193,7 @@ test('hasMissingRequiredConfigurationFields returns false when author_bio field
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});
-test('hasMissingRequiredConfigurationFields returns false when field has default value', function () {
+test('hasMissingRequiredConfigurationFields returns false when field has default value', function (): void {
$user = User::factory()->create();
$configurationTemplate = [
@@ -217,7 +217,7 @@ test('hasMissingRequiredConfigurationFields returns false when field has default
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});
-test('hasMissingRequiredConfigurationFields returns true when required xhrSelect field is missing', function () {
+test('hasMissingRequiredConfigurationFields returns true when required xhrSelect field is missing', function (): void {
$user = User::factory()->create();
$configurationTemplate = [
@@ -242,7 +242,7 @@ test('hasMissingRequiredConfigurationFields returns true when required xhrSelect
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
-test('hasMissingRequiredConfigurationFields returns false when required xhrSelect field is set', function () {
+test('hasMissingRequiredConfigurationFields returns false when required xhrSelect field is set', function (): void {
$user = User::factory()->create();
$configurationTemplate = [
@@ -268,3 +268,79 @@ test('hasMissingRequiredConfigurationFields returns false when required xhrSelec
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});
+
+test('hasMissingRequiredConfigurationFields returns true when required multi_string field is missing', function (): void {
+ $user = User::factory()->create();
+
+ $configurationTemplate = [
+ 'custom_fields' => [
+ [
+ 'keyname' => 'tags',
+ 'field_type' => 'multi_string',
+ 'name' => 'Tags',
+ 'description' => 'Enter tags separated by commas',
+ // Not marked as optional, so it's required
+ ],
+ ],
+ ];
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'configuration_template' => $configurationTemplate,
+ 'configuration' => [], // Empty configuration
+ ]);
+
+ expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
+});
+
+test('hasMissingRequiredConfigurationFields returns false when required multi_string field is set', function (): void {
+ $user = User::factory()->create();
+
+ $configurationTemplate = [
+ 'custom_fields' => [
+ [
+ 'keyname' => 'tags',
+ 'field_type' => 'multi_string',
+ 'name' => 'Tags',
+ 'description' => 'Enter tags separated by commas',
+ // Not marked as optional, so it's required
+ ],
+ ],
+ ];
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'configuration_template' => $configurationTemplate,
+ 'configuration' => [
+ 'tags' => 'tag1, tag2, tag3', // Required field is set with comma-separated values
+ ],
+ ]);
+
+ expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
+});
+
+test('hasMissingRequiredConfigurationFields returns true when required multi_string field is empty string', function (): void {
+ $user = User::factory()->create();
+
+ $configurationTemplate = [
+ 'custom_fields' => [
+ [
+ 'keyname' => 'tags',
+ 'field_type' => 'multi_string',
+ 'name' => 'Tags',
+ 'description' => 'Enter tags separated by commas',
+ // Not marked as optional, so it's required
+ ],
+ ],
+ ];
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'configuration_template' => $configurationTemplate,
+ 'configuration' => [
+ 'tags' => '', // Empty string
+ ],
+ ]);
+
+ expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
+});
diff --git a/tests/Feature/PluginResponseTest.php b/tests/Feature/PluginResponseTest.php
new file mode 100644
index 0000000..2a75c9e
--- /dev/null
+++ b/tests/Feature/PluginResponseTest.php
@@ -0,0 +1,287 @@
+ Http::response([
+ 'title' => 'Test Data',
+ 'items' => [
+ ['id' => 1, 'name' => 'Item 1'],
+ ['id' => 2, 'name' => 'Item 2'],
+ ],
+ ], 200, ['Content-Type' => 'application/json']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/api/data',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toBe([
+ 'title' => 'Test Data',
+ 'items' => [
+ ['id' => 1, 'name' => 'Item 1'],
+ ['id' => 2, 'name' => 'Item 2'],
+ ],
+ ]);
+});
+
+test('plugin parses XML responses and wraps under rss key', function (): void {
+ $xmlContent = '
+
+
+ Test RSS Feed
+ -
+ Test Item 1
+ Description 1
+
+ -
+ Test Item 2
+ Description 2
+
+
+ ';
+
+ Http::fake([
+ 'example.com/feed.xml' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/feed.xml',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toHaveKey('rss');
+ expect($plugin->data_payload['rss'])->toHaveKey('@attributes');
+ expect($plugin->data_payload['rss'])->toHaveKey('channel');
+ expect($plugin->data_payload['rss']['channel']['title'])->toBe('Test RSS Feed');
+ expect($plugin->data_payload['rss']['channel']['item'])->toHaveCount(2);
+});
+
+test('plugin parses JSON-parsable response body as JSON', function (): void {
+ $jsonContent = '{"title": "Test Data", "items": [1, 2, 3]}';
+
+ Http::fake([
+ 'example.com/data' => Http::response($jsonContent, 200, ['Content-Type' => 'text/plain']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/data',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toBe([
+ 'title' => 'Test Data',
+ 'items' => [1, 2, 3],
+ ]);
+});
+
+test('plugin wraps plain text response body as JSON', function (): void {
+ $jsonContent = 'Lorem ipsum dolor sit amet';
+
+ Http::fake([
+ 'example.com/data' => Http::response($jsonContent, 200, ['Content-Type' => 'text/plain']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/data',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toBe([
+ 'data' => 'Lorem ipsum dolor sit amet',
+ ]);
+});
+
+test('plugin handles invalid XML gracefully', function (): void {
+ $invalidXml = '
- unclosed tag';
+
+ Http::fake([
+ 'example.com/invalid.xml' => Http::response($invalidXml, 200, ['Content-Type' => 'application/xml']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/invalid.xml',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toBe(['error' => 'Failed to parse XML response']);
+});
+
+test('plugin handles multiple URLs with mixed content types', function (): void {
+ $jsonResponse = ['title' => 'JSON Data', 'items' => [1, 2, 3]];
+ $xmlContent = '
- XML Data
';
+
+ Http::fake([
+ 'example.com/json' => Http::response($jsonResponse, 200, ['Content-Type' => 'application/json']),
+ 'example.com/xml' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => "https://example.com/json\nhttps://example.com/xml",
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toHaveKey('IDX_0');
+ expect($plugin->data_payload)->toHaveKey('IDX_1');
+
+ // First URL should be JSON
+ expect($plugin->data_payload['IDX_0'])->toBe($jsonResponse);
+
+ // Second URL should be XML wrapped under rss
+ expect($plugin->data_payload['IDX_1'])->toHaveKey('rss');
+ expect($plugin->data_payload['IDX_1']['rss']['item'])->toBe('XML Data');
+});
+
+test('plugin handles POST requests with XML responses', function (): void {
+ $xmlContent = 'successtest';
+
+ Http::fake([
+ 'example.com/api' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/api',
+ 'polling_verb' => 'post',
+ 'polling_body' => '{"query": "test"}',
+ ]);
+
+ $plugin->updateDataPayload();
+
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toHaveKey('rss');
+ expect($plugin->data_payload['rss'])->toHaveKey('status');
+ expect($plugin->data_payload['rss'])->toHaveKey('data');
+ expect($plugin->data_payload['rss']['status'])->toBe('success');
+ expect($plugin->data_payload['rss']['data'])->toBe('test');
+});
+
+test('plugin parses iCal responses and filters to recent window', function (): void {
+ Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC'));
+
+ $icalContent = <<<'ICS'
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Example Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:event-1@example.com
+DTSTAMP:20250101T120000Z
+DTSTART:20250110T090000Z
+DTEND:20250110T100000Z
+SUMMARY:Past within window
+END:VEVENT
+BEGIN:VEVENT
+UID:event-2@example.com
+DTSTAMP:20250101T120000Z
+DTSTART:20250301T090000Z
+DTEND:20250301T100000Z
+SUMMARY:Far future
+END:VEVENT
+BEGIN:VEVENT
+UID:event-3@example.com
+DTSTAMP:20250101T120000Z
+DTSTART:20250120T090000Z
+DTEND:20250120T100000Z
+SUMMARY:Upcoming within window
+END:VEVENT
+END:VCALENDAR
+ICS;
+
+ Http::fake([
+ 'example.com/calendar.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/calendar.ics',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+ $plugin->refresh();
+
+ $ical = $plugin->data_payload['ical'];
+
+ expect($ical)->toHaveCount(2);
+ expect($ical[0]['SUMMARY'])->toBe('Past within window');
+ expect($ical[1]['SUMMARY'])->toBe('Upcoming within window');
+ expect(collect($ical)->pluck('SUMMARY'))->not->toContain('Far future');
+ expect($ical[0]['DTSTART'])->toBe('2025-01-10T09:00:00+00:00');
+ expect($ical[1]['DTSTART'])->toBe('2025-01-20T09:00:00+00:00');
+
+ Carbon::setTestNow();
+});
+
+test('plugin detects iCal content without calendar content type', function (): void {
+ Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC'));
+
+ $icalContent = <<<'ICS'
+BEGIN:VCALENDAR
+VERSION:2.0
+BEGIN:VEVENT
+UID:event-body-detected@example.com
+DTSTAMP:20250101T120000Z
+DTSTART:20250116T090000Z
+DTEND:20250116T100000Z
+SUMMARY:Detected by body
+END:VEVENT
+END:VCALENDAR
+ICS;
+
+ Http::fake([
+ 'example.com/calendar-body.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/plain']),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => 'https://example.com/calendar-body.ics',
+ 'polling_verb' => 'get',
+ ]);
+
+ $plugin->updateDataPayload();
+ $plugin->refresh();
+
+ expect($plugin->data_payload)->toHaveKey('ical');
+ expect($plugin->data_payload['ical'])->toHaveCount(1);
+ expect($plugin->data_payload['ical'][0]['SUMMARY'])->toBe('Detected by body');
+ expect($plugin->data_payload['ical'][0]['DTSTART'])->toBe('2025-01-16T09:00:00+00:00');
+
+ Carbon::setTestNow();
+});
diff --git a/tests/Feature/PluginWebhookTest.php b/tests/Feature/PluginWebhookTest.php
index 70fa53a..22d1d54 100644
--- a/tests/Feature/PluginWebhookTest.php
+++ b/tests/Feature/PluginWebhookTest.php
@@ -3,7 +3,7 @@
use App\Models\Plugin;
use Illuminate\Support\Str;
-test('webhook updates plugin data for webhook strategy', function () {
+test('webhook updates plugin data for webhook strategy', function (): void {
// Create a plugin with webhook strategy
$plugin = Plugin::factory()->create([
'data_strategy' => 'webhook',
@@ -26,7 +26,7 @@ test('webhook updates plugin data for webhook strategy', function () {
]);
});
-test('webhook returns 400 for non-webhook strategy plugins', function () {
+test('webhook returns 400 for non-webhook strategy plugins', function (): void {
// Create a plugin with non-webhook strategy
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
@@ -43,7 +43,7 @@ test('webhook returns 400 for non-webhook strategy plugins', function () {
->assertJson(['error' => 'Plugin does not use webhook strategy']);
});
-test('webhook returns 400 when merge_variables is missing', function () {
+test('webhook returns 400 when merge_variables is missing', function (): void {
// Create a plugin with webhook strategy
$plugin = Plugin::factory()->create([
'data_strategy' => 'webhook',
@@ -58,7 +58,7 @@ test('webhook returns 400 when merge_variables is missing', function () {
->assertJson(['error' => 'Request must contain merge_variables key']);
});
-test('webhook returns 404 for non-existent plugin', function () {
+test('webhook returns 404 for non-existent plugin', function (): void {
// Make request with non-existent plugin UUID
$response = $this->postJson('/api/custom_plugins/'.Str::uuid(), [
'merge_variables' => ['new' => 'data'],
diff --git a/tests/Feature/Settings/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php
index 3252860..0e33955 100644
--- a/tests/Feature/Settings/PasswordUpdateTest.php
+++ b/tests/Feature/Settings/PasswordUpdateTest.php
@@ -6,7 +6,7 @@ use Livewire\Volt\Volt;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
-test('password can be updated', function () {
+test('password can be updated', function (): void {
$user = User::factory()->create([
'password' => Hash::make('password'),
]);
@@ -24,7 +24,7 @@ test('password can be updated', function () {
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
});
-test('correct password must be provided to update password', function () {
+test('correct password must be provided to update password', function (): void {
$user = User::factory()->create([
'password' => Hash::make('password'),
]);
diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php
index 48ea114..cbf424c 100644
--- a/tests/Feature/Settings/ProfileUpdateTest.php
+++ b/tests/Feature/Settings/ProfileUpdateTest.php
@@ -5,13 +5,13 @@ use Livewire\Volt\Volt;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
-test('profile page is displayed', function () {
+test('profile page is displayed', function (): void {
$this->actingAs($user = User::factory()->create());
$this->get('/settings/profile')->assertOk();
});
-test('profile information can be updated', function () {
+test('profile information can be updated', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
@@ -30,7 +30,7 @@ test('profile information can be updated', function () {
expect($user->email_verified_at)->toBeNull();
});
-test('email verification status is unchanged when email address is unchanged', function () {
+test('email verification status is unchanged when email address is unchanged', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
@@ -45,7 +45,7 @@ test('email verification status is unchanged when email address is unchanged', f
expect($user->refresh()->email_verified_at)->not->toBeNull();
});
-test('user can delete their account', function () {
+test('user can delete their account', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
@@ -62,7 +62,7 @@ test('user can delete their account', function () {
expect(auth()->check())->toBeFalse();
});
-test('correct password must be provided to delete account', function () {
+test('correct password must be provided to delete account', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
diff --git a/tests/Feature/TransformDefaultImagesTest.php b/tests/Feature/TransformDefaultImagesTest.php
new file mode 100644
index 0000000..2ea995f
--- /dev/null
+++ b/tests/Feature/TransformDefaultImagesTest.php
@@ -0,0 +1,87 @@
+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 (): void {
+ // Ensure we have device models
+ $deviceModels = DeviceModel::all();
+ expect($deviceModels)->not->toBeEmpty();
+
+ // Run the command
+ $this->artisan('images:generate-defaults')
+ ->assertExitCode(0);
+
+ // Check that the default-screens directory was created
+ expect(Storage::disk('public')->exists('images/default-screens'))->toBeTrue();
+
+ // Check that images were generated for each device model
+ foreach ($deviceModels as $deviceModel) {
+ $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}";
+
+ expect(Storage::disk('public')->exists($setupPath))->toBeTrue();
+ expect(Storage::disk('public')->exists($sleepPath))->toBeTrue();
+ }
+});
+
+test('getDeviceSpecificDefaultImage falls back to original images for device without model', function (): void {
+ $device = new Device();
+ $device->deviceModel = null;
+
+ $setupImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'setup-logo');
+ $sleepImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'sleep');
+
+ expect($setupImage)->toBe('images/setup-logo.bmp');
+ expect($sleepImage)->toBe('images/sleep.bmp');
+});
+
+test('generateDefaultScreenImage creates images from Blade templates', function (): void {
+ $device = Device::factory()->create();
+
+ $setupUuid = ImageGenerationService::generateDefaultScreenImage($device, 'setup-logo');
+ $sleepUuid = ImageGenerationService::generateDefaultScreenImage($device, 'sleep');
+
+ expect($setupUuid)->not->toBeEmpty();
+ 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";
+
+ expect(Storage::disk('public')->exists($setupPath))->toBeTrue();
+ expect(Storage::disk('public')->exists($sleepPath))->toBeTrue();
+})->skipOnCI();
+
+test('generateDefaultScreenImage throws exception for invalid image type', function (): void {
+ $device = Device::factory()->create();
+
+ expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-type'))
+ ->toThrow(InvalidArgumentException::class);
+});
+
+test('getDeviceSpecificDefaultImage returns null for invalid image type', function (): void {
+ $device = new Device();
+ $device->deviceModel = DeviceModel::first();
+
+ $result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'invalid-type');
+ expect($result)->toBeNull();
+});
diff --git a/tests/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php
new file mode 100644
index 0000000..a80c63a
--- /dev/null
+++ b/tests/Feature/Volt/CatalogTrmnlTest.php
@@ -0,0 +1,286 @@
+ Http::response([
+ 'data' => [
+ [
+ 'id' => 123,
+ 'name' => 'Weather Chum',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 10, 'forks' => 2],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Weather Chum')
+ ->assertSee('Install')
+ ->assertDontSeeHtml('variant="subtle" icon="eye"')
+ ->assertSee('Installs: 10');
+});
+
+it('shows preview button when screenshot_url is provided', function (): void {
+ Http::fake([
+ 'usetrmnl.com/recipes.json*' => Http::response([
+ 'data' => [
+ [
+ 'id' => 123,
+ 'name' => 'Weather Chum',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => 'https://example.com/screenshot.png',
+ 'author_bio' => null,
+ 'stats' => ['installs' => 10, 'forks' => 2],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Weather Chum')
+ ->assertSee('Preview');
+});
+
+it('searches TRMNL recipes when search term is provided', function (): void {
+ Http::fake([
+ // First call (mount -> newest)
+ 'usetrmnl.com/recipes.json?*' => Http::sequence()
+ ->push([
+ 'data' => [
+ [
+ 'id' => 1,
+ 'name' => 'Initial Recipe',
+ 'icon_url' => null,
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 1, 'forks' => 0],
+ ],
+ ],
+ ], 200)
+ // Second call (search)
+ ->push([
+ 'data' => [
+ [
+ 'id' => 2,
+ 'name' => 'Weather Search Result',
+ 'icon_url' => null,
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 3, 'forks' => 1],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Initial Recipe')
+ ->set('search', 'weather')
+ ->assertSee('Weather Search Result')
+ ->assertSee('Install');
+});
+
+it('installs plugin successfully when user is authenticated', function (): void {
+ $user = User::factory()->create();
+
+ Http::fake([
+ 'usetrmnl.com/recipes.json*' => Http::response([
+ 'data' => [
+ [
+ 'id' => 123,
+ 'name' => 'Weather Chum',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 10, 'forks' => 2],
+ ],
+ ],
+ ], 200),
+ 'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('fake zip content', 200),
+ ]);
+
+ $this->actingAs($user);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Weather Chum')
+ ->call('installPlugin', '123')
+ ->assertSee('Error installing plugin'); // This will fail because we don't have a real zip file
+});
+
+it('shows error when user is not authenticated', function (): void {
+ Http::fake([
+ 'usetrmnl.com/recipes.json*' => Http::response([
+ 'data' => [
+ [
+ 'id' => 123,
+ 'name' => 'Weather Chum',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 10, 'forks' => 2],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Weather Chum')
+ ->call('installPlugin', '123')
+ ->assertStatus(403); // This will return 403 because user is not authenticated
+});
+
+it('shows error when plugin installation fails', function (): void {
+ $user = User::factory()->create();
+
+ Http::fake([
+ 'usetrmnl.com/recipes.json*' => Http::response([
+ 'data' => [
+ [
+ 'id' => 123,
+ 'name' => 'Weather Chum',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 10, 'forks' => 2],
+ ],
+ ],
+ ], 200),
+ 'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('invalid zip content', 200),
+ ]);
+
+ $this->actingAs($user);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Weather Chum')
+ ->call('installPlugin', '123')
+ ->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
+});
+
+it('previews a recipe with async fetch', function (): void {
+ Http::fake([
+ 'usetrmnl.com/recipes.json*' => Http::response([
+ 'data' => [
+ [
+ 'id' => 123,
+ 'name' => 'Weather Chum',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => 'https://example.com/old.png',
+ 'author_bio' => null,
+ 'stats' => ['installs' => 10, 'forks' => 2],
+ ],
+ ],
+ ], 200),
+ 'usetrmnl.com/recipes/123.json' => Http::response([
+ 'data' => [
+ 'id' => 123,
+ 'name' => 'Weather Chum Updated',
+ 'icon_url' => 'https://example.com/icon.png',
+ 'screenshot_url' => 'https://example.com/new.png',
+ 'author_bio' => ['description' => 'New bio'],
+ 'stats' => ['installs' => 11, 'forks' => 3],
+ ],
+ ], 200),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Weather Chum')
+ ->call('previewRecipe', '123')
+ ->assertSet('previewingRecipe', '123')
+ ->assertSet('previewData.name', 'Weather Chum Updated')
+ ->assertSet('previewData.screenshot_url', 'https://example.com/new.png')
+ ->assertSee('Preview Weather Chum Updated')
+ ->assertSee('New bio');
+});
+
+it('supports pagination and loading more recipes', function (): void {
+ Http::fake([
+ 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([
+ 'data' => [
+ [
+ 'id' => 1,
+ 'name' => 'Recipe Page 1',
+ 'icon_url' => null,
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 1, 'forks' => 0],
+ ],
+ ],
+ 'next_page_url' => '/recipes.json?page=2',
+ ], 200),
+ 'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([
+ 'data' => [
+ [
+ 'id' => 2,
+ 'name' => 'Recipe Page 2',
+ 'icon_url' => null,
+ 'screenshot_url' => null,
+ 'author_bio' => null,
+ 'stats' => ['installs' => 2, 'forks' => 0],
+ ],
+ ],
+ 'next_page_url' => null,
+ ], 200),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Recipe Page 1')
+ ->assertDontSee('Recipe Page 2')
+ ->assertSee('Load next page')
+ ->call('loadMore')
+ ->assertSee('Recipe Page 1')
+ ->assertSee('Recipe Page 2')
+ ->assertDontSee('Load next page');
+});
+
+it('resets pagination when search term changes', function (): void {
+ Http::fake([
+ 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence()
+ ->push([
+ 'data' => [['id' => 1, 'name' => 'Initial 1']],
+ 'next_page_url' => '/recipes.json?page=2',
+ ])
+ ->push([
+ 'data' => [['id' => 3, 'name' => 'Initial 1 Again']],
+ 'next_page_url' => null,
+ ]),
+ 'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([
+ 'data' => [['id' => 2, 'name' => 'Weather Result']],
+ 'next_page_url' => null,
+ ]),
+ ]);
+
+ Livewire::withoutLazyLoading();
+
+ Volt::test('catalog.trmnl')
+ ->assertSee('Initial 1')
+ ->call('loadMore')
+ ->set('search', 'weather')
+ ->assertSee('Weather Result')
+ ->assertDontSee('Initial 1')
+ ->assertSet('page', 1);
+});
diff --git a/tests/Feature/Volt/DevicePalettesTest.php b/tests/Feature/Volt/DevicePalettesTest.php
new file mode 100644
index 0000000..376a4a6
--- /dev/null
+++ b/tests/Feature/Volt/DevicePalettesTest.php
@@ -0,0 +1,575 @@
+create();
+
+ $this->actingAs($user);
+
+ $this->get(route('device-palettes.index'))->assertOk();
+});
+
+test('component loads all device palettes on mount', function (): void {
+ $user = User::factory()->create();
+ $initialCount = DevicePalette::count();
+ DevicePalette::create(['name' => 'palette-1', 'grays' => 2, 'framework_class' => '']);
+ DevicePalette::create(['name' => 'palette-2', 'grays' => 4, 'framework_class' => '']);
+ DevicePalette::create(['name' => 'palette-3', 'grays' => 16, 'framework_class' => '']);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index');
+
+ $palettes = $component->get('devicePalettes');
+ expect($palettes)->toHaveCount($initialCount + 3);
+});
+
+test('can open modal to create new device palette', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal');
+
+ $component
+ ->assertSet('editingDevicePaletteId', null)
+ ->assertSet('viewingDevicePaletteId', null)
+ ->assertSet('name', null)
+ ->assertSet('grays', 2);
+});
+
+test('can create a new device palette', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('description', 'Test Palette Description')
+ ->set('grays', 16)
+ ->set('colors', ['#FF0000', '#00FF00'])
+ ->set('framework_class', 'TestFramework')
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+
+ expect(DevicePalette::where('name', 'test-palette')->exists())->toBeTrue();
+
+ $palette = DevicePalette::where('name', 'test-palette')->first();
+ expect($palette->description)->toBe('Test Palette Description');
+ expect($palette->grays)->toBe(16);
+ expect($palette->colors)->toBe(['#FF0000', '#00FF00']);
+ expect($palette->framework_class)->toBe('TestFramework');
+ expect($palette->source)->toBe('manual');
+});
+
+test('can create a grayscale-only palette without colors', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'grayscale-palette')
+ ->set('grays', 256)
+ ->set('colors', [])
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+
+ $palette = DevicePalette::where('name', 'grayscale-palette')->first();
+ expect($palette->colors)->toBeNull();
+ expect($palette->grays)->toBe(256);
+});
+
+test('can open modal to edit existing device palette', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'existing-palette',
+ 'description' => 'Existing Description',
+ 'grays' => 4,
+ 'colors' => ['#FF0000', '#00FF00'],
+ 'framework_class' => 'Framework',
+ 'source' => 'manual',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal', $palette->id);
+
+ $component
+ ->assertSet('editingDevicePaletteId', $palette->id)
+ ->assertSet('name', 'existing-palette')
+ ->assertSet('description', 'Existing Description')
+ ->assertSet('grays', 4)
+ ->assertSet('colors', ['#FF0000', '#00FF00'])
+ ->assertSet('framework_class', 'Framework');
+});
+
+test('can update an existing device palette', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'original-palette',
+ 'grays' => 2,
+ 'framework_class' => '',
+ 'source' => 'manual',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal', $palette->id)
+ ->set('name', 'updated-palette')
+ ->set('description', 'Updated Description')
+ ->set('grays', 16)
+ ->set('colors', ['#0000FF'])
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+
+ $palette->refresh();
+ expect($palette->name)->toBe('updated-palette');
+ expect($palette->description)->toBe('Updated Description');
+ expect($palette->grays)->toBe(16);
+ expect($palette->colors)->toBe(['#0000FF']);
+});
+
+test('can delete a device palette', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'to-delete',
+ 'grays' => 2,
+ 'framework_class' => '',
+ 'source' => 'manual',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('deleteDevicePalette', $palette->id);
+
+ expect(DevicePalette::find($palette->id))->toBeNull();
+ $component->assertSet('devicePalettes', function ($palettes) use ($palette) {
+ return $palettes->where('id', $palette->id)->isEmpty();
+ });
+});
+
+test('can duplicate a device palette', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'original-palette',
+ 'description' => 'Original Description',
+ 'grays' => 4,
+ 'colors' => ['#FF0000', '#00FF00'],
+ 'framework_class' => 'Framework',
+ 'source' => 'manual',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('duplicateDevicePalette', $palette->id);
+
+ $component
+ ->assertSet('editingDevicePaletteId', null)
+ ->assertSet('name', 'original-palette (Copy)')
+ ->assertSet('description', 'Original Description')
+ ->assertSet('grays', 4)
+ ->assertSet('colors', ['#FF0000', '#00FF00'])
+ ->assertSet('framework_class', 'Framework');
+});
+
+test('can add a color to the colors array', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colorInput', '#FF0000')
+ ->call('addColor');
+
+ $component
+ ->assertHasNoErrors()
+ ->assertSet('colors', ['#FF0000'])
+ ->assertSet('colorInput', '');
+});
+
+test('cannot add duplicate colors', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colors', ['#FF0000'])
+ ->set('colorInput', '#FF0000')
+ ->call('addColor');
+
+ $component
+ ->assertHasNoErrors()
+ ->assertSet('colors', ['#FF0000']);
+});
+
+test('can add multiple colors', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colorInput', '#FF0000')
+ ->call('addColor')
+ ->set('colorInput', '#00FF00')
+ ->call('addColor')
+ ->set('colorInput', '#0000FF')
+ ->call('addColor');
+
+ $component
+ ->assertSet('colors', ['#FF0000', '#00FF00', '#0000FF']);
+});
+
+test('can remove a color from the colors array', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colors', ['#FF0000', '#00FF00', '#0000FF'])
+ ->call('removeColor', 1);
+
+ $component->assertSet('colors', ['#FF0000', '#0000FF']);
+});
+
+test('removing color reindexes array', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colors', ['#FF0000', '#00FF00', '#0000FF'])
+ ->call('removeColor', 0);
+
+ $colors = $component->get('colors');
+ expect($colors)->toBe(['#00FF00', '#0000FF']);
+ expect(array_keys($colors))->toBe([0, 1]);
+});
+
+test('can open modal in view-only mode for api-sourced palette', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'api-palette',
+ 'grays' => 2,
+ 'framework_class' => '',
+ 'source' => 'api',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal', $palette->id, true);
+
+ $component
+ ->assertSet('viewingDevicePaletteId', $palette->id)
+ ->assertSet('editingDevicePaletteId', null);
+});
+
+test('name is required when creating device palette', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('grays', 16)
+ ->call('saveDevicePalette');
+
+ $component->assertHasErrors(['name']);
+});
+
+test('name must be unique when creating device palette', function (): void {
+ $user = User::factory()->create();
+ DevicePalette::create([
+ 'name' => 'existing-name',
+ 'grays' => 2,
+ 'framework_class' => '',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'existing-name')
+ ->set('grays', 16)
+ ->call('saveDevicePalette');
+
+ $component->assertHasErrors(['name']);
+});
+
+test('name can be same when updating device palette', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'original-name',
+ 'grays' => 2,
+ 'framework_class' => '',
+ 'source' => 'manual',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal', $palette->id)
+ ->set('grays', 16)
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+});
+
+test('grays is required when creating device palette', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', null)
+ ->call('saveDevicePalette');
+
+ $component->assertHasErrors(['grays']);
+});
+
+test('grays must be at least 1', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', 0)
+ ->call('saveDevicePalette');
+
+ $component->assertHasErrors(['grays']);
+});
+
+test('grays must be at most 256', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', 257)
+ ->call('saveDevicePalette');
+
+ $component->assertHasErrors(['grays']);
+});
+
+test('colors must be valid hex format', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', 16)
+ ->set('colors', ['invalid-color', '#FF0000'])
+ ->call('saveDevicePalette');
+
+ $component->assertHasErrors(['colors.0']);
+});
+
+test('color input must be valid hex format when adding color', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colorInput', 'invalid-color')
+ ->call('addColor');
+
+ $component->assertHasErrors(['colorInput']);
+});
+
+test('color input accepts valid hex format', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colorInput', '#FF0000')
+ ->call('addColor');
+
+ $component->assertHasNoErrors();
+});
+
+test('color input accepts lowercase hex format', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('colorInput', '#ff0000')
+ ->call('addColor');
+
+ $component->assertHasNoErrors();
+});
+
+test('description can be null', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', 16)
+ ->set('description', null)
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+
+ $palette = DevicePalette::where('name', 'test-palette')->first();
+ expect($palette->description)->toBeNull();
+});
+
+test('framework class can be empty string', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', 16)
+ ->set('framework_class', '')
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+
+ $palette = DevicePalette::where('name', 'test-palette')->first();
+ expect($palette->framework_class)->toBe('');
+});
+
+test('empty colors array is saved as null', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('grays', 16)
+ ->set('colors', [])
+ ->call('saveDevicePalette');
+
+ $component->assertHasNoErrors();
+
+ $palette = DevicePalette::where('name', 'test-palette')->first();
+ expect($palette->colors)->toBeNull();
+});
+
+test('component resets form after saving', function (): void {
+ $user = User::factory()->create();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'test-palette')
+ ->set('description', 'Test Description')
+ ->set('grays', 16)
+ ->set('colors', ['#FF0000'])
+ ->set('framework_class', 'TestFramework')
+ ->call('saveDevicePalette');
+
+ $component
+ ->assertSet('name', null)
+ ->assertSet('description', null)
+ ->assertSet('grays', 2)
+ ->assertSet('colors', [])
+ ->assertSet('framework_class', '')
+ ->assertSet('colorInput', '')
+ ->assertSet('editingDevicePaletteId', null)
+ ->assertSet('viewingDevicePaletteId', null);
+});
+
+test('component handles palette with null colors when editing', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'grayscale-palette',
+ 'grays' => 2,
+ 'colors' => null,
+ 'framework_class' => '',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal', $palette->id);
+
+ $component->assertSet('colors', []);
+});
+
+test('component handles palette with string colors when editing', function (): void {
+ $user = User::factory()->create();
+ $palette = DevicePalette::create([
+ 'name' => 'string-colors-palette',
+ 'grays' => 2,
+ 'framework_class' => '',
+ ]);
+ // Manually set colors as JSON string to simulate edge case
+ $palette->setRawAttributes(array_merge($palette->getAttributes(), [
+ 'colors' => json_encode(['#FF0000', '#00FF00']),
+ ]));
+ $palette->save();
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('openDevicePaletteModal', $palette->id);
+
+ $component->assertSet('colors', ['#FF0000', '#00FF00']);
+});
+
+test('component refreshes palette list after creating', function (): void {
+ $user = User::factory()->create();
+ $initialCount = DevicePalette::count();
+ DevicePalette::create(['name' => 'palette-1', 'grays' => 2, 'framework_class' => '']);
+ DevicePalette::create(['name' => 'palette-2', 'grays' => 4, 'framework_class' => '']);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->set('name', 'new-palette')
+ ->set('grays', 16)
+ ->call('saveDevicePalette');
+
+ $palettes = $component->get('devicePalettes');
+ expect($palettes)->toHaveCount($initialCount + 3);
+ expect(DevicePalette::count())->toBe($initialCount + 3);
+});
+
+test('component refreshes palette list after deleting', function (): void {
+ $user = User::factory()->create();
+ $initialCount = DevicePalette::count();
+ $palette1 = DevicePalette::create([
+ 'name' => 'palette-1',
+ 'grays' => 2,
+ 'framework_class' => '',
+ 'source' => 'manual',
+ ]);
+ $palette2 = DevicePalette::create([
+ 'name' => 'palette-2',
+ 'grays' => 2,
+ 'framework_class' => '',
+ 'source' => 'manual',
+ ]);
+
+ $this->actingAs($user);
+
+ $component = Volt::test('device-palettes.index')
+ ->call('deleteDevicePalette', $palette1->id);
+
+ $palettes = $component->get('devicePalettes');
+ expect($palettes)->toHaveCount($initialCount + 1);
+ expect(DevicePalette::count())->toBe($initialCount + 1);
+});
diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php
index 44a4f33..963bc0c 100644
--- a/tests/Unit/ExampleTest.php
+++ b/tests/Unit/ExampleTest.php
@@ -1,5 +1,5 @@
toBeTrue();
});
diff --git a/tests/Unit/Liquid/Filters/DataTest.php b/tests/Unit/Liquid/Filters/DataTest.php
index bdf649f..1200b6f 100644
--- a/tests/Unit/Liquid/Filters/DataTest.php
+++ b/tests/Unit/Liquid/Filters/DataTest.php
@@ -2,14 +2,14 @@
use App\Liquid\Filters\Data;
-test('json filter converts arrays to JSON', function () {
+test('json filter converts arrays to JSON', function (): void {
$filter = new Data();
$array = ['foo' => 'bar', 'baz' => 'qux'];
expect($filter->json($array))->toBe('{"foo":"bar","baz":"qux"}');
});
-test('json filter converts objects to JSON', function () {
+test('json filter converts objects to JSON', function (): void {
$filter = new Data();
$object = new stdClass();
$object->foo = 'bar';
@@ -18,7 +18,7 @@ test('json filter converts objects to JSON', function () {
expect($filter->json($object))->toBe('{"foo":"bar","baz":"qux"}');
});
-test('json filter handles nested structures', function () {
+test('json filter handles nested structures', function (): void {
$filter = new Data();
$nested = [
'foo' => 'bar',
@@ -31,7 +31,7 @@ test('json filter handles nested structures', function () {
expect($filter->json($nested))->toBe('{"foo":"bar","nested":{"baz":"qux","items":[1,2,3]}}');
});
-test('json filter handles scalar values', function () {
+test('json filter handles scalar values', function (): void {
$filter = new Data();
expect($filter->json('string'))->toBe('"string"');
@@ -40,21 +40,21 @@ test('json filter handles scalar values', function () {
expect($filter->json(null))->toBe('null');
});
-test('json filter preserves unicode characters', function () {
+test('json filter preserves unicode characters', function (): void {
$filter = new Data();
$data = ['message' => 'Hello, 世界'];
expect($filter->json($data))->toBe('{"message":"Hello, 世界"}');
});
-test('json filter does not escape slashes', function () {
+test('json filter does not escape slashes', function (): void {
$filter = new Data();
$data = ['url' => 'https://example.com/path'];
expect($filter->json($data))->toBe('{"url":"https://example.com/path"}');
});
-test('find_by filter finds object by key-value pair', function () {
+test('find_by filter finds object by key-value pair', function (): void {
$filter = new Data();
$collection = [
['name' => 'Ryan', 'age' => 35],
@@ -66,7 +66,7 @@ test('find_by filter finds object by key-value pair', function () {
expect($result)->toBe(['name' => 'Ryan', 'age' => 35]);
});
-test('find_by filter returns null when no match found', function () {
+test('find_by filter returns null when no match found', function (): void {
$filter = new Data();
$collection = [
['name' => 'Ryan', 'age' => 35],
@@ -78,7 +78,7 @@ test('find_by filter returns null when no match found', function () {
expect($result)->toBeNull();
});
-test('find_by filter returns fallback when no match found', function () {
+test('find_by filter returns fallback when no match found', function (): void {
$filter = new Data();
$collection = [
['name' => 'Ryan', 'age' => 35],
@@ -90,7 +90,7 @@ test('find_by filter returns fallback when no match found', function () {
expect($result)->toBe('Not Found');
});
-test('find_by filter finds by age', function () {
+test('find_by filter finds by age', function (): void {
$filter = new Data();
$collection = [
['name' => 'Ryan', 'age' => 35],
@@ -102,7 +102,7 @@ test('find_by filter finds by age', function () {
expect($result)->toBe(['name' => 'Sara', 'age' => 29]);
});
-test('find_by filter handles empty collection', function () {
+test('find_by filter handles empty collection', function (): void {
$filter = new Data();
$collection = [];
@@ -110,7 +110,7 @@ test('find_by filter handles empty collection', function () {
expect($result)->toBeNull();
});
-test('find_by filter handles collection with non-array items', function () {
+test('find_by filter handles collection with non-array items', function (): void {
$filter = new Data();
$collection = [
'not an array',
@@ -122,7 +122,7 @@ test('find_by filter handles collection with non-array items', function () {
expect($result)->toBe(['name' => 'Ryan', 'age' => 35]);
});
-test('find_by filter handles items without the specified key', function () {
+test('find_by filter handles items without the specified key', function (): void {
$filter = new Data();
$collection = [
['age' => 35],
@@ -134,7 +134,7 @@ test('find_by filter handles items without the specified key', function () {
expect($result)->toBe(['name' => 'Ryan', 'age' => 35]);
});
-test('group_by filter groups collection by age', function () {
+test('group_by filter groups collection by age', function (): void {
$filter = new Data();
$collection = [
['name' => 'Ryan', 'age' => 35],
@@ -153,7 +153,7 @@ test('group_by filter groups collection by age', function () {
]);
});
-test('group_by filter groups collection by name', function () {
+test('group_by filter groups collection by name', function (): void {
$filter = new Data();
$collection = [
['name' => 'Ryan', 'age' => 35],
@@ -172,7 +172,7 @@ test('group_by filter groups collection by name', function () {
]);
});
-test('group_by filter handles empty collection', function () {
+test('group_by filter handles empty collection', function (): void {
$filter = new Data();
$collection = [];
@@ -180,7 +180,7 @@ test('group_by filter handles empty collection', function () {
expect($result)->toBe([]);
});
-test('group_by filter handles collection with non-array items', function () {
+test('group_by filter handles collection with non-array items', function (): void {
$filter = new Data();
$collection = [
'not an array',
@@ -197,7 +197,7 @@ test('group_by filter handles collection with non-array items', function () {
]);
});
-test('group_by filter handles items without the specified key', function () {
+test('group_by filter handles items without the specified key', function (): void {
$filter = new Data();
$collection = [
['age' => 35],
@@ -217,7 +217,7 @@ test('group_by filter handles items without the specified key', function () {
]);
});
-test('group_by filter handles mixed data types as keys', function () {
+test('group_by filter handles mixed data types as keys', function (): void {
$filter = new Data();
$collection = [
['name' => 'Ryan', 'active' => true],
@@ -238,7 +238,7 @@ test('group_by filter handles mixed data types as keys', function () {
]);
});
-test('sample filter returns a random element from array', function () {
+test('sample filter returns a random element from array', function (): void {
$filter = new Data();
$array = ['1', '2', '3', '4', '5'];
@@ -246,7 +246,7 @@ test('sample filter returns a random element from array', function () {
expect($result)->toBeIn($array);
});
-test('sample filter returns a random element from string array', function () {
+test('sample filter returns a random element from string array', function (): void {
$filter = new Data();
$array = ['cat', 'dog'];
@@ -254,7 +254,7 @@ test('sample filter returns a random element from string array', function () {
expect($result)->toBeIn($array);
});
-test('sample filter returns null for empty array', function () {
+test('sample filter returns null for empty array', function (): void {
$filter = new Data();
$array = [];
@@ -262,7 +262,7 @@ test('sample filter returns null for empty array', function () {
expect($result)->toBeNull();
});
-test('sample filter returns the only element from single element array', function () {
+test('sample filter returns the only element from single element array', function (): void {
$filter = new Data();
$array = ['single'];
@@ -270,7 +270,7 @@ test('sample filter returns the only element from single element array', functio
expect($result)->toBe('single');
});
-test('sample filter works with mixed data types', function () {
+test('sample filter works with mixed data types', function (): void {
$filter = new Data();
$array = [1, 'string', true, null, ['nested']];
@@ -278,7 +278,7 @@ test('sample filter works with mixed data types', function () {
expect($result)->toBeIn($array);
});
-test('parse_json filter parses JSON string to array', function () {
+test('parse_json filter parses JSON string to array', function (): void {
$filter = new Data();
$jsonString = '[{"a":1,"b":"c"},"d"]';
@@ -286,7 +286,7 @@ test('parse_json filter parses JSON string to array', function () {
expect($result)->toBe([['a' => 1, 'b' => 'c'], 'd']);
});
-test('parse_json filter parses simple JSON object', function () {
+test('parse_json filter parses simple JSON object', function (): void {
$filter = new Data();
$jsonString = '{"name":"John","age":30,"city":"New York"}';
@@ -294,7 +294,7 @@ test('parse_json filter parses simple JSON object', function () {
expect($result)->toBe(['name' => 'John', 'age' => 30, 'city' => 'New York']);
});
-test('parse_json filter parses JSON array', function () {
+test('parse_json filter parses JSON array', function (): void {
$filter = new Data();
$jsonString = '["apple","banana","cherry"]';
@@ -302,7 +302,7 @@ test('parse_json filter parses JSON array', function () {
expect($result)->toBe(['apple', 'banana', 'cherry']);
});
-test('parse_json filter parses nested JSON structure', function () {
+test('parse_json filter parses nested JSON structure', function (): void {
$filter = new Data();
$jsonString = '{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}],"total":2}';
@@ -316,7 +316,7 @@ test('parse_json filter parses nested JSON structure', function () {
]);
});
-test('parse_json filter handles primitive values', function () {
+test('parse_json filter handles primitive values', function (): void {
$filter = new Data();
expect($filter->parse_json('"hello"'))->toBe('hello');
@@ -325,3 +325,173 @@ test('parse_json filter handles primitive values', function () {
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/Filters/DateTest.php b/tests/Unit/Liquid/Filters/DateTest.php
index 5813e10..7de8949 100644
--- a/tests/Unit/Liquid/Filters/DateTest.php
+++ b/tests/Unit/Liquid/Filters/DateTest.php
@@ -3,30 +3,92 @@
use App\Liquid\Filters\Date;
use Carbon\Carbon;
-test('days_ago filter returns correct date', function () {
+test('days_ago filter returns correct date', function (): void {
$filter = new Date();
$threeDaysAgo = Carbon::now()->subDays(3)->toDateString();
expect($filter->days_ago(3))->toBe($threeDaysAgo);
});
-test('days_ago filter handles string input', function () {
+test('days_ago filter handles string input', function (): void {
$filter = new Date();
$fiveDaysAgo = Carbon::now()->subDays(5)->toDateString();
expect($filter->days_ago('5'))->toBe($fiveDaysAgo);
});
-test('days_ago filter with zero days returns today', function () {
+test('days_ago filter with zero days returns today', function (): void {
$filter = new Date();
$today = Carbon::now()->toDateString();
expect($filter->days_ago(0))->toBe($today);
});
-test('days_ago filter with large number works correctly', function () {
+test('days_ago filter with large number works correctly', function (): void {
$filter = new Date();
$hundredDaysAgo = Carbon::now()->subDays(100)->toDateString();
expect($filter->days_ago(100))->toBe($hundredDaysAgo);
});
+
+test('ordinalize filter formats date with ordinal day', function (): void {
+ $filter = new Date();
+
+ expect($filter->ordinalize('2025-10-02', '%A, %B <>, %Y'))
+ ->toBe('Thursday, October 2nd, 2025');
+});
+
+test('ordinalize filter handles datetime string with timezone', function (): void {
+ $filter = new Date();
+
+ expect($filter->ordinalize('2025-12-31 16:50:38 -0400', '%A, %b <>'))
+ ->toBe('Wednesday, Dec 31st');
+});
+
+test('ordinalize filter handles different ordinal suffixes', function (): void {
+ $filter = new Date();
+
+ // 1st
+ expect($filter->ordinalize('2025-01-01', '<>'))
+ ->toBe('1st');
+
+ // 2nd
+ expect($filter->ordinalize('2025-01-02', '<>'))
+ ->toBe('2nd');
+
+ // 3rd
+ expect($filter->ordinalize('2025-01-03', '<>'))
+ ->toBe('3rd');
+
+ // 4th
+ expect($filter->ordinalize('2025-01-04', '<>'))
+ ->toBe('4th');
+
+ // 11th (special case)
+ expect($filter->ordinalize('2025-01-11', '<>'))
+ ->toBe('11th');
+
+ // 12th (special case)
+ expect($filter->ordinalize('2025-01-12', '<>'))
+ ->toBe('12th');
+
+ // 13th (special case)
+ expect($filter->ordinalize('2025-01-13', '<>'))
+ ->toBe('13th');
+
+ // 21st
+ expect($filter->ordinalize('2025-01-21', '<>'))
+ ->toBe('21st');
+
+ // 22nd
+ expect($filter->ordinalize('2025-01-22', '<>'))
+ ->toBe('22nd');
+
+ // 23rd
+ expect($filter->ordinalize('2025-01-23', '<>'))
+ ->toBe('23rd');
+
+ // 24th
+ expect($filter->ordinalize('2025-01-24', '<>'))
+ ->toBe('24th');
+});
diff --git a/tests/Unit/Liquid/Filters/LocalizationTest.php b/tests/Unit/Liquid/Filters/LocalizationTest.php
index 384c837..3129b1e 100644
--- a/tests/Unit/Liquid/Filters/LocalizationTest.php
+++ b/tests/Unit/Liquid/Filters/LocalizationTest.php
@@ -2,7 +2,7 @@
use App\Liquid\Filters\Localization;
-test('l_date formats date with default format', function () {
+test('l_date formats date with default format', function (): void {
$filter = new Localization();
$date = '2025-01-11';
@@ -15,7 +15,7 @@ test('l_date formats date with default format', function () {
expect($result)->toContain('11');
});
-test('l_date formats date with custom format', function () {
+test('l_date formats date with custom format', function (): void {
$filter = new Localization();
$date = '2025-01-11';
@@ -27,7 +27,7 @@ test('l_date formats date with custom format', function () {
// We can't check for 'Jan' specifically as it might be localized
});
-test('l_date handles DateTime objects', function () {
+test('l_date handles DateTime objects', function (): void {
$filter = new Localization();
$date = new DateTimeImmutable('2025-01-11');
@@ -36,27 +36,102 @@ test('l_date handles DateTime objects', function () {
expect($result)->toContain('2025-01-11');
});
-test('l_word translates common words', function () {
+test('l_word translates common words', function (): void {
$filter = new Localization();
expect($filter->l_word('today', 'de'))->toBe('heute');
});
-test('l_word returns original word if no translation exists', function () {
+test('l_word returns original word if no translation exists', function (): void {
$filter = new Localization();
expect($filter->l_word('hello', 'es-ES'))->toBe('hello');
expect($filter->l_word('world', 'ko'))->toBe('world');
});
-test('l_word is case-insensitive', function () {
+test('l_word is case-insensitive', function (): void {
$filter = new Localization();
expect($filter->l_word('TODAY', 'de'))->toBe('heute');
});
-test('l_word returns original word for unknown locales', function () {
+test('l_word returns original word for unknown locales', function (): void {
$filter = new Localization();
expect($filter->l_word('today', 'unknown-locale'))->toBe('today');
});
+
+test('l_date handles locale parameter', function (): void {
+ $filter = new Localization();
+ $date = '2025-01-11';
+
+ $result = $filter->l_date($date, 'Y-m-d', 'de');
+
+ // The result should still contain the date components
+ expect($result)->toContain('2025');
+ expect($result)->toContain('01');
+ expect($result)->toContain('11');
+});
+
+test('l_date handles null locale parameter', function (): void {
+ $filter = new Localization();
+ $date = '2025-01-11';
+
+ $result = $filter->l_date($date, 'Y-m-d');
+
+ // Should work the same as default
+ expect($result)->toContain('2025');
+ expect($result)->toContain('01');
+ expect($result)->toContain('11');
+});
+
+test('l_date handles different date formats with locale', function (): void {
+ $filter = new Localization();
+ $date = '2025-01-11';
+
+ $result = $filter->l_date($date, '%B %d, %Y', 'en');
+
+ // Should contain the month name and date
+ expect($result)->toContain('2025');
+ expect($result)->toContain('11');
+});
+
+test('l_date handles DateTimeInterface objects with locale', function (): void {
+ $filter = new Localization();
+ $date = new DateTimeImmutable('2025-01-11');
+
+ $result = $filter->l_date($date, 'Y-m-d', 'fr');
+
+ // Should still format correctly
+ expect($result)->toContain('2025');
+ expect($result)->toContain('01');
+ expect($result)->toContain('11');
+});
+
+test('l_date handles invalid date gracefully', function (): void {
+ $filter = new Localization();
+ $invalidDate = 'invalid-date';
+
+ // This should throw an exception or return a default value
+ // The exact behavior depends on Carbon's implementation
+ expect(fn (): string => $filter->l_date($invalidDate))->toThrow(Exception::class);
+});
+
+test('l_word handles empty string', function (): void {
+ $filter = new Localization();
+
+ expect($filter->l_word('', 'de'))->toBe('');
+});
+
+test('l_word handles special characters', function (): void {
+ $filter = new Localization();
+
+ // Test with a word that has special characters
+ expect($filter->l_word('café', 'de'))->toBe('café');
+});
+
+test('l_word handles numeric strings', function (): void {
+ $filter = new Localization();
+
+ expect($filter->l_word('123', 'de'))->toBe('123');
+});
diff --git a/tests/Unit/Liquid/Filters/NumbersTest.php b/tests/Unit/Liquid/Filters/NumbersTest.php
index 8ea73bf..42deffb 100644
--- a/tests/Unit/Liquid/Filters/NumbersTest.php
+++ b/tests/Unit/Liquid/Filters/NumbersTest.php
@@ -2,7 +2,7 @@
use App\Liquid\Filters\Numbers;
-test('number_with_delimiter formats numbers with commas by default', function () {
+test('number_with_delimiter formats numbers with commas by default', function (): void {
$filter = new Numbers();
expect($filter->number_with_delimiter(1234))->toBe('1,234');
@@ -10,21 +10,21 @@ test('number_with_delimiter formats numbers with commas by default', function ()
expect($filter->number_with_delimiter(0))->toBe('0');
});
-test('number_with_delimiter handles custom delimiters', function () {
+test('number_with_delimiter handles custom delimiters', function (): void {
$filter = new Numbers();
expect($filter->number_with_delimiter(1234, '.'))->toBe('1.234');
expect($filter->number_with_delimiter(1000000, ' '))->toBe('1 000 000');
});
-test('number_with_delimiter handles decimal values with custom separators', function () {
+test('number_with_delimiter handles decimal values with custom separators', function (): void {
$filter = new Numbers();
expect($filter->number_with_delimiter(1234.57, ' ', ','))->toBe('1 234,57');
expect($filter->number_with_delimiter(1234.5, '.', ','))->toBe('1.234,50');
});
-test('number_to_currency formats numbers with dollar sign by default', function () {
+test('number_to_currency formats numbers with dollar sign by default', function (): void {
$filter = new Numbers();
expect($filter->number_to_currency(1234))->toBe('$1,234');
@@ -32,16 +32,107 @@ test('number_to_currency formats numbers with dollar sign by default', function
expect($filter->number_to_currency(0))->toBe('$0');
});
-test('number_to_currency handles custom currency symbols', function () {
+test('number_to_currency handles custom currency symbols', function (): void {
$filter = new Numbers();
expect($filter->number_to_currency(1234, '£'))->toBe('£1,234');
expect($filter->number_to_currency(152350.69, '€'))->toBe('€152,350.69');
});
-test('number_to_currency handles custom delimiters and separators', function () {
+test('number_to_currency handles custom delimiters and separators', function (): void {
$filter = new Numbers();
- expect($filter->number_to_currency(1234.57, '£', '.', ','))->toBe('1.234,57 £');
- expect($filter->number_to_currency(1234.57, '€', ',', '.'))->toBe('€1,234.57');
+ $result1 = $filter->number_to_currency(1234.57, '£', '.', ',');
+ $result2 = $filter->number_to_currency(1234.57, '€', ',', '.');
+
+ expect($result1)->toContain('1.234,57');
+ expect($result1)->toContain('£');
+ expect($result2)->toContain('1,234.57');
+ expect($result2)->toContain('€');
+});
+
+test('number_with_delimiter handles string numbers', function (): void {
+ $filter = new Numbers();
+
+ expect($filter->number_with_delimiter('1234'))->toBe('1,234');
+ expect($filter->number_with_delimiter('1234.56'))->toBe('1,234.56');
+});
+
+test('number_with_delimiter handles negative numbers', function (): void {
+ $filter = new Numbers();
+
+ expect($filter->number_with_delimiter(-1234))->toBe('-1,234');
+ expect($filter->number_with_delimiter(-1234.56))->toBe('-1,234.56');
+});
+
+test('number_with_delimiter handles zero', function (): void {
+ $filter = new Numbers();
+
+ expect($filter->number_with_delimiter(0))->toBe('0');
+ expect($filter->number_with_delimiter(0.0))->toBe('0.00');
+});
+
+test('number_with_delimiter handles very small numbers', function (): void {
+ $filter = new Numbers();
+
+ expect($filter->number_with_delimiter(0.01))->toBe('0.01');
+ expect($filter->number_with_delimiter(0.001))->toBe('0.00');
+});
+
+test('number_to_currency handles string numbers', function (): void {
+ $filter = new Numbers();
+
+ expect($filter->number_to_currency('1234'))->toBe('$1,234');
+ expect($filter->number_to_currency('1234.56'))->toBe('$1,234.56');
+});
+
+test('number_to_currency handles negative numbers', function (): void {
+ $filter = new Numbers();
+
+ expect($filter->number_to_currency(-1234))->toBe('-$1,234');
+ expect($filter->number_to_currency(-1234.56))->toBe('-$1,234.56');
+});
+
+test('number_to_currency handles zero', function (): void {
+ $filter = new Numbers();
+
+ expect($filter->number_to_currency(0))->toBe('$0');
+ expect($filter->number_to_currency(0.0))->toBe('$0.00');
+});
+
+test('number_to_currency handles currency code conversion', function (): void {
+ $filter = new Numbers();
+
+ expect($filter->number_to_currency(1234, '$'))->toBe('$1,234');
+ expect($filter->number_to_currency(1234, '€'))->toBe('€1,234');
+ expect($filter->number_to_currency(1234, '£'))->toBe('£1,234');
+});
+
+test('number_to_currency handles German locale formatting', function (): void {
+ $filter = new Numbers();
+
+ // When delimiter is '.' and separator is ',', it should use German locale
+ $result = $filter->number_to_currency(1234.56, 'EUR', '.', ',');
+ expect($result)->toContain('1.234,56');
+});
+
+test('number_with_delimiter handles different decimal separators', function (): void {
+ $filter = new Numbers();
+
+ expect($filter->number_with_delimiter(1234.56, ',', ','))->toBe('1,234,56');
+ expect($filter->number_with_delimiter(1234.56, ' ', ','))->toBe('1 234,56');
+});
+
+test('number_to_currency handles very large numbers', function (): void {
+ $filter = new Numbers();
+
+ expect($filter->number_to_currency(1000000))->toBe('$1,000,000');
+ expect($filter->number_to_currency(1000000.50))->toBe('$1,000,000.50');
+});
+
+test('number_with_delimiter handles very large numbers', function (): void {
+ $filter = new Numbers();
+
+ expect($filter->number_with_delimiter(1000000))->toBe('1,000,000');
+ expect($filter->number_with_delimiter(1000000.50))->toBe('1,000,000.50');
});
diff --git a/tests/Unit/Liquid/Filters/StringMarkupTest.php b/tests/Unit/Liquid/Filters/StringMarkupTest.php
index 4021a07..bfd1a07 100644
--- a/tests/Unit/Liquid/Filters/StringMarkupTest.php
+++ b/tests/Unit/Liquid/Filters/StringMarkupTest.php
@@ -2,35 +2,35 @@
use App\Liquid\Filters\StringMarkup;
-test('pluralize returns singular form with count 1', function () {
+test('pluralize returns singular form with count 1', function (): void {
$filter = new StringMarkup();
expect($filter->pluralize('book', 1))->toBe('1 book');
expect($filter->pluralize('person', 1))->toBe('1 person');
});
-test('pluralize returns plural form with count greater than 1', function () {
+test('pluralize returns plural form with count greater than 1', function (): void {
$filter = new StringMarkup();
expect($filter->pluralize('book', 2))->toBe('2 books');
expect($filter->pluralize('person', 4))->toBe('4 people');
});
-test('pluralize handles irregular plurals correctly', function () {
+test('pluralize handles irregular plurals correctly', function (): void {
$filter = new StringMarkup();
expect($filter->pluralize('child', 3))->toBe('3 children');
expect($filter->pluralize('sheep', 5))->toBe('5 sheep');
});
-test('pluralize uses default count of 2 when not specified', function () {
+test('pluralize uses default count of 2 when not specified', function (): void {
$filter = new StringMarkup();
expect($filter->pluralize('book'))->toBe('2 books');
expect($filter->pluralize('person'))->toBe('2 people');
});
-test('markdown_to_html converts basic markdown to HTML', function () {
+test('markdown_to_html converts basic markdown to HTML', function (): void {
$filter = new StringMarkup();
$markdown = 'This is *italic* and **bold**.';
@@ -42,7 +42,7 @@ test('markdown_to_html converts basic markdown to HTML', function () {
expect($result)->toContain('bold');
});
-test('markdown_to_html converts links correctly', function () {
+test('markdown_to_html converts links correctly', function (): void {
$filter = new StringMarkup();
$markdown = 'This is [a link](https://example.com).';
@@ -51,7 +51,7 @@ test('markdown_to_html converts links correctly', function () {
expect($result)->toContain('a link');
});
-test('markdown_to_html handles fallback when Parsedown is not available', function () {
+test('markdown_to_html handles fallback when Parsedown is not available', function (): void {
// Create a mock that simulates Parsedown not being available
$filter = new class extends StringMarkup
{
@@ -68,23 +68,103 @@ test('markdown_to_html handles fallback when Parsedown is not available', functi
expect($result)->toBe('This is *italic* and [a link](https://example.com).');
});
-test('strip_html removes HTML tags', function () {
+test('strip_html removes HTML tags', function (): void {
$filter = new StringMarkup();
$html = 'This is bold and italic.
';
expect($filter->strip_html($html))->toBe('This is bold and italic.');
});
-test('strip_html preserves text content', function () {
+test('strip_html preserves text content', function (): void {
$filter = new StringMarkup();
$html = 'Hello, world!
';
expect($filter->strip_html($html))->toBe('Hello, world!');
});
-test('strip_html handles nested tags', function () {
+test('strip_html handles nested tags', function (): void {
$filter = new StringMarkup();
$html = 'Paragraph with nested tags.
';
expect($filter->strip_html($html))->toBe('Paragraph with nested tags.');
});
+
+test('markdown_to_html handles CommonMarkException gracefully', function (): void {
+ $filter = new StringMarkup();
+
+ // Create a mock that throws CommonMarkException
+ $filter = new class extends StringMarkup
+ {
+ public function markdown_to_html(string $markdown): ?string
+ {
+ try {
+ // Simulate CommonMarkException
+ throw new Exception('Invalid markdown');
+ } catch (Exception $e) {
+ Illuminate\Support\Facades\Log::error('Markdown conversion error: '.$e->getMessage());
+ }
+
+ return null;
+ }
+ };
+
+ $result = $filter->markdown_to_html('invalid markdown');
+
+ expect($result)->toBeNull();
+});
+
+test('markdown_to_html handles empty string', function (): void {
+ $filter = new StringMarkup();
+
+ $result = $filter->markdown_to_html('');
+
+ expect($result)->toBe('');
+});
+
+test('markdown_to_html handles complex markdown', function (): void {
+ $filter = new StringMarkup();
+ $markdown = "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- List item 1\n- List item 2\n\n[Link](https://example.com)";
+
+ $result = $filter->markdown_to_html($markdown);
+
+ expect($result)->toContain('Heading
');
+ expect($result)->toContain('bold');
+ expect($result)->toContain('italic');
+ expect($result)->toContain('');
+ expect($result)->toContain('- List item 1
');
+ expect($result)->toContain('Link');
+});
+
+test('strip_html handles empty string', function (): void {
+ $filter = new StringMarkup();
+
+ expect($filter->strip_html(''))->toBe('');
+});
+
+test('strip_html handles string without HTML tags', function (): void {
+ $filter = new StringMarkup();
+ $text = 'This is plain text without any HTML tags.';
+
+ expect($filter->strip_html($text))->toBe($text);
+});
+
+test('strip_html handles self-closing tags', function (): void {
+ $filter = new StringMarkup();
+ $html = 'Text with
line break and
horizontal rule.';
+
+ expect($filter->strip_html($html))->toBe('Text with line break and horizontal rule.');
+});
+
+test('pluralize handles zero count', function (): void {
+ $filter = new StringMarkup();
+
+ expect($filter->pluralize('book', 0))->toBe('0 books');
+ expect($filter->pluralize('person', 0))->toBe('0 people');
+});
+
+test('pluralize handles negative count', function (): void {
+ $filter = new StringMarkup();
+
+ expect($filter->pluralize('book', -1))->toBe('-1 book');
+ expect($filter->pluralize('person', -5))->toBe('-5 people');
+});
diff --git a/tests/Unit/Liquid/Filters/UniquenessTest.php b/tests/Unit/Liquid/Filters/UniquenessTest.php
index 291f312..76840e1 100644
--- a/tests/Unit/Liquid/Filters/UniquenessTest.php
+++ b/tests/Unit/Liquid/Filters/UniquenessTest.php
@@ -2,7 +2,7 @@
use App\Liquid\Filters\Uniqueness;
-test('append_random appends a random string with 4 characters', function () {
+test('append_random appends a random string with 4 characters', function (): void {
$filter = new Uniqueness();
$result = $filter->append_random('chart-');
diff --git a/tests/Unit/Liquid/Utils/ExpressionUtilsTest.php b/tests/Unit/Liquid/Utils/ExpressionUtilsTest.php
new file mode 100644
index 0000000..ee4d2fd
--- /dev/null
+++ b/tests/Unit/Liquid/Utils/ExpressionUtilsTest.php
@@ -0,0 +1,201 @@
+ 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');
+});
diff --git a/tests/Unit/Models/DeviceLogTest.php b/tests/Unit/Models/DeviceLogTest.php
index 37e128f..f28f4cd 100644
--- a/tests/Unit/Models/DeviceLogTest.php
+++ b/tests/Unit/Models/DeviceLogTest.php
@@ -6,7 +6,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
-test('device log belongs to a device', function () {
+test('device log belongs to a device', function (): void {
$device = Device::factory()->create();
$log = DeviceLog::factory()->create(['device_id' => $device->id]);
@@ -14,7 +14,7 @@ test('device log belongs to a device', function () {
->and($log->device->id)->toBe($device->id);
});
-test('device log casts log_entry to array', function () {
+test('device log casts log_entry to array', function (): void {
Device::factory()->create();
$log = DeviceLog::factory()->create([
'log_entry' => [
@@ -29,7 +29,7 @@ test('device log casts log_entry to array', function () {
->and($log->log_entry['level'])->toBe('info');
});
-test('device log casts device_timestamp to datetime', function () {
+test('device log casts device_timestamp to datetime', function (): void {
Device::factory()->create();
$timestamp = now();
$log = DeviceLog::factory()->create([
@@ -40,7 +40,7 @@ test('device log casts device_timestamp to datetime', function () {
->and($log->device_timestamp->timestamp)->toBe($timestamp->timestamp);
});
-test('device log factory creates valid data', function () {
+test('device log factory creates valid data', function (): void {
Device::factory()->create();
$log = DeviceLog::factory()->create();
@@ -50,7 +50,7 @@ test('device log factory creates valid data', function () {
->and($log->log_entry)->toHaveKeys(['creation_timestamp', 'device_status_stamp', 'log_id', 'log_message', 'log_codeline', 'log_sourcefile', 'additional_info']);
});
-test('device log can be created with minimal required fields', function () {
+test('device log can be created with minimal required fields', function (): void {
$device = Device::factory()->create();
$log = DeviceLog::create([
'device_id' => $device->id,
diff --git a/tests/Unit/Models/DeviceModelTest.php b/tests/Unit/Models/DeviceModelTest.php
new file mode 100644
index 0000000..8c2b6e9
--- /dev/null
+++ b/tests/Unit/Models/DeviceModelTest.php
@@ -0,0 +1,119 @@
+create([
+ 'name' => 'Test Model',
+ 'width' => 800,
+ 'height' => 480,
+ 'colors' => 4,
+ 'bit_depth' => 2,
+ 'scale_factor' => 1.0,
+ 'rotation' => 0,
+ 'offset_x' => 0,
+ 'offset_y' => 0,
+ ]);
+
+ expect($deviceModel->name)->toBe('Test Model');
+ expect($deviceModel->width)->toBe(800);
+ expect($deviceModel->height)->toBe(480);
+ expect($deviceModel->colors)->toBe(4);
+ expect($deviceModel->bit_depth)->toBe(2);
+ expect($deviceModel->scale_factor)->toBe(1.0);
+ expect($deviceModel->rotation)->toBe(0);
+ expect($deviceModel->offset_x)->toBe(0);
+ expect($deviceModel->offset_y)->toBe(0);
+});
+
+test('device model casts attributes correctly', function (): void {
+ $deviceModel = DeviceModel::factory()->create([
+ 'width' => '800',
+ 'height' => '480',
+ 'colors' => '4',
+ 'bit_depth' => '2',
+ 'scale_factor' => '1.5',
+ 'rotation' => '90',
+ 'offset_x' => '10',
+ 'offset_y' => '20',
+ ]);
+
+ expect($deviceModel->width)->toBeInt();
+ expect($deviceModel->height)->toBeInt();
+ expect($deviceModel->colors)->toBeInt();
+ expect($deviceModel->bit_depth)->toBeInt();
+ expect($deviceModel->scale_factor)->toBeFloat();
+ expect($deviceModel->rotation)->toBeInt();
+ expect($deviceModel->offset_x)->toBeInt();
+ expect($deviceModel->offset_y)->toBeInt();
+});
+
+test('get color depth attribute returns correct format for bit depth 2', function (): void {
+ $deviceModel = DeviceModel::factory()->create(['bit_depth' => 2]);
+
+ expect($deviceModel->getColorDepthAttribute())->toBe('2bit');
+});
+
+test('get color depth attribute returns correct format for bit depth 4', function (): void {
+ $deviceModel = DeviceModel::factory()->create(['bit_depth' => 4]);
+
+ expect($deviceModel->getColorDepthAttribute())->toBe('4bit');
+});
+
+test('get color depth attribute returns 4bit for bit depth greater than 4', function (): void {
+ $deviceModel = DeviceModel::factory()->create(['bit_depth' => 8]);
+
+ expect($deviceModel->getColorDepthAttribute())->toBe('4bit');
+});
+
+test('get color depth attribute returns null when bit depth is null', function (): void {
+ $deviceModel = new DeviceModel(['bit_depth' => null]);
+
+ expect($deviceModel->getColorDepthAttribute())->toBeNull();
+});
+
+test('get scale level attribute returns null for width 800 or less', function (): void {
+ $deviceModel = DeviceModel::factory()->create(['width' => 800]);
+
+ expect($deviceModel->getScaleLevelAttribute())->toBeNull();
+});
+
+test('get scale level attribute returns large for width between 801 and 1000', function (): void {
+ $deviceModel = DeviceModel::factory()->create(['width' => 900]);
+
+ expect($deviceModel->getScaleLevelAttribute())->toBe('large');
+});
+
+test('get scale level attribute returns xlarge for width between 1001 and 1400', function (): void {
+ $deviceModel = DeviceModel::factory()->create(['width' => 1200]);
+
+ expect($deviceModel->getScaleLevelAttribute())->toBe('xlarge');
+});
+
+test('get scale level attribute returns xxlarge for width greater than 1400', function (): void {
+ $deviceModel = DeviceModel::factory()->create(['width' => 1500]);
+
+ expect($deviceModel->getScaleLevelAttribute())->toBe('xxlarge');
+});
+
+test('get scale level attribute returns null when width is null', function (): void {
+ $deviceModel = new DeviceModel(['width' => null]);
+
+ expect($deviceModel->getScaleLevelAttribute())->toBeNull();
+});
+
+test('device model factory creates valid data', function (): void {
+ $deviceModel = DeviceModel::factory()->create();
+
+ expect($deviceModel->name)->not->toBeEmpty();
+ expect($deviceModel->width)->toBeInt();
+ expect($deviceModel->height)->toBeInt();
+ expect($deviceModel->colors)->toBeInt();
+ expect($deviceModel->bit_depth)->toBeInt();
+ expect($deviceModel->scale_factor)->toBeFloat();
+ expect($deviceModel->rotation)->toBeInt();
+ expect($deviceModel->offset_x)->toBeInt();
+ expect($deviceModel->offset_y)->toBeInt();
+});
diff --git a/tests/Unit/Models/PlaylistItemTest.php b/tests/Unit/Models/PlaylistItemTest.php
index 6bfe00c..428a165 100644
--- a/tests/Unit/Models/PlaylistItemTest.php
+++ b/tests/Unit/Models/PlaylistItemTest.php
@@ -4,7 +4,7 @@ use App\Models\Playlist;
use App\Models\PlaylistItem;
use App\Models\Plugin;
-test('playlist item belongs to playlist', function () {
+test('playlist item belongs to playlist', function (): void {
$playlist = Playlist::factory()->create();
$playlistItem = PlaylistItem::factory()->create(['playlist_id' => $playlist->id]);
@@ -13,7 +13,7 @@ test('playlist item belongs to playlist', function () {
->id->toBe($playlist->id);
});
-test('playlist item belongs to plugin', function () {
+test('playlist item belongs to plugin', function (): void {
$plugin = Plugin::factory()->create();
$playlistItem = PlaylistItem::factory()->create(['plugin_id' => $plugin->id]);
@@ -22,7 +22,7 @@ test('playlist item belongs to plugin', function () {
->id->toBe($plugin->id);
});
-test('playlist item can check if it is a mashup', function () {
+test('playlist item can check if it is a mashup', function (): void {
$plugin = Plugin::factory()->create();
$regularItem = PlaylistItem::factory()->create([
'mashup' => null,
@@ -44,7 +44,7 @@ test('playlist item can check if it is a mashup', function () {
->and($mashupItem->isMashup())->toBeTrue();
});
-test('playlist item can get mashup name', function () {
+test('playlist item can get mashup name', function (): void {
$plugin1 = Plugin::factory()->create();
$plugin2 = Plugin::factory()->create();
$mashupItem = PlaylistItem::factory()->create([
@@ -59,7 +59,7 @@ test('playlist item can get mashup name', function () {
expect($mashupItem->getMashupName())->toBe('Test Mashup');
});
-test('playlist item can get mashup layout type', function () {
+test('playlist item can get mashup layout type', function (): void {
$plugin1 = Plugin::factory()->create();
$plugin2 = Plugin::factory()->create();
$mashupItem = PlaylistItem::factory()->create([
@@ -74,7 +74,7 @@ test('playlist item can get mashup layout type', function () {
expect($mashupItem->getMashupLayoutType())->toBe('1Lx1R');
});
-test('playlist item can get mashup plugin ids', function () {
+test('playlist item can get mashup plugin ids', function (): void {
$plugin1 = Plugin::factory()->create();
$plugin2 = Plugin::factory()->create();
$mashupItem = PlaylistItem::factory()->create([
@@ -89,7 +89,7 @@ test('playlist item can get mashup plugin ids', function () {
expect($mashupItem->getMashupPluginIds())->toBe([$plugin1->id, $plugin2->id]);
});
-test('playlist item can get required plugin count for different layouts', function () {
+test('playlist item can get required plugin count for different layouts', function (): void {
$layouts = [
'1Lx1R' => 2,
'1Tx1B' => 2,
@@ -117,7 +117,7 @@ test('playlist item can get required plugin count for different layouts', functi
}
});
-test('playlist item can get layout type', function () {
+test('playlist item can get layout type', function (): void {
$layoutTypes = [
'1Lx1R' => 'vertical',
'1Lx2R' => 'vertical',
@@ -144,7 +144,7 @@ test('playlist item can get layout type', function () {
}
});
-test('playlist item can get layout size for different positions', function () {
+test('playlist item can get layout size for different positions', function (): void {
$plugin1 = Plugin::factory()->create();
$plugin2 = Plugin::factory()->create();
$plugin3 = Plugin::factory()->create();
@@ -163,7 +163,7 @@ test('playlist item can get layout size for different positions', function () {
->and($mashupItem->getLayoutSize(2))->toBe('half_vertical');
});
-test('playlist item can get available layouts', function () {
+test('playlist item can get available layouts', function (): void {
$layouts = PlaylistItem::getAvailableLayouts();
expect($layouts)->toBeArray()
@@ -171,7 +171,7 @@ test('playlist item can get available layouts', function () {
->and($layouts['1Lx1R'])->toBe('1 Left - 1 Right (2 plugins)');
});
-test('playlist item can get required plugin count for layout', function () {
+test('playlist item can get required plugin count for layout', function (): void {
$layouts = [
'1Lx1R' => 2,
'1Tx1B' => 2,
@@ -187,7 +187,7 @@ test('playlist item can get required plugin count for layout', function () {
}
});
-test('playlist item can create mashup', function () {
+test('playlist item can create mashup', function (): void {
$playlist = Playlist::factory()->create();
$plugins = Plugin::factory()->count(3)->create();
$pluginIds = $plugins->pluck('id')->toArray();
diff --git a/tests/Unit/Models/PlaylistTest.php b/tests/Unit/Models/PlaylistTest.php
index 55d31c7..62d3aaf 100644
--- a/tests/Unit/Models/PlaylistTest.php
+++ b/tests/Unit/Models/PlaylistTest.php
@@ -4,7 +4,7 @@ use App\Models\Device;
use App\Models\Playlist;
use App\Models\PlaylistItem;
-test('playlist has required attributes', function () {
+test('playlist has required attributes', function (): void {
$playlist = Playlist::factory()->create([
'name' => 'Test Playlist',
'is_active' => true,
@@ -21,7 +21,7 @@ test('playlist has required attributes', function () {
->active_until->format('H:i')->toBe('17:00');
});
-test('playlist belongs to device', function () {
+test('playlist belongs to device', function (): void {
$device = Device::factory()->create();
$playlist = Playlist::factory()->create(['device_id' => $device->id]);
@@ -30,7 +30,7 @@ test('playlist belongs to device', function () {
->id->toBe($device->id);
});
-test('playlist has many items', function () {
+test('playlist has many items', function (): void {
$playlist = Playlist::factory()->create();
$items = PlaylistItem::factory()->count(3)->create(['playlist_id' => $playlist->id]);
@@ -39,7 +39,7 @@ test('playlist has many items', function () {
->each->toBeInstanceOf(PlaylistItem::class);
});
-test('getNextPlaylistItem returns null when playlist is inactive', function () {
+test('getNextPlaylistItem returns null when playlist is inactive', function (): void {
$playlist = Playlist::factory()->create(['is_active' => false]);
expect($playlist->getNextPlaylistItem())->toBeNull();
diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php
index 248e6f5..aa9a28e 100644
--- a/tests/Unit/Models/PluginTest.php
+++ b/tests/Unit/Models/PluginTest.php
@@ -1,11 +1,13 @@
create([
'name' => 'Test Plugin',
'data_payload' => ['key' => 'value'],
@@ -18,7 +20,7 @@ test('plugin has required attributes', function () {
->uuid->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/');
});
-test('plugin automatically generates uuid on creation', function () {
+test('plugin automatically generates uuid on creation', function (): void {
$plugin = Plugin::factory()->create();
expect($plugin->uuid)
@@ -26,14 +28,14 @@ test('plugin automatically generates uuid on creation', function () {
->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/');
});
-test('plugin can have custom uuid', function () {
+test('plugin can have custom uuid', function (): void {
$uuid = Illuminate\Support\Str::uuid();
$plugin = Plugin::factory()->create(['uuid' => $uuid]);
expect($plugin->uuid)->toBe($uuid);
});
-test('plugin data_payload is cast to array', function () {
+test('plugin data_payload is cast to array', function (): void {
$data = ['key' => 'value'];
$plugin = Plugin::factory()->create(['data_payload' => $data]);
@@ -42,7 +44,7 @@ test('plugin data_payload is cast to array', function () {
->toBe($data);
});
-test('plugin can have polling body for POST requests', function () {
+test('plugin can have polling body for POST requests', function (): void {
$plugin = Plugin::factory()->create([
'polling_verb' => 'post',
'polling_body' => '{"query": "query { user { id name } }"}',
@@ -51,7 +53,7 @@ test('plugin can have polling body for POST requests', function () {
expect($plugin->polling_body)->toBe('{"query": "query { user { id name } }"}');
});
-test('updateDataPayload sends POST request with body when polling_verb is post', function () {
+test('updateDataPayload sends POST request with body when polling_verb is post', function (): void {
Http::fake([
'https://example.com/api' => Http::response(['success' => true], 200),
]);
@@ -65,14 +67,12 @@ test('updateDataPayload sends POST request with body when polling_verb is post',
$plugin->updateDataPayload();
- Http::assertSent(function ($request) {
- return $request->url() === 'https://example.com/api' &&
- $request->method() === 'POST' &&
- $request->body() === '{"query": "query { user { id name } }"}';
- });
+ Http::assertSent(fn ($request): bool => $request->url() === 'https://example.com/api' &&
+ $request->method() === 'POST' &&
+ $request->body() === '{"query": "query { user { id name } }"}');
});
-test('updateDataPayload handles multiple URLs with IDX_ prefixes', function () {
+test('updateDataPayload handles multiple URLs with IDX_ prefixes', function (): void {
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => "https://api1.example.com/data\nhttps://api2.example.com/weather\nhttps://api3.example.com/news",
@@ -99,7 +99,36 @@ test('updateDataPayload handles multiple URLs with IDX_ prefixes', function () {
expect($plugin->data_payload['IDX_2'])->toBe(['headline' => 'test']);
});
-test('updateDataPayload handles single URL without nesting', function () {
+test('updateDataPayload skips empty lines in polling_url and maintains sequential IDX keys', function (): void {
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ // empty lines and extra spaces between the URL to generate empty entries
+ 'polling_url' => "https://api1.example.com/data\n \n\nhttps://api2.example.com/weather\n ",
+ 'polling_verb' => 'get',
+ ]);
+
+ // Mock only the valid URLs
+ Http::fake([
+ 'https://api1.example.com/data' => Http::response(['item' => 'first'], 200),
+ 'https://api2.example.com/weather' => Http::response(['item' => 'second'], 200),
+ ]);
+
+ $plugin->updateDataPayload();
+
+ // payload should only have 2 items, and they should be indexed 0 and 1
+ expect($plugin->data_payload)->toHaveCount(2);
+ expect($plugin->data_payload)->toHaveKey('IDX_0');
+ expect($plugin->data_payload)->toHaveKey('IDX_1');
+
+ // data is correct
+ expect($plugin->data_payload['IDX_0'])->toBe(['item' => 'first']);
+ expect($plugin->data_payload['IDX_1'])->toBe(['item' => 'second']);
+
+ // no empty index exists
+ expect($plugin->data_payload)->not->toHaveKey('IDX_2');
+});
+
+test('updateDataPayload handles single URL without nesting', function (): void {
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://api.example.com/data',
@@ -120,7 +149,7 @@ test('updateDataPayload handles single URL without nesting', function () {
expect($plugin->data_payload)->not->toHaveKey('IDX_0');
});
-test('updateDataPayload resolves Liquid variables in polling_header', function () {
+test('updateDataPayload resolves Liquid variables in polling_header', function (): void {
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://api.example.com/data',
@@ -139,15 +168,13 @@ test('updateDataPayload resolves Liquid variables in polling_header', function (
$plugin->updateDataPayload();
- Http::assertSent(function ($request) {
- return $request->url() === 'https://api.example.com/data' &&
- $request->method() === 'GET' &&
- $request->header('Authorization')[0] === 'Bearer test123' &&
- $request->header('X-Custom-Header')[0] === 'custom_header_value';
- });
+ Http::assertSent(fn ($request): bool => $request->url() === 'https://api.example.com/data' &&
+ $request->method() === 'GET' &&
+ $request->header('Authorization')[0] === 'Bearer test123' &&
+ $request->header('X-Custom-Header')[0] === 'custom_header_value');
});
-test('updateDataPayload resolves Liquid variables in polling_body', function () {
+test('updateDataPayload resolves Liquid variables in polling_body', function (): void {
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://api.example.com/data',
@@ -166,7 +193,7 @@ test('updateDataPayload resolves Liquid variables in polling_body', function ()
$plugin->updateDataPayload();
- Http::assertSent(function ($request) {
+ Http::assertSent(function ($request): bool {
$expectedBody = '{"query": "query { user { id name } }", "api_key": "test123", "user_id": "456"}';
return $request->url() === 'https://api.example.com/data' &&
@@ -175,7 +202,7 @@ test('updateDataPayload resolves Liquid variables in polling_body', function ()
});
});
-test('webhook plugin is stale if webhook event occurred', function () {
+test('webhook plugin is stale if webhook event occurred', function (): void {
$plugin = Plugin::factory()->create([
'data_strategy' => 'webhook',
'data_payload_updated_at' => now()->subMinutes(10),
@@ -186,7 +213,7 @@ test('webhook plugin is stale if webhook event occurred', function () {
});
-test('webhook plugin data not stale if no webhook event occurred for 1 hour', function () {
+test('webhook plugin data not stale if no webhook event occurred for 1 hour', function (): void {
$plugin = Plugin::factory()->create([
'data_strategy' => 'webhook',
'data_payload_updated_at' => now()->subMinutes(60),
@@ -197,7 +224,7 @@ test('webhook plugin data not stale if no webhook event occurred for 1 hour', fu
});
-test('plugin configuration is cast to array', function () {
+test('plugin configuration is cast to array', function (): void {
$config = ['timezone' => 'UTC', 'refresh_interval' => 30];
$plugin = Plugin::factory()->create(['configuration' => $config]);
@@ -206,7 +233,7 @@ test('plugin configuration is cast to array', function () {
->toBe($config);
});
-test('plugin can get configuration value by key', function () {
+test('plugin can get configuration value by key', function (): void {
$config = ['timezone' => 'UTC', 'refresh_interval' => 30];
$plugin = Plugin::factory()->create(['configuration' => $config]);
@@ -215,7 +242,7 @@ test('plugin can get configuration value by key', function () {
expect($plugin->getConfiguration('nonexistent', 'default'))->toBe('default');
});
-test('plugin configuration template is cast to array', function () {
+test('plugin configuration template is cast to array', function (): void {
$template = [
'custom_fields' => [
[
@@ -233,7 +260,7 @@ test('plugin configuration template is cast to array', function () {
->toBe($template);
});
-test('resolveLiquidVariables resolves variables from configuration', function () {
+test('resolveLiquidVariables resolves variables from configuration', function (): void {
$plugin = Plugin::factory()->create([
'configuration' => [
'api_key' => '12345',
@@ -263,7 +290,7 @@ test('resolveLiquidVariables resolves variables from configuration', function ()
expect($result)->toBe('High');
});
-test('resolveLiquidVariables handles invalid Liquid syntax gracefully', function () {
+test('resolveLiquidVariables handles invalid Liquid syntax gracefully', function (): void {
$plugin = Plugin::factory()->create([
'configuration' => [
'api_key' => '12345',
@@ -277,7 +304,7 @@ test('resolveLiquidVariables handles invalid Liquid syntax gracefully', function
->toThrow(Keepsuit\Liquid\Exceptions\SyntaxException::class);
});
-test('plugin can extract default values from custom fields configuration template', function () {
+test('plugin can extract default values from custom fields configuration template', function (): void {
$configurationTemplate = [
'custom_fields' => [
[
@@ -323,7 +350,7 @@ test('plugin can extract default values from custom fields configuration templat
expect($plugin->getConfiguration('timezone'))->toBeNull();
});
-test('resolveLiquidVariables resolves configuration variables correctly', function () {
+test('resolveLiquidVariables resolves configuration variables correctly', function (): void {
$plugin = Plugin::factory()->create([
'configuration' => [
'Latitude' => '48.2083',
@@ -338,7 +365,7 @@ test('resolveLiquidVariables resolves configuration variables correctly', functi
expect($plugin->resolveLiquidVariables($template))->toBe($expected);
});
-test('resolveLiquidVariables handles missing variables gracefully', function () {
+test('resolveLiquidVariables handles missing variables gracefully', function (): void {
$plugin = Plugin::factory()->create([
'configuration' => [
'Latitude' => '48.2083',
@@ -351,7 +378,7 @@ test('resolveLiquidVariables handles missing variables gracefully', function ()
expect($plugin->resolveLiquidVariables($template))->toBe($expected);
});
-test('resolveLiquidVariables handles empty configuration', function () {
+test('resolveLiquidVariables handles empty configuration', function (): void {
$plugin = Plugin::factory()->create([
'configuration' => [],
]);
@@ -361,3 +388,553 @@ test('resolveLiquidVariables handles empty configuration', function () {
expect($plugin->resolveLiquidVariables($template))->toBe($expected);
});
+
+test('resolveLiquidVariables uses external renderer when preferred_renderer is trmnl-liquid and template contains for loop', function (): void {
+ Illuminate\Support\Facades\Process::fake([
+ '*' => Illuminate\Support\Facades\Process::result(
+ output: 'https://api1.example.com/data\nhttps://api2.example.com/data',
+ exitCode: 0
+ ),
+ ]);
+
+ config(['services.trmnl.liquid_enabled' => true]);
+ config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
+
+ $plugin = Plugin::factory()->create([
+ 'preferred_renderer' => 'trmnl-liquid',
+ 'configuration' => [
+ 'recipe_ids' => '1,2',
+ ],
+ ]);
+
+ $template = <<<'LIQUID'
+{% assign ids = recipe_ids | split: "," %}
+{% for id in ids %}
+https://api{{ id }}.example.com/data
+{% endfor %}
+LIQUID;
+
+ $result = $plugin->resolveLiquidVariables($template);
+
+ // Trim trailing newlines that may be added by the process
+ expect(mb_trim($result))->toBe('https://api1.example.com/data\nhttps://api2.example.com/data');
+
+ Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
+ $command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
+
+ return str_contains($command, 'trmnl-liquid-cli') &&
+ str_contains($command, '--template') &&
+ str_contains($command, '--context');
+ });
+});
+
+test('resolveLiquidVariables uses internal renderer when preferred_renderer is not trmnl-liquid', function (): void {
+ $plugin = Plugin::factory()->create([
+ 'preferred_renderer' => 'php',
+ 'configuration' => [
+ 'recipe_ids' => '1,2',
+ ],
+ ]);
+
+ $template = <<<'LIQUID'
+{% assign ids = recipe_ids | split: "," %}
+{% for id in ids %}
+https://api{{ id }}.example.com/data
+{% endfor %}
+LIQUID;
+
+ // Should use internal renderer even with for loop
+ $result = $plugin->resolveLiquidVariables($template);
+
+ // Internal renderer should process the template
+ expect($result)->toBeString();
+});
+
+test('resolveLiquidVariables uses internal renderer when external renderer is disabled', function (): void {
+ config(['services.trmnl.liquid_enabled' => false]);
+
+ $plugin = Plugin::factory()->create([
+ 'preferred_renderer' => 'trmnl-liquid',
+ 'configuration' => [
+ 'recipe_ids' => '1,2',
+ ],
+ ]);
+
+ $template = <<<'LIQUID'
+{% assign ids = recipe_ids | split: "," %}
+{% for id in ids %}
+https://api{{ id }}.example.com/data
+{% endfor %}
+LIQUID;
+
+ // Should use internal renderer when external is disabled
+ $result = $plugin->resolveLiquidVariables($template);
+
+ expect($result)->toBeString();
+});
+
+test('resolveLiquidVariables uses internal renderer when template does not contain for loop', function (): void {
+ config(['services.trmnl.liquid_enabled' => true]);
+ config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
+
+ $plugin = Plugin::factory()->create([
+ 'preferred_renderer' => 'trmnl-liquid',
+ 'configuration' => [
+ 'api_key' => 'test123',
+ ],
+ ]);
+
+ $template = 'https://api.example.com/data?key={{ api_key }}';
+
+ // Should use internal renderer when no for loop
+ $result = $plugin->resolveLiquidVariables($template);
+
+ expect($result)->toBe('https://api.example.com/data?key=test123');
+
+ Illuminate\Support\Facades\Process::assertNothingRan();
+});
+
+test('resolveLiquidVariables detects for loop with standard opening tag', function (): void {
+ Illuminate\Support\Facades\Process::fake([
+ '*' => Illuminate\Support\Facades\Process::result(
+ output: 'resolved',
+ exitCode: 0
+ ),
+ ]);
+
+ config(['services.trmnl.liquid_enabled' => true]);
+ config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
+
+ $plugin = Plugin::factory()->create([
+ 'preferred_renderer' => 'trmnl-liquid',
+ 'configuration' => [],
+ ]);
+
+ // Test {% for pattern
+ $template = '{% for item in items %}test{% endfor %}';
+ $plugin->resolveLiquidVariables($template);
+
+ Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
+ $command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
+
+ return str_contains($command, 'trmnl-liquid-cli');
+ });
+});
+
+test('resolveLiquidVariables detects for loop with whitespace stripping tag', function (): void {
+ Illuminate\Support\Facades\Process::fake([
+ '*' => Illuminate\Support\Facades\Process::result(
+ output: 'resolved',
+ exitCode: 0
+ ),
+ ]);
+
+ config(['services.trmnl.liquid_enabled' => true]);
+ config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
+
+ $plugin = Plugin::factory()->create([
+ 'preferred_renderer' => 'trmnl-liquid',
+ 'configuration' => [],
+ ]);
+
+ // Test {%- for pattern (with whitespace stripping)
+ $template = '{%- for item in items %}test{% endfor %}';
+ $plugin->resolveLiquidVariables($template);
+
+ Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
+ $command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
+
+ return str_contains($command, 'trmnl-liquid-cli');
+ });
+});
+
+test('updateDataPayload resolves entire polling_url field first then splits by newline', function (): void {
+ Http::fake([
+ 'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200),
+ 'https://api2.example.com/data' => Http::response(['data' => 'test2'], 200),
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'polling_url' => "https://api1.example.com/data\nhttps://api2.example.com/data",
+ 'polling_verb' => 'get',
+ 'configuration' => [
+ 'recipe_ids' => '1,2',
+ ],
+ ]);
+
+ $plugin->updateDataPayload();
+
+ // Should have split the multi-line URL and generated two requests
+ expect($plugin->data_payload)->toHaveKey('IDX_0');
+ expect($plugin->data_payload)->toHaveKey('IDX_1');
+ expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']);
+ expect($plugin->data_payload['IDX_1'])->toBe(['data' => 'test2']);
+});
+
+test('updateDataPayload handles multi-line polling_url with for loop using external renderer', function (): void {
+ Illuminate\Support\Facades\Process::fake([
+ '*' => Illuminate\Support\Facades\Process::result(
+ output: "https://api1.example.com/data\nhttps://api2.example.com/data",
+ exitCode: 0
+ ),
+ ]);
+
+ Http::fake([
+ 'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200),
+ 'https://api2.example.com/data' => Http::response(['data' => 'test2'], 200),
+ ]);
+
+ config(['services.trmnl.liquid_enabled' => true]);
+ config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
+
+ $plugin = Plugin::factory()->create([
+ 'data_strategy' => 'polling',
+ 'preferred_renderer' => 'trmnl-liquid',
+ 'polling_url' => <<<'LIQUID'
+{% assign ids = recipe_ids | split: "," %}
+{% for id in ids %}
+https://api{{ id }}.example.com/data
+{% endfor %}
+LIQUID
+ ,
+ 'polling_verb' => 'get',
+ 'configuration' => [
+ 'recipe_ids' => '1,2',
+ ],
+ ]);
+
+ $plugin->updateDataPayload();
+
+ // Should have used external renderer and generated two URLs
+ expect($plugin->data_payload)->toHaveKey('IDX_0');
+ expect($plugin->data_payload)->toHaveKey('IDX_1');
+ expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']);
+ expect($plugin->data_payload['IDX_1'])->toBe(['data' => 'test2']);
+
+ Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
+ $command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
+
+ return str_contains($command, 'trmnl-liquid-cli');
+ });
+});
+
+test('plugin render uses user timezone when set', function (): void {
+ $user = User::factory()->create([
+ 'timezone' => 'America/New_York',
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'markup_language' => 'liquid',
+ 'render_markup' => '{{ trmnl.user.time_zone_iana }}',
+ ]);
+
+ $rendered = $plugin->render();
+
+ expect($rendered)->toContain('America/New_York');
+});
+
+test('plugin render falls back to app timezone when user timezone is not set', function (): void {
+ $user = User::factory()->create([
+ 'timezone' => null,
+ ]);
+
+ config(['app.timezone' => 'Europe/London']);
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'markup_language' => 'liquid',
+ 'render_markup' => '{{ trmnl.user.time_zone_iana }}',
+ ]);
+
+ $rendered = $plugin->render();
+
+ expect($rendered)->toContain('Europe/London');
+});
+
+test('plugin render calculates correct UTC offset from user timezone', function (): void {
+ $user = User::factory()->create([
+ 'timezone' => 'America/New_York', // UTC-5 (EST) or UTC-4 (EDT)
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'markup_language' => 'liquid',
+ 'render_markup' => '{{ trmnl.user.utc_offset }}',
+ ]);
+
+ $rendered = $plugin->render();
+
+ // America/New_York offset should be -18000 (EST) or -14400 (EDT) in seconds
+ $expectedOffset = (string) Carbon::now('America/New_York')->getOffset();
+ expect($rendered)->toContain($expectedOffset);
+});
+
+test('plugin render calculates correct UTC offset from app timezone when user timezone is null', function (): void {
+ $user = User::factory()->create([
+ 'timezone' => null,
+ ]);
+
+ config(['app.timezone' => 'Europe/London']);
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'markup_language' => 'liquid',
+ 'render_markup' => '{{ trmnl.user.utc_offset }}',
+ ]);
+
+ $rendered = $plugin->render();
+
+ // Europe/London offset should be 0 (GMT) or 3600 (BST) in seconds
+ $expectedOffset = (string) Carbon::now('Europe/London')->getOffset();
+ expect($rendered)->toContain($expectedOffset);
+});
+
+test('plugin render includes utc_offset and time_zone_iana in trmnl.user context', function (): void {
+ $user = User::factory()->create([
+ 'timezone' => 'America/Chicago', // UTC-6 (CST) or UTC-5 (CDT)
+ ]);
+
+ $plugin = Plugin::factory()->create([
+ 'user_id' => $user->id,
+ 'markup_language' => 'liquid',
+ 'render_markup' => '{{ trmnl.user.time_zone_iana }}|{{ trmnl.user.utc_offset }}',
+ ]);
+
+ $rendered = $plugin->render();
+
+ expect($rendered)
+ ->toContain('America/Chicago')
+ ->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds)
+});
+
+/**
+ * Plugin security: XSS Payload Dataset
+ * [Input, Expected Result, Forbidden String]
+ */
+dataset('xss_vectors', [
+ 'standard_script' => ['Safe ', 'Safe ', '