feat(#91): add multi color and palette support

This commit is contained in:
Benjamin Nussbaum 2025-11-22 16:43:33 +01:00
parent 61b9ff56e0
commit 568bd69fea
19 changed files with 1696 additions and 185 deletions

View file

@ -12,6 +12,13 @@ uses(RefreshDatabase::class);
beforeEach(function (): void {
DeviceModel::truncate();
// Mock palettes API to return empty array by default
Http::fake([
'usetrmnl.com/api/palettes' => Http::response([
'data' => [],
], 200),
]);
});
test('fetch device models job can be dispatched', function (): void {
@ -21,6 +28,7 @@ test('fetch device models job can be dispatched', function (): void {
test('fetch device models job handles successful api response', function (): void {
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([
'data' => [
[
@ -42,6 +50,10 @@ test('fetch device models job handles successful api response', function (): voi
], 200),
]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated device models', ['count' => 1]);
@ -67,6 +79,7 @@ test('fetch device models job handles successful api response', function (): voi
test('fetch device models job handles multiple device models', function (): void {
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([
'data' => [
[
@ -103,6 +116,10 @@ test('fetch device models job handles multiple device models', function (): void
], 200),
]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated device models', ['count' => 2]);
@ -116,11 +133,16 @@ test('fetch device models job handles multiple device models', function (): void
test('fetch device models job handles empty data array', function (): void {
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([
'data' => [],
], 200),
]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated device models', ['count' => 0]);
@ -133,11 +155,16 @@ test('fetch device models job handles empty data array', function (): void {
test('fetch device models job handles missing data field', function (): void {
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([
'message' => 'No data available',
], 200),
]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated device models', ['count' => 0]);
@ -150,11 +177,16 @@ test('fetch device models job handles missing data field', function (): void {
test('fetch device models job handles non-array data', function (): void {
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([
'data' => 'invalid-data',
], 200),
]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('error')
->once()
->with('Invalid response format from device models API', Mockery::type('array'));
@ -167,11 +199,16 @@ test('fetch device models job handles non-array data', function (): void {
test('fetch device models job handles api failure', function (): void {
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([
'error' => 'Internal Server Error',
], 500),
]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('error')
->once()
->with('Failed to fetch device models from API', [
@ -187,11 +224,16 @@ test('fetch device models job handles api failure', function (): void {
test('fetch device models job handles network exception', function (): void {
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => function (): void {
throw new Exception('Network connection failed');
},
]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('error')
->once()
->with('Exception occurred while fetching device models', Mockery::type('array'));
@ -204,6 +246,7 @@ test('fetch device models job handles network exception', function (): void {
test('fetch device models job handles device model with missing name', function (): void {
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([
'data' => [
[
@ -214,6 +257,10 @@ test('fetch device models job handles device model with missing name', function
], 200),
]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('warning')
->once()
->with('Device model data missing name field', Mockery::type('array'));
@ -230,6 +277,7 @@ test('fetch device models job handles device model with missing name', function
test('fetch device models job handles device model with partial data', function (): void {
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([
'data' => [
[
@ -240,6 +288,10 @@ test('fetch device models job handles device model with partial data', function
], 200),
]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated device models', ['count' => 1]);
@ -273,6 +325,7 @@ test('fetch device models job updates existing device model', function (): void
]);
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([
'data' => [
[
@ -294,6 +347,10 @@ test('fetch device models job updates existing device model', function (): void
], 200),
]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated device models', ['count' => 1]);
@ -311,6 +368,7 @@ test('fetch device models job updates existing device model', function (): void
test('fetch device models job handles processing exception for individual model', function (): void {
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([
'data' => [
[
@ -327,6 +385,10 @@ test('fetch device models job handles processing exception for individual model'
], 200),
]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('warning')
->once()
->with('Device model data missing name field', Mockery::type('array'));

View file

@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Livewire\Volt\Volt;
it('loads newest TRMNL recipes on mount', function () {
it('loads newest TRMNL recipes on mount', function (): void {
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
@ -31,7 +31,7 @@ it('loads newest TRMNL recipes on mount', function () {
->assertSee('Installs: 10');
});
it('searches TRMNL recipes when search term is provided', function () {
it('searches TRMNL recipes when search term is provided', function (): void {
Http::fake([
// First call (mount -> newest)
'usetrmnl.com/recipes.json?*' => Http::sequence()
@ -71,7 +71,7 @@ it('searches TRMNL recipes when search term is provided', function () {
->assertSee('Install');
});
it('installs plugin successfully when user is authenticated', function () {
it('installs plugin successfully when user is authenticated', function (): void {
$user = User::factory()->create();
Http::fake([
@ -100,7 +100,7 @@ it('installs plugin successfully when user is authenticated', function () {
->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 () {
it('shows error when user is not authenticated', function (): void {
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
@ -124,7 +124,7 @@ it('shows error when user is not authenticated', function () {
->assertStatus(403); // This will return 403 because user is not authenticated
});
it('shows error when plugin installation fails', function () {
it('shows error when plugin installation fails', function (): void {
$user = User::factory()->create();
Http::fake([

View file

@ -0,0 +1,575 @@
<?php
declare(strict_types=1);
use App\Models\DevicePalette;
use App\Models\User;
use Livewire\Volt\Volt;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('device palettes page can be rendered', function (): void {
$user = User::factory()->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);
});

View file

@ -77,7 +77,7 @@ test('l_date handles null locale parameter', function (): void {
$filter = new Localization();
$date = '2025-01-11';
$result = $filter->l_date($date, 'Y-m-d', null);
$result = $filter->l_date($date, 'Y-m-d');
// Should work the same as default
expect($result)->toContain('2025');