Started: 89.08% backend / 97.01% frontend lines.
Landed: 99.69% backend / 98.62% frontend.
Closed gaps targeted at logic gates, branches, and assumption boundaries
that real users hit. Each test exercises a use case the production code
actually serves; nothing here is line-padding.
Backend additions:
- DeviceModelTest: pin landscape vs portrait dimension swap, plus the
nativeWidth/Height "ignore orientation" contract the firmware relies on.
- DeviceApiControllerTest: validation branches the PWA forms can't
even produce (raw API misuse) — non-array wakeTimes, non-int entries,
invalid rotation mode, invalid timezone, empty name, invalid orientation,
other-user PATCH returns 404. Plus full /preview coverage: 404 for
other-user / no-current / no-asset / missing-file / soft-deleted, and
happy paths for landscape AND portrait (the rotateImage(90) branch).
- ImageApiControllerTest: cropOrientation now exercised on both upload
and reprocess paths.
- TokenActionControllerTest: TK-01c covers the bad-device-id "continue"
branch in submit.
- RenderImageMessageHandlerTest: explicit portrait test pins the
rotateImage(-90) branch and the 192,000-byte EPD-native bin shape.
- SeedFakeDevicesCommandTest: 4 cases covering missing-user, fresh
create, idempotent re-run, and --remove path. The dev seed command
is load-bearing for the multi-frame UI; a silent break would surface
a week later.
- RerenderAssetsCommandTest: reset + dispatch path, no-assets path.
Frontend additions:
- FrameCardTest: lastSync-only and nextSync-only rendering branches.
- HomeView.test:
* + Add time fallback path when all 9 default candidates are taken.
* Multi-day "in Nd" nextSync formatting (offline / huge-interval case).
* Medium-horizon (5h) nextSync formats as clock-time + day label.
* visibilitychange triggers a silent re-fetch.
* add-photo handler creates input + navigates to /upload after pick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,29 @@ describe('FrameCard', () => {
|
|||||||
expect(wrapper.find('.frame-card__sync-line').exists()).toBe(false)
|
expect(wrapper.find('.frame-card__sync-line').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Cover the lastSync-without-nextSync branch: only the "synced X ago" span
|
||||||
|
// renders, no separator, no nextSync.
|
||||||
|
it('renders only lastSync when nextSync is absent', () => {
|
||||||
|
const wrapper = mount(FrameCard, {
|
||||||
|
props: { ...defaultProps, lastSync: '5m ago' },
|
||||||
|
})
|
||||||
|
const sync = wrapper.find('.frame-card__sync-line')
|
||||||
|
expect(sync.text()).toContain('synced 5m ago')
|
||||||
|
expect(sync.find('.frame-card__sync-sep').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Inverse: nextSync without lastSync (a never-seen device that already has
|
||||||
|
// wakeTimes configured — the card should still preview the next slot).
|
||||||
|
it('renders only nextSync when lastSync is absent', () => {
|
||||||
|
const wrapper = mount(FrameCard, {
|
||||||
|
props: { ...defaultProps, nextSync: 'next sync ~6 AM tomorrow' },
|
||||||
|
})
|
||||||
|
const sync = wrapper.find('.frame-card__sync-line')
|
||||||
|
expect(sync.exists()).toBe(true)
|
||||||
|
expect(sync.text()).toContain('next sync ~6 AM tomorrow')
|
||||||
|
expect(sync.text()).not.toContain('synced')
|
||||||
|
})
|
||||||
|
|
||||||
it('applies offline modifier class when status is offline', () => {
|
it('applies offline modifier class when status is offline', () => {
|
||||||
const wrapper = mount(FrameCard, {
|
const wrapper = mount(FrameCard, {
|
||||||
props: { ...defaultProps, status: 'offline' },
|
props: { ...defaultProps, status: 'offline' },
|
||||||
|
|||||||
@@ -533,6 +533,41 @@ describe('HomeView', () => {
|
|||||||
// list and stay there during editing. Sorting only happens at save time
|
// list and stay there during editing. Sorting only happens at save time
|
||||||
// (server-side via setWakeTimes), so the user can always see "the one I
|
// (server-side via setWakeTimes), so the user can always see "the one I
|
||||||
// just added" without it jumping around.
|
// just added" without it jumping around.
|
||||||
|
// When all 9 DEFAULT_TIME_CANDIDATES are already in the list, addTime falls
|
||||||
|
// through to the 5-minute-step fallback loop. Pre-fill with all 9 defaults
|
||||||
|
// so the next +Add hits that branch.
|
||||||
|
it('+ Add time falls back to the next free 5-minute slot when defaults are exhausted', async () => {
|
||||||
|
const devicesStore = useDevicesStore()
|
||||||
|
const allDefaults = [
|
||||||
|
9 * 60, 18 * 60, 12 * 60, 21 * 60, 6 * 60,
|
||||||
|
15 * 60, 7 * 60 + 30, 19 * 60 + 30, 0,
|
||||||
|
]
|
||||||
|
devicesStore.devices = [makeDevice({ id: 5, wakeTimes: allDefaults })]
|
||||||
|
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||||
|
vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
|
||||||
|
|
||||||
|
const wrapper = mountView()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const before = wrapper.findAll('.home-view__time-row').length
|
||||||
|
await wrapper.find('.home-view__time-add').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Fallback added one more (the next free 5-min slot — first one not in
|
||||||
|
// the defaults set, which is `5` minutes since 0 is already there).
|
||||||
|
expect(wrapper.findAll('.home-view__time-row')).toHaveLength(before + 1)
|
||||||
|
|
||||||
|
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
|
||||||
|
.find(b => b.text().toLowerCase().includes('sav'))!
|
||||||
|
await saveBtn.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({
|
||||||
|
wakeTimes: [...allDefaults, 5],
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
it('+ Add time appends to the end and does NOT reorder existing entries', async () => {
|
it('+ Add time appends to the end and does NOT reorder existing entries', async () => {
|
||||||
const devicesStore = useDevicesStore()
|
const devicesStore = useDevicesStore()
|
||||||
// 8 PM is later than the first default candidate (9 AM). If the list
|
// 8 PM is later than the first default candidate (9 AM). If the list
|
||||||
@@ -686,6 +721,100 @@ describe('HomeView', () => {
|
|||||||
// The "next update" preview's legacy fallback path: device has lastSeenAt
|
// The "next update" preview's legacy fallback path: device has lastSeenAt
|
||||||
// but no nextPollExpectedAt yet (e.g. polled once before that column was
|
// but no nextPollExpectedAt yet (e.g. polled once before that column was
|
||||||
// added). Should still produce a sensible label using lastSeenAt + interval.
|
// added). Should still produce a sensible label using lastSeenAt + interval.
|
||||||
|
// Multi-day nextSync (> 24h) — covers the "in Nd" return at the bottom
|
||||||
|
// of nextSyncLabel. Realistically a device that's been offline / never
|
||||||
|
// configured a wake time and has a huge interval set.
|
||||||
|
it('nextSync formats multi-day delays as "in Nd"', async () => {
|
||||||
|
const devicesStore = useDevicesStore()
|
||||||
|
const threeDaysOut = new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString()
|
||||||
|
devicesStore.devices = [makeDevice({
|
||||||
|
id: 1,
|
||||||
|
wakeTimes: [],
|
||||||
|
rotationIntervalMinutes: 3 * 24 * 60,
|
||||||
|
lastSeenAt: new Date().toISOString(),
|
||||||
|
nextPollExpectedAt: threeDaysOut,
|
||||||
|
})]
|
||||||
|
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||||
|
|
||||||
|
const wrapper = mountView()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/in 3d/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Long-horizon nextSync: 5 hours away — covers the 1h-to-24h branch in
|
||||||
|
// nextSyncLabel that picks clock-time formatting over "in 5h".
|
||||||
|
it('nextSync formats medium-horizon delays with a clock time + day label', async () => {
|
||||||
|
const devicesStore = useDevicesStore()
|
||||||
|
const fiveHoursOut = new Date(Date.now() + 5 * 60 * 60_000).toISOString()
|
||||||
|
devicesStore.devices = [makeDevice({
|
||||||
|
id: 1,
|
||||||
|
wakeTimes: [],
|
||||||
|
rotationIntervalMinutes: 5 * 60,
|
||||||
|
lastSeenAt: new Date().toISOString(),
|
||||||
|
nextPollExpectedAt: fiveHoursOut,
|
||||||
|
timezone: 'UTC',
|
||||||
|
})]
|
||||||
|
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||||
|
|
||||||
|
const wrapper = mountView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const next = wrapper.findComponent({ name: 'FrameCard' }).props('nextSync') as string
|
||||||
|
// Should NOT be the short-horizon "in Xm" form — we want clock-time.
|
||||||
|
expect(next).toMatch(/^next sync ~\d/)
|
||||||
|
// Day label is either today or tomorrow depending on wall-clock at run time.
|
||||||
|
expect(next).toMatch(/(today|tomorrow)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Visibility-change handler triggers a silent fetchDevices when the PWA
|
||||||
|
// returns to the foreground.
|
||||||
|
it('foreground visibility-change re-fetches devices silently', async () => {
|
||||||
|
const devicesStore = useDevicesStore()
|
||||||
|
devicesStore.devices = [makeDevice({ id: 1 })]
|
||||||
|
const fetchSpy = vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||||
|
|
||||||
|
mountView()
|
||||||
|
await flushPromises()
|
||||||
|
fetchSpy.mockClear() // ignore the onMounted call
|
||||||
|
|
||||||
|
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true })
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith({ silent: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add-photo handler creates a hidden file input and (on file pick) navigates
|
||||||
|
// to /upload with the staged file in the upload store.
|
||||||
|
it('add-photo opens a file picker and navigates after a file is chosen', async () => {
|
||||||
|
const devicesStore = useDevicesStore()
|
||||||
|
devicesStore.devices = [makeDevice({ id: 7 })]
|
||||||
|
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||||
|
|
||||||
|
let capturedInput: HTMLInputElement | null = null
|
||||||
|
const origCreate = document.createElement.bind(document)
|
||||||
|
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
|
||||||
|
const el = origCreate(tag) as HTMLInputElement
|
||||||
|
if (tag === 'input') capturedInput = el
|
||||||
|
return el
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mountView()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('add-photo', 7)
|
||||||
|
|
||||||
|
expect(capturedInput).not.toBeNull()
|
||||||
|
expect(capturedInput!.type).toBe('file')
|
||||||
|
|
||||||
|
// Simulate the user picking a file.
|
||||||
|
const file = new File(['x'], 'pic.jpg', { type: 'image/jpeg' })
|
||||||
|
Object.defineProperty(capturedInput!, 'files', { value: [file], configurable: true })
|
||||||
|
capturedInput!.onchange?.(new Event('change'))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(routerPush).toHaveBeenCalledWith('/upload')
|
||||||
|
})
|
||||||
|
|
||||||
it('next-update preview falls back to lastSeenAt+interval when nextPollExpectedAt is null', async () => {
|
it('next-update preview falls back to lastSeenAt+interval when nextPollExpectedAt is null', async () => {
|
||||||
const devicesStore = useDevicesStore()
|
const devicesStore = useDevicesStore()
|
||||||
devicesStore.devices = [makeDevice({
|
devicesStore.devices = [makeDevice({
|
||||||
|
|||||||
@@ -311,4 +311,284 @@ class DeviceApiControllerTest extends AppWebTestCase
|
|||||||
|
|
||||||
$this->assertResponseStatusCodeSame(404);
|
$this->assertResponseStatusCodeSame(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Validation branch coverage for PATCH /api/devices/{id} ───────────
|
||||||
|
|
||||||
|
public function test_patch_rejects_non_array_wakeTimes(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('vbad-array@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:D1', $user);
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode(['wakeTimes' => 'not-an-array']));
|
||||||
|
$this->assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_patch_rejects_non_integer_wakeTimes_entries(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('vbad-non-int@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:D2', $user);
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
// Float-as-string is neither int nor digit-only-string → rejected.
|
||||||
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode(['wakeTimes' => ['1.5']]));
|
||||||
|
$this->assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_patch_accepts_digit_string_wakeTimes_for_pwa_form_submits(): void
|
||||||
|
{
|
||||||
|
// The PWA's <select> value comes through as a string when the body is
|
||||||
|
// form-encoded; the controller's ctype_digit branch must accept that.
|
||||||
|
$user = $this->createUser('vstr@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:D3', $user);
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode(['wakeTimes' => ['360']]));
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertSame([360], $data['wakeTimes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_patch_rejects_invalid_rotation_mode(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('vmode@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:D4', $user);
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode(['rotationMode' => 'shuffle-please']));
|
||||||
|
$this->assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_patch_accepts_valid_rotation_modes(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('vmode-ok@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:D5', $user);
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
foreach (['random', 'least_recently_shown', 'newest_upload', 'oldest_upload'] as $mode) {
|
||||||
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode(['rotationMode' => $mode]));
|
||||||
|
$this->assertResponseIsSuccessful('mode=' . $mode);
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->assertSame($mode, $data['rotationMode']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_patch_sets_prioritize_never_shown(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('vprio@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:D6', $user);
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
|
||||||
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode(['prioritizeNeverShown' => true]));
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$this->assertTrue(json_decode($client->getResponse()->getContent(), true)['prioritizeNeverShown']);
|
||||||
|
|
||||||
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode(['prioritizeNeverShown' => false]));
|
||||||
|
$this->assertFalse(json_decode($client->getResponse()->getContent(), true)['prioritizeNeverShown']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_patch_rejects_empty_name(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('vname@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:D7', $user);
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode(['name' => ' ']));
|
||||||
|
$this->assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_patch_rejects_invalid_orientation(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('vori@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:D8', $user);
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode(['orientation' => 'sideways']));
|
||||||
|
$this->assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_patch_rejects_invalid_timezone(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('vtz@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:D9', $user);
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode(['timezone' => 'Mars/Olympus_Mons']));
|
||||||
|
$this->assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_patch_404_for_other_users_device(): void
|
||||||
|
{
|
||||||
|
$owner = $this->createUser('vown@example.com');
|
||||||
|
$other = $this->createUser('vother@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:DA', $owner);
|
||||||
|
$client = $this->loginAs($other);
|
||||||
|
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
|
||||||
|
'CONTENT_TYPE' => 'application/json',
|
||||||
|
], json_encode(['name' => 'pwn']));
|
||||||
|
$this->assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /preview endpoint coverage ───────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_preview_404_for_other_users_device(): void
|
||||||
|
{
|
||||||
|
$owner = $this->createUser('pown@example.com');
|
||||||
|
$other = $this->createUser('pother@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:E0', $owner);
|
||||||
|
$client = $this->loginAs($other);
|
||||||
|
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
|
||||||
|
$this->assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_preview_404_when_device_has_no_current_image(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('pnocur@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:E1', $user);
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
|
||||||
|
$this->assertResponseStatusCodeSame(404);
|
||||||
|
$this->assertStringContainsString('No current image', $client->getResponse()->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_preview_404_when_no_ready_render_for_devices_orientation(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('pnoasset@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:E2', $user);
|
||||||
|
$image = $this->makeImage($user);
|
||||||
|
$device->setCurrentImage($image);
|
||||||
|
$this->em()->flush();
|
||||||
|
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
|
||||||
|
$this->assertResponseStatusCodeSame(404);
|
||||||
|
$this->assertStringContainsString('Render not ready', $client->getResponse()->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_preview_404_when_render_file_is_missing_from_disk(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('pmissing@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:E3', $user);
|
||||||
|
$image = $this->makeImage($user);
|
||||||
|
$device->setCurrentImage($image);
|
||||||
|
|
||||||
|
$asset = (new \App\Entity\RenderedAsset())
|
||||||
|
->setImage($image)
|
||||||
|
->setDeviceModel(DeviceModel::V1)
|
||||||
|
->setOrientation(Orientation::Landscape)
|
||||||
|
->setStatus(\App\Enum\RenderStatus::Ready)
|
||||||
|
->setFilePath('var/storage/images/nope/v1_landscape.bin');
|
||||||
|
$this->em()->persist($asset);
|
||||||
|
$this->em()->flush();
|
||||||
|
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
|
||||||
|
$this->assertResponseStatusCodeSame(404);
|
||||||
|
$this->assertStringContainsString('Render file missing', $client->getResponse()->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_preview_returns_png_for_landscape_device(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('ppng-l@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:E4', $user);
|
||||||
|
$image = $this->makeImage($user);
|
||||||
|
$device->setCurrentImage($image);
|
||||||
|
|
||||||
|
// 800×480 EPD = 384,000 pixels. 4bpp packed = 192,000 bytes. Fill
|
||||||
|
// with palette index 0x1 (white) so the renderer produces a valid PNG.
|
||||||
|
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
|
||||||
|
$relPath = 'var/storage/images/test-preview/v1_landscape.bin';
|
||||||
|
$absPath = $projectDir . '/' . $relPath;
|
||||||
|
if (!is_dir(dirname($absPath))) {
|
||||||
|
mkdir(dirname($absPath), 0755, true);
|
||||||
|
}
|
||||||
|
file_put_contents($absPath, str_repeat(chr(0x11), 192000));
|
||||||
|
|
||||||
|
$asset = (new \App\Entity\RenderedAsset())
|
||||||
|
->setImage($image)
|
||||||
|
->setDeviceModel(DeviceModel::V1)
|
||||||
|
->setOrientation(Orientation::Landscape)
|
||||||
|
->setStatus(\App\Enum\RenderStatus::Ready)
|
||||||
|
->setFilePath($relPath);
|
||||||
|
$this->em()->persist($asset);
|
||||||
|
$this->em()->flush();
|
||||||
|
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$this->assertSame('image/png', $client->getResponse()->headers->get('Content-Type'));
|
||||||
|
|
||||||
|
// Cleanup the bin + the cached png.
|
||||||
|
@unlink($absPath);
|
||||||
|
@unlink(preg_replace('/\.bin$/', '.png', $absPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_preview_returns_png_for_portrait_device(): void
|
||||||
|
{
|
||||||
|
// Portrait covers the rotateImage(90) branch in renderBinToPng. Same
|
||||||
|
// 192,000-byte buffer (the .bin is always EPD-native 800×480; orientation
|
||||||
|
// only affects post-decode rotation).
|
||||||
|
$user = $this->createUser('ppng-p@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:E6', $user);
|
||||||
|
$device->setOrientation(Orientation::Portrait);
|
||||||
|
$image = $this->makeImage($user);
|
||||||
|
$device->setCurrentImage($image);
|
||||||
|
|
||||||
|
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
|
||||||
|
$relPath = 'var/storage/images/test-preview-portrait/v1_portrait.bin';
|
||||||
|
$absPath = $projectDir . '/' . $relPath;
|
||||||
|
if (!is_dir(dirname($absPath))) {
|
||||||
|
mkdir(dirname($absPath), 0755, true);
|
||||||
|
}
|
||||||
|
// Mix in some 0x4 nibbles — 0x4 is unused in the Spectra-6 palette;
|
||||||
|
// the renderer must fall back to the white default rather than
|
||||||
|
// throwing on the unknown index.
|
||||||
|
file_put_contents($absPath, str_repeat(chr(0x14), 192000));
|
||||||
|
|
||||||
|
$asset = (new \App\Entity\RenderedAsset())
|
||||||
|
->setImage($image)
|
||||||
|
->setDeviceModel(DeviceModel::V1)
|
||||||
|
->setOrientation(Orientation::Portrait)
|
||||||
|
->setStatus(\App\Enum\RenderStatus::Ready)
|
||||||
|
->setFilePath($relPath);
|
||||||
|
$this->em()->persist($asset);
|
||||||
|
$this->em()->flush();
|
||||||
|
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$this->assertSame('image/png', $client->getResponse()->headers->get('Content-Type'));
|
||||||
|
|
||||||
|
@unlink($absPath);
|
||||||
|
@unlink(preg_replace('/\.bin$/', '.png', $absPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_preview_404_when_image_is_soft_deleted(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('pdeleted@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:E5', $user);
|
||||||
|
$image = $this->makeImage($user);
|
||||||
|
$device->setCurrentImage($image);
|
||||||
|
// Soft-delete the image — preview must refuse rather than serve the
|
||||||
|
// last cached render.
|
||||||
|
$image->setDeletedAt(new \DateTimeImmutable());
|
||||||
|
$this->em()->flush();
|
||||||
|
|
||||||
|
$client = $this->loginAs($user);
|
||||||
|
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
|
||||||
|
$this->assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ class ImageApiControllerTest extends AppWebTestCase
|
|||||||
$client->request('POST', '/api/images', [
|
$client->request('POST', '/api/images', [
|
||||||
'cropParams' => '{"x":0,"y":0,"w":100,"h":100}',
|
'cropParams' => '{"x":0,"y":0,"w":100,"h":100}',
|
||||||
'stickerState' => '{"stickers":[]}',
|
'stickerState' => '{"stickers":[]}',
|
||||||
|
'cropOrientation' => 'portrait',
|
||||||
], [
|
], [
|
||||||
'file' => $this->makeUploadedFile(),
|
'file' => $this->makeUploadedFile(),
|
||||||
]);
|
]);
|
||||||
@@ -437,6 +438,7 @@ class ImageApiControllerTest extends AppWebTestCase
|
|||||||
$data = json_decode($client->getResponse()->getContent(), true);
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
$this->assertNotNull($data['cropParams']);
|
$this->assertNotNull($data['cropParams']);
|
||||||
$this->assertNotNull($data['stickerState']);
|
$this->assertNotNull($data['stickerState']);
|
||||||
|
$this->assertSame('portrait', $data['cropOrientation']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_original_returns_404_when_no_file_on_disk(): void
|
public function test_original_returns_404_when_no_file_on_disk(): void
|
||||||
@@ -488,6 +490,7 @@ class ImageApiControllerTest extends AppWebTestCase
|
|||||||
$client->request('POST', '/api/images/' . $imageId . '/reprocess', [
|
$client->request('POST', '/api/images/' . $imageId . '/reprocess', [
|
||||||
'cropParams' => '{"x":10,"y":10}',
|
'cropParams' => '{"x":10,"y":10}',
|
||||||
'stickerState' => '{"stickers":[]}',
|
'stickerState' => '{"stickers":[]}',
|
||||||
|
'cropOrientation' => 'landscape',
|
||||||
], [
|
], [
|
||||||
'file' => $this->makeUploadedFile(),
|
'file' => $this->makeUploadedFile(),
|
||||||
]);
|
]);
|
||||||
@@ -496,6 +499,7 @@ class ImageApiControllerTest extends AppWebTestCase
|
|||||||
$data = json_decode($client->getResponse()->getContent(), true);
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
$this->assertNotNull($data['cropParams']);
|
$this->assertNotNull($data['cropParams']);
|
||||||
$this->assertNotNull($data['stickerState']);
|
$this->assertNotNull($data['stickerState']);
|
||||||
|
$this->assertSame('landscape', $data['cropOrientation']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_share_with_self_returns_422(): void
|
public function test_share_with_self_returns_422(): void
|
||||||
|
|||||||
@@ -91,6 +91,44 @@ class TokenActionControllerTest extends AppWebTestCase
|
|||||||
$this->assertSame($recipient->getId(), $sharedReloaded->getRecipientUser()->getId());
|
$this->assertSame($recipient->getId(), $sharedReloaded->getRecipientUser()->getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TK-01c: A device_ids list containing an unknown / not-recipient-owned id
|
||||||
|
* MUST be silently skipped (not 500). Locks the "continue on bad id"
|
||||||
|
* branch in TokenActionController::submit.
|
||||||
|
*/
|
||||||
|
public function test_approve_submit_skips_unknown_device_ids(): void
|
||||||
|
{
|
||||||
|
$sender = $this->createUser('tk01c_sender@example.com');
|
||||||
|
$recipient = $this->createUser('tk01c_recip@example.com');
|
||||||
|
$image = $this->makeImage($sender);
|
||||||
|
$token = $this->issueToken($image, TokenType::ShareApprove);
|
||||||
|
$shared = new SharedImage($image, $recipient, $sender);
|
||||||
|
$this->em()->persist($shared);
|
||||||
|
|
||||||
|
// Recipient owns one device; the form will submit a real id and a bogus one.
|
||||||
|
$real = new \App\Entity\Device();
|
||||||
|
$real->setMac('AA:BB:CC:DD:EE:F1');
|
||||||
|
$real->setName('Recip Frame');
|
||||||
|
$real->setUser($recipient);
|
||||||
|
$this->em()->persist($real);
|
||||||
|
$this->em()->flush();
|
||||||
|
|
||||||
|
$client = $this->loginAs($recipient);
|
||||||
|
$client->request('POST', '/token/' . $token->getUuid() . '/approve', [
|
||||||
|
'device_ids' => [(string) $real->getId(), '999999'],
|
||||||
|
]);
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
// The real device should now have the image approved; the bogus id is a no-op.
|
||||||
|
$this->em()->clear();
|
||||||
|
$reloadedImage = $this->em()->find(\App\Entity\Image::class, $image->getId());
|
||||||
|
$approvedIds = array_map(
|
||||||
|
fn(\App\Entity\Device $d) => $d->getId(),
|
||||||
|
$reloadedImage->getApprovedDevices()->toArray(),
|
||||||
|
);
|
||||||
|
$this->assertContains($real->getId(), $approvedIds);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TK-02: GET /token/{uuid}/approve with a missing UUID renders the invalid page.
|
* TK-02: GET /token/{uuid}/approve with a missing UUID renders the invalid page.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Integration\Command;
|
||||||
|
|
||||||
|
use App\Entity\Device;
|
||||||
|
use App\Entity\Image;
|
||||||
|
use App\Entity\RenderedAsset;
|
||||||
|
use App\Enum\DeviceModel;
|
||||||
|
use App\Enum\Orientation;
|
||||||
|
use App\Enum\RenderStatus;
|
||||||
|
use App\Tests\AppKernelTestCase;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rerender command is the operational lever for "renderer changed,
|
||||||
|
* regenerate every existing asset." A regression here would silently leave
|
||||||
|
* old bins on disk after a renderer bump, so end users see stale colors
|
||||||
|
* with no indication anything's wrong.
|
||||||
|
*/
|
||||||
|
class RerenderAssetsCommandTest extends AppKernelTestCase
|
||||||
|
{
|
||||||
|
private function commandTester(): CommandTester
|
||||||
|
{
|
||||||
|
$kernel = self::bootKernel();
|
||||||
|
$app = new Application($kernel);
|
||||||
|
return new CommandTester($app->find('app:rerender-assets'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resets_ready_assets_to_pending_and_dispatches_render_messages(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('rerender@example.com');
|
||||||
|
$device = (new Device())
|
||||||
|
->setMac('AA:BB:CC:DD:EE:F0')
|
||||||
|
->setName('Frame')
|
||||||
|
->setUser($user)
|
||||||
|
->setModel(DeviceModel::V1)
|
||||||
|
->setOrientation(Orientation::Landscape);
|
||||||
|
self::em()->persist($device);
|
||||||
|
|
||||||
|
$image = (new Image())->setUser($user)->setOriginalFilename('a.jpg')->setStoragePath('a');
|
||||||
|
self::em()->persist($image);
|
||||||
|
|
||||||
|
$asset = (new RenderedAsset())
|
||||||
|
->setImage($image)
|
||||||
|
->setDeviceModel(DeviceModel::V1)
|
||||||
|
->setOrientation(Orientation::Landscape)
|
||||||
|
->setStatus(RenderStatus::Ready)
|
||||||
|
->setFilePath('var/storage/x.bin');
|
||||||
|
self::em()->persist($asset);
|
||||||
|
self::em()->flush();
|
||||||
|
|
||||||
|
$tester = $this->commandTester();
|
||||||
|
$exit = $tester->execute([]);
|
||||||
|
$this->assertSame(0, $exit);
|
||||||
|
$this->assertStringContainsString('Reset and re-dispatched 1 rendered assets', $tester->getDisplay());
|
||||||
|
|
||||||
|
self::em()->clear();
|
||||||
|
$reloaded = self::em()->find(RenderedAsset::class, $asset->getId());
|
||||||
|
$this->assertSame(RenderStatus::Pending, $reloaded->getStatus());
|
||||||
|
$this->assertNull($reloaded->getFilePath());
|
||||||
|
|
||||||
|
// The in-memory transport doesn't reliably retain messages dispatched
|
||||||
|
// from CommandTester across the kernel boundary, so we verify the
|
||||||
|
// dispatch indirectly: a fresh asset was reset (filePath cleared,
|
||||||
|
// Pending status), which only happens on the path that ALSO calls
|
||||||
|
// $bus->dispatch(). The reset wouldn't be flushed if the dispatch
|
||||||
|
// had thrown.
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_no_assets_means_no_messages_dispatched(): void
|
||||||
|
{
|
||||||
|
$tester = $this->commandTester();
|
||||||
|
$exit = $tester->execute([]);
|
||||||
|
$this->assertSame(0, $exit);
|
||||||
|
$this->assertStringContainsString('Reset and re-dispatched 0 rendered assets', $tester->getDisplay());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Integration\Command;
|
||||||
|
|
||||||
|
use App\Entity\Device;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Tests\AppKernelTestCase;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seed command is dev-only but the rotation pipeline relies on its
|
||||||
|
* "5 devices in different sync states" output for visual UI checks.
|
||||||
|
* If it silently breaks (e.g. an entity field rename), nobody catches it
|
||||||
|
* for a week. This test exercises the create / remove / re-run paths.
|
||||||
|
*/
|
||||||
|
class SeedFakeDevicesCommandTest extends AppKernelTestCase
|
||||||
|
{
|
||||||
|
private function commandTester(): CommandTester
|
||||||
|
{
|
||||||
|
$kernel = self::bootKernel();
|
||||||
|
$app = new Application($kernel);
|
||||||
|
return new CommandTester($app->find('app:seed-fake-devices'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_errors_when_email_does_not_match_a_user(): void
|
||||||
|
{
|
||||||
|
$tester = $this->commandTester();
|
||||||
|
$exit = $tester->execute(['email' => 'nobody@example.com']);
|
||||||
|
$this->assertSame(1, $exit);
|
||||||
|
$this->assertStringContainsString('No user found', $tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creates_five_fake_devices_for_an_existing_user(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('seed@example.com');
|
||||||
|
|
||||||
|
$tester = $this->commandTester();
|
||||||
|
$exit = $tester->execute(['email' => 'seed@example.com']);
|
||||||
|
|
||||||
|
$this->assertSame(0, $exit);
|
||||||
|
$this->assertStringContainsString('Seeded 5 fake devices', $tester->getDisplay());
|
||||||
|
|
||||||
|
self::em()->clear();
|
||||||
|
$reloaded = self::em()->getRepository(User::class)->findOneBy(['email' => 'seed@example.com']);
|
||||||
|
$devices = self::em()->getRepository(Device::class)->findBy(['user' => $reloaded]);
|
||||||
|
$this->assertCount(5, $devices);
|
||||||
|
// The Cabin device gets wakeTimes=[4*60]; everything else is empty.
|
||||||
|
$withWakeTimes = array_filter($devices, fn(Device $d) => !empty($d->getWakeTimes()));
|
||||||
|
$this->assertCount(1, $withWakeTimes);
|
||||||
|
$this->assertSame([4 * 60], reset($withWakeTimes)->getWakeTimes());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_re_runs_are_idempotent_existing_fakes_get_swept(): void
|
||||||
|
{
|
||||||
|
$this->createUser('seed-rerun@example.com');
|
||||||
|
$tester = $this->commandTester();
|
||||||
|
|
||||||
|
$tester->execute(['email' => 'seed-rerun@example.com']);
|
||||||
|
$tester->execute(['email' => 'seed-rerun@example.com']);
|
||||||
|
|
||||||
|
// After two runs we should still have exactly five devices and the
|
||||||
|
// second run's output mentions sweeping.
|
||||||
|
$this->assertStringContainsString('Removed 5 existing fake device(s)', $tester->getDisplay());
|
||||||
|
|
||||||
|
self::em()->clear();
|
||||||
|
$user = self::em()->getRepository(User::class)->findOneBy(['email' => 'seed-rerun@example.com']);
|
||||||
|
$devices = self::em()->getRepository(Device::class)->findBy(['user' => $user]);
|
||||||
|
$this->assertCount(5, $devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_remove_flag_sweeps_and_exits_without_creating(): void
|
||||||
|
{
|
||||||
|
$this->createUser('seed-rm@example.com');
|
||||||
|
$tester = $this->commandTester();
|
||||||
|
$tester->execute(['email' => 'seed-rm@example.com']);
|
||||||
|
|
||||||
|
$exit = $tester->execute(['email' => 'seed-rm@example.com', '--remove' => true]);
|
||||||
|
$this->assertSame(0, $exit);
|
||||||
|
|
||||||
|
self::em()->clear();
|
||||||
|
$user = self::em()->getRepository(User::class)->findOneBy(['email' => 'seed-rm@example.com']);
|
||||||
|
$devices = self::em()->getRepository(Device::class)->findBy(['user' => $user]);
|
||||||
|
$this->assertCount(0, $devices);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,4 +155,36 @@ class RenderImageMessageHandlerTest extends AppKernelTestCase
|
|||||||
unlink($badFile);
|
unlink($badFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MH-PORTRAIT: portrait orientation rotates the source 90° CCW so the
|
||||||
|
// photo's top edge maps to the EPD's left column (the panel is physically
|
||||||
|
// rotated when mounted vertically). Without the rotate, the rendered .bin
|
||||||
|
// would come out sideways. This test exercises the rotateImage(-90) branch
|
||||||
|
// in the handler that the landscape-only happy-path tests skip.
|
||||||
|
public function test_portrait_rotation_branch_runs(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('mh-portrait@example.com');
|
||||||
|
$image = (new Image())->setUser($user)
|
||||||
|
->setOriginalFilename('p.jpg')
|
||||||
|
->setStoragePath('var/storage/images/_render_fixture.jpg');
|
||||||
|
$this->em()->persist($image);
|
||||||
|
$this->em()->flush();
|
||||||
|
|
||||||
|
$imageId = $image->getId();
|
||||||
|
$imageDir = $this->projectDir . '/var/storage/images/' . $imageId;
|
||||||
|
mkdir($imageDir, 0755, true);
|
||||||
|
$this->createdDirs[] = $imageDir;
|
||||||
|
|
||||||
|
$this->invokeHandler($imageId, 'v1', 'portrait');
|
||||||
|
|
||||||
|
// Portrait .bin should still be 800×480 EPD-native (192,000 bytes).
|
||||||
|
$binPath = $imageDir . '/v1_portrait.bin';
|
||||||
|
$this->assertFileExists($binPath);
|
||||||
|
$this->assertSame(192000, filesize($binPath));
|
||||||
|
|
||||||
|
$assetRepo = static::getContainer()->get(\App\Repository\RenderedAssetRepository::class);
|
||||||
|
$asset = $assetRepo->findOneBy(['image' => $image, 'orientation' => \App\Enum\Orientation::Portrait]);
|
||||||
|
$this->assertNotNull($asset);
|
||||||
|
$this->assertSame(RenderStatus::Ready, $asset->getStatus());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Enum;
|
||||||
|
|
||||||
|
use App\Enum\DeviceModel;
|
||||||
|
use App\Enum\Orientation;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* width()/height() must follow orientation, but nativeWidth()/nativeHeight()
|
||||||
|
* are the EPD's hardware scan dimensions and must NOT depend on orientation
|
||||||
|
* (the renderer pre-rotates portrait images and streams raw bytes).
|
||||||
|
*/
|
||||||
|
class DeviceModelTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_v1_landscape_dimensions_are_800x480(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(800, DeviceModel::V1->width(Orientation::Landscape));
|
||||||
|
$this->assertSame(480, DeviceModel::V1->height(Orientation::Landscape));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_v1_portrait_dimensions_are_swapped(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(480, DeviceModel::V1->width(Orientation::Portrait));
|
||||||
|
$this->assertSame(800, DeviceModel::V1->height(Orientation::Portrait));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_native_dimensions_ignore_orientation(): void
|
||||||
|
{
|
||||||
|
// The firmware streams 800x480 EPD-native rows regardless of how the
|
||||||
|
// photo was framed; renderer rotates the input photo, then writes in
|
||||||
|
// EPD scan order.
|
||||||
|
$this->assertSame(800, DeviceModel::V1->nativeWidth());
|
||||||
|
$this->assertSame(480, DeviceModel::V1->nativeHeight());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user