Two related bugs that surfaced on the first 13.3" device's first photo: 1) Web-UI portrait preview was 90° sideways. DeviceApiController:: renderBinToPng rotated whenever the device was Portrait — correct for V1 (landscape-native, Portrait => renderer rotated, so preview un-rotates) but wrong for V2 (portrait-native — the renderer doesn't rotate, so the preview shouldn't either). Now mirrors the render-pipeline check: rotate only when `orientation !== model->nativeOrientation()`. Two new functional tests pin the V2 portrait and V2 landscape PNG dimensions to guard against regressions. 2) Cropped photo letterboxed on the 13.3" panel. CropEditor / StickerCanvas / FrameCard had V1 dimensions hardcoded (1600×960 = 5:3 aspect). V2 is 4:3 (1200×1600 portrait / 1600×1200 landscape), so a "full crop" came out the wrong shape and the server's white-canvas composite added bars. New `panelDims(model, orientation)` helper in @/types is the single source of truth on the frontend; matches DeviceModel::width/height on the server. Threaded `model` through Device serializer → Device type → UploadView → CropEditor / StickerCanvas, and HomeView → FrameCard. FrameCard tests updated to cover all four model × orientation placeholders. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -57,7 +57,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import type { CropParams } from '@/types'
|
import { panelDims, type CropParams, type DeviceModel } from '@/types'
|
||||||
|
|
||||||
type Orientation = 'landscape' | 'portrait'
|
type Orientation = 'landscape' | 'portrait'
|
||||||
|
|
||||||
@@ -65,6 +65,10 @@ const props = defineProps<{
|
|||||||
src: string
|
src: string
|
||||||
/** Frame's current orientation — used as the toggle's initial value and for the mismatch chip. */
|
/** Frame's current orientation — used as the toggle's initial value and for the mismatch chip. */
|
||||||
orientation: Orientation
|
orientation: Orientation
|
||||||
|
/** Frame's hardware model — drives crop output dimensions. V1 (7.3", 5:3) and
|
||||||
|
* V2 (13.3", 4:3) have different aspect ratios. Defaults to 'v1' so existing
|
||||||
|
* callers that don't pass a model keep the original behaviour. */
|
||||||
|
model?: DeviceModel
|
||||||
deviceName?: string
|
deviceName?: string
|
||||||
initialParams?: CropParams | null
|
initialParams?: CropParams | null
|
||||||
/** When editing an existing image, defaults the toggle to the saved choice instead of `orientation`. */
|
/** When editing an existing image, defaults the toggle to the saved choice instead of `orientation`. */
|
||||||
@@ -85,11 +89,10 @@ const ORIENT_OPTS: Array<{ value: Orientation; label: string }> = [
|
|||||||
// the crop frame and the eventual output blob.
|
// the crop frame and the eventual output blob.
|
||||||
const cropOrientation = ref<Orientation>(props.initialOrientation ?? props.orientation)
|
const cropOrientation = ref<Orientation>(props.initialOrientation ?? props.orientation)
|
||||||
|
|
||||||
const outputDims = computed(() =>
|
const outputDims = computed(() => {
|
||||||
cropOrientation.value === 'landscape'
|
const { width, height } = panelDims(props.model ?? 'v1', cropOrientation.value)
|
||||||
? { w: 1600, h: 960 }
|
return { w: width, h: height }
|
||||||
: { w: 960, h: 1600 }
|
})
|
||||||
)
|
|
||||||
const aspect = computed(() => outputDims.value.w / outputDims.value.h)
|
const aspect = computed(() => outputDims.value.w / outputDims.value.h)
|
||||||
|
|
||||||
// Visible only when the user's choice doesn't match the frame's setting.
|
// Visible only when the user's choice doesn't match the frame's setting.
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
|
import { panelDims, type DeviceModel } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
deviceId: number
|
deviceId: number
|
||||||
@@ -77,6 +78,9 @@ const props = defineProps<{
|
|||||||
size: 'large' | 'compact'
|
size: 'large' | 'compact'
|
||||||
status: 'ok' | 'offline' | 'sync-fail'
|
status: 'ok' | 'offline' | 'sync-fail'
|
||||||
orientation: 'landscape' | 'portrait'
|
orientation: 'landscape' | 'portrait'
|
||||||
|
/** Frame's hardware model — drives the empty-preview placeholder aspect
|
||||||
|
* ratio. V1 is 5:3, V2 is 4:3 (or 3:4 portrait). Defaults to 'v1'. */
|
||||||
|
model?: DeviceModel
|
||||||
thumbnailUrl?: string
|
thumbnailUrl?: string
|
||||||
photoCount?: number
|
photoCount?: number
|
||||||
lastSync?: string | null
|
lastSync?: string | null
|
||||||
@@ -91,11 +95,11 @@ defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
|
|||||||
// roughly the same shape so the layout doesn't jump.
|
// roughly the same shape so the layout doesn't jump.
|
||||||
const previewStyle = computed(() => ({}))
|
const previewStyle = computed(() => ({}))
|
||||||
|
|
||||||
const emptyAspectStyle = computed(() =>
|
const emptyAspectStyle = computed(() => {
|
||||||
props.size === 'large'
|
if (props.size !== 'large') return {}
|
||||||
? { aspectRatio: props.orientation === 'portrait' ? '3 / 5' : '5 / 3' }
|
const { width, height } = panelDims(props.model ?? 'v1', props.orientation)
|
||||||
: {}
|
return { aspectRatio: `${width} / ${height}` }
|
||||||
)
|
})
|
||||||
|
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
switch (props.status) {
|
switch (props.status) {
|
||||||
|
|||||||
@@ -74,11 +74,14 @@ import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
|||||||
import Konva from 'konva'
|
import Konva from 'konva'
|
||||||
import BaseButton from '@/components/BaseButton.vue'
|
import BaseButton from '@/components/BaseButton.vue'
|
||||||
import StickerTray from '@/components/StickerTray.vue'
|
import StickerTray from '@/components/StickerTray.vue'
|
||||||
import type { StickerLayer } from '@/types'
|
import { panelDims, type StickerLayer, type DeviceModel } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
croppedUrl: string
|
croppedUrl: string
|
||||||
orientation: 'landscape' | 'portrait'
|
orientation: 'landscape' | 'portrait'
|
||||||
|
/** Frame's hardware model — drives stage aspect ratio + final output dims.
|
||||||
|
* Defaults to 'v1' so existing callers keep their behaviour. */
|
||||||
|
model?: DeviceModel
|
||||||
stickers: StickerLayer[]
|
stickers: StickerLayer[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -101,7 +104,8 @@ const selectedId = ref<string | null>(null)
|
|||||||
const stageW = ref(375)
|
const stageW = ref(375)
|
||||||
const stageH = ref(225)
|
const stageH = ref(225)
|
||||||
|
|
||||||
const ASPECT = props.orientation === 'landscape' ? (1600 / 960) : (960 / 1600)
|
const { width: OUT_W, height: OUT_H } = panelDims(props.model ?? 'v1', props.orientation)
|
||||||
|
const ASPECT = OUT_W / OUT_H
|
||||||
|
|
||||||
function sizeStage() {
|
function sizeStage() {
|
||||||
if (!containerRef.value) return
|
if (!containerRef.value) return
|
||||||
@@ -384,8 +388,7 @@ async function done() {
|
|||||||
const stage: Konva.Stage = stageRef.value?.getNode()
|
const stage: Konva.Stage = stageRef.value?.getNode()
|
||||||
if (!stage) return
|
if (!stage) return
|
||||||
|
|
||||||
const outputW = props.orientation === 'landscape' ? 1600 : 960
|
const pixelRatio = OUT_W / stageW.value
|
||||||
const pixelRatio = outputW / stageW.value
|
|
||||||
|
|
||||||
// Use explicit mimeType so the blob is a real JPEG, not the default PNG
|
// Use explicit mimeType so the blob is a real JPEG, not the default PNG
|
||||||
const blob = await stage.toBlob({ pixelRatio, mimeType: 'image/jpeg', quality: 0.92 }) as Blob | null
|
const blob = await stage.toBlob({ pixelRatio, mimeType: 'image/jpeg', quality: 0.92 }) as Blob | null
|
||||||
|
|||||||
@@ -146,19 +146,35 @@ describe('FrameCard', () => {
|
|||||||
expect(wrapper.emitted('edit')![0]).toEqual([1])
|
expect(wrapper.emitted('edit')![0]).toEqual([1])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reserves a 5:3 aspect placeholder when no thumbnail is present (landscape)', () => {
|
it('reserves a V1 landscape (1600 × 960) placeholder when no thumbnail is present', () => {
|
||||||
const wrapper = mount(FrameCard, {
|
const wrapper = mount(FrameCard, {
|
||||||
props: { ...defaultProps, size: 'large', orientation: 'landscape' },
|
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v1' },
|
||||||
})
|
})
|
||||||
const empty = wrapper.find('.frame-card__empty-preview')
|
const empty = wrapper.find('.frame-card__empty-preview')
|
||||||
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/)
|
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*960/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reserves a 3:5 aspect placeholder when no thumbnail is present (portrait)', () => {
|
it('reserves a V1 portrait (960 × 1600) placeholder when no thumbnail is present', () => {
|
||||||
const wrapper = mount(FrameCard, {
|
const wrapper = mount(FrameCard, {
|
||||||
props: { ...defaultProps, size: 'large', orientation: 'portrait' },
|
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v1' },
|
||||||
})
|
})
|
||||||
const empty = wrapper.find('.frame-card__empty-preview')
|
const empty = wrapper.find('.frame-card__empty-preview')
|
||||||
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/)
|
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*960\s*\/\s*1600/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserves a V2 portrait (1200 × 1600) placeholder when no thumbnail is present', () => {
|
||||||
|
const wrapper = mount(FrameCard, {
|
||||||
|
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v2' },
|
||||||
|
})
|
||||||
|
const empty = wrapper.find('.frame-card__empty-preview')
|
||||||
|
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1200\s*\/\s*1600/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reserves a V2 landscape (1600 × 1200) placeholder when no thumbnail is present', () => {
|
||||||
|
const wrapper = mount(FrameCard, {
|
||||||
|
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v2' },
|
||||||
|
})
|
||||||
|
const empty = wrapper.find('.frame-card__empty-preview')
|
||||||
|
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*1200/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,10 +6,37 @@ export interface User {
|
|||||||
timezone: string
|
timezone: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeviceModel = 'v1' | 'v2'
|
||||||
|
|
||||||
|
/** Panel dimensions a device renders to for a given user orientation. */
|
||||||
|
export interface PanelDims {
|
||||||
|
/** Pre-rotation source width — matches the renderer's thumbnail target. */
|
||||||
|
width: number
|
||||||
|
/** Pre-rotation source height. */
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crop / render dimensions for a device's photo at the user's chosen
|
||||||
|
* orientation. Mirrors `DeviceModel::width(Orientation)` / `height(...)` on
|
||||||
|
* the server. Keep these tables in sync.
|
||||||
|
*
|
||||||
|
* - V1: native landscape 800×480 → upscaled to 1600×960 for better dither
|
||||||
|
* - V2: native portrait 1200×1600
|
||||||
|
*/
|
||||||
|
export function panelDims(model: DeviceModel, orientation: 'landscape' | 'portrait'): PanelDims {
|
||||||
|
if (model === 'v1') {
|
||||||
|
return orientation === 'landscape' ? { width: 1600, height: 960 } : { width: 960, height: 1600 }
|
||||||
|
}
|
||||||
|
// v2 — 13.3" Spectra-6, portrait-native
|
||||||
|
return orientation === 'landscape' ? { width: 1600, height: 1200 } : { width: 1200, height: 1600 }
|
||||||
|
}
|
||||||
|
|
||||||
export interface Device {
|
export interface Device {
|
||||||
id: number
|
id: number
|
||||||
mac: string
|
mac: string
|
||||||
name: string
|
name: string
|
||||||
|
model: DeviceModel
|
||||||
orientation: 'landscape' | 'portrait'
|
orientation: 'landscape' | 'portrait'
|
||||||
rotationIntervalMinutes: number
|
rotationIntervalMinutes: number
|
||||||
/** Wake times as minutes-since-midnight (0-1439). Empty = use rotationIntervalMinutes. */
|
/** Wake times as minutes-since-midnight (0-1439). Empty = use rotationIntervalMinutes. */
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
size="large"
|
size="large"
|
||||||
:status="deviceStatus(devicesStore.devices[0])"
|
:status="deviceStatus(devicesStore.devices[0])"
|
||||||
:orientation="devicesStore.devices[0].orientation"
|
:orientation="devicesStore.devices[0].orientation"
|
||||||
|
:model="devicesStore.devices[0].model"
|
||||||
:thumbnailUrl="previewUrl(devicesStore.devices[0])"
|
:thumbnailUrl="previewUrl(devicesStore.devices[0])"
|
||||||
:lastSync="lastSyncLabel(devicesStore.devices[0])"
|
:lastSync="lastSyncLabel(devicesStore.devices[0])"
|
||||||
:nextSync="nextSyncLabel(devicesStore.devices[0])"
|
:nextSync="nextSyncLabel(devicesStore.devices[0])"
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
size="large"
|
size="large"
|
||||||
:status="deviceStatus(device)"
|
:status="deviceStatus(device)"
|
||||||
:orientation="device.orientation"
|
:orientation="device.orientation"
|
||||||
|
:model="device.model"
|
||||||
:thumbnailUrl="previewUrl(device)"
|
:thumbnailUrl="previewUrl(device)"
|
||||||
:lastSync="lastSyncLabel(device)"
|
:lastSync="lastSyncLabel(device)"
|
||||||
:nextSync="nextSyncLabel(device)"
|
:nextSync="nextSyncLabel(device)"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
v-if="step === 'crop' && uploadStore.originalUrl"
|
v-if="step === 'crop' && uploadStore.originalUrl"
|
||||||
:src="uploadStore.originalUrl"
|
:src="uploadStore.originalUrl"
|
||||||
:orientation="contextOrientation"
|
:orientation="contextOrientation"
|
||||||
|
:model="contextModel"
|
||||||
:device-name="contextDeviceName"
|
:device-name="contextDeviceName"
|
||||||
:initial-params="uploadStore.cropParams"
|
:initial-params="uploadStore.cropParams"
|
||||||
:initial-orientation="uploadStore.cropOrientation"
|
:initial-orientation="uploadStore.cropOrientation"
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
v-else-if="step === 'stickers' && uploadStore.croppedUrl"
|
v-else-if="step === 'stickers' && uploadStore.croppedUrl"
|
||||||
:cropped-url="uploadStore.croppedUrl"
|
:cropped-url="uploadStore.croppedUrl"
|
||||||
:orientation="effectiveOrientation"
|
:orientation="effectiveOrientation"
|
||||||
|
:model="contextModel"
|
||||||
:stickers="uploadStore.stickers"
|
:stickers="uploadStore.stickers"
|
||||||
class="upload-view__stage"
|
class="upload-view__stage"
|
||||||
@add-sticker="uploadStore.addSticker"
|
@add-sticker="uploadStore.addSticker"
|
||||||
@@ -78,7 +80,7 @@ import { useUploadStore } from '@/stores/upload'
|
|||||||
import { useDevicesStore } from '@/stores/devices'
|
import { useDevicesStore } from '@/stores/devices'
|
||||||
import { useImagesStore } from '@/stores/images'
|
import { useImagesStore } from '@/stores/images'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { CropParams } from '@/types'
|
import type { CropParams, DeviceModel } from '@/types'
|
||||||
import CropEditor from '@/components/CropEditor.vue'
|
import CropEditor from '@/components/CropEditor.vue'
|
||||||
import StickerCanvas from '@/components/StickerCanvas.vue'
|
import StickerCanvas from '@/components/StickerCanvas.vue'
|
||||||
import DevicePicker from '@/components/DevicePicker.vue'
|
import DevicePicker from '@/components/DevicePicker.vue'
|
||||||
@@ -120,6 +122,9 @@ const contextDevice = computed(() =>
|
|||||||
: devicesStore.devices[0]
|
: devicesStore.devices[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const contextModel = computed<DeviceModel>(() =>
|
||||||
|
contextDevice.value?.model ?? 'v1'
|
||||||
|
)
|
||||||
const contextOrientation = computed<'landscape' | 'portrait'>(() =>
|
const contextOrientation = computed<'landscape' | 'portrait'>(() =>
|
||||||
contextDevice.value?.orientation ?? 'landscape'
|
contextDevice.value?.orientation ?? 'landscape'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Entity\Device;
|
|||||||
use App\Entity\Image;
|
use App\Entity\Image;
|
||||||
use App\Entity\RenderedAsset;
|
use App\Entity\RenderedAsset;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Enum\DeviceModel;
|
||||||
use App\Enum\Orientation;
|
use App\Enum\Orientation;
|
||||||
use App\Enum\RenderStatus;
|
use App\Enum\RenderStatus;
|
||||||
use App\Enum\RotationMode;
|
use App\Enum\RotationMode;
|
||||||
@@ -277,6 +278,7 @@ class DeviceApiController extends AbstractController
|
|||||||
$device->getModel()->nativeWidth(),
|
$device->getModel()->nativeWidth(),
|
||||||
$device->getModel()->nativeHeight(),
|
$device->getModel()->nativeHeight(),
|
||||||
$device->getOrientation(),
|
$device->getOrientation(),
|
||||||
|
$device->getModel(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +291,7 @@ class DeviceApiController extends AbstractController
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderBinToPng(string $binPath, string $pngPath, int $width, int $height, Orientation $orientation): void
|
private function renderBinToPng(string $binPath, string $pngPath, int $width, int $height, Orientation $orientation, DeviceModel $model): void
|
||||||
{
|
{
|
||||||
$bin = (string) file_get_contents($binPath);
|
$bin = (string) file_get_contents($binPath);
|
||||||
$len = strlen($bin);
|
$len = strlen($bin);
|
||||||
@@ -311,10 +313,14 @@ class DeviceApiController extends AbstractController
|
|||||||
$im = new \Imagick();
|
$im = new \Imagick();
|
||||||
$im->readImageBlob($ppm);
|
$im->readImageBlob($ppm);
|
||||||
|
|
||||||
// The .bin is always laid out in EPD-native scan order. For portrait,
|
// The .bin is always panel-native scan order. The render pipeline
|
||||||
// the renderer pre-rotated the photo 90° CCW; rotate 90° here so the
|
// rotates the source 90° CCW only when the user's orientation differs
|
||||||
// browser-side preview shows the photo upright.
|
// from the panel's native — see RenderImageMessageHandler. Mirror that
|
||||||
if ($orientation === Orientation::Portrait) {
|
// here so the preview un-rotates exactly when the renderer rotated:
|
||||||
|
// V1 (landscape-native) + Portrait → renderer rotated, preview rotates back.
|
||||||
|
// V2 (portrait-native) + Portrait → renderer did NOT rotate, no rotate here.
|
||||||
|
// V2 (portrait-native) + Landscape → renderer rotated, preview rotates back.
|
||||||
|
if ($orientation !== $model->nativeOrientation()) {
|
||||||
$im->rotateImage(new \ImagickPixel('white'), 90);
|
$im->rotateImage(new \ImagickPixel('white'), 90);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ final class DeviceSerializer
|
|||||||
'id' => $d->getId(),
|
'id' => $d->getId(),
|
||||||
'mac' => $d->getMac(),
|
'mac' => $d->getMac(),
|
||||||
'name' => $d->getName(),
|
'name' => $d->getName(),
|
||||||
|
'model' => $d->getModel()->value,
|
||||||
'orientation' => $d->getOrientation()->value,
|
'orientation' => $d->getOrientation()->value,
|
||||||
'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(),
|
'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(),
|
||||||
'wakeTimes' => $d->getWakeTimes(),
|
'wakeTimes' => $d->getWakeTimes(),
|
||||||
|
|||||||
@@ -576,6 +576,101 @@ class DeviceApiControllerTest extends AppWebTestCase
|
|||||||
@unlink(preg_replace('/\.bin$/', '.png', $absPath));
|
@unlink(preg_replace('/\.bin$/', '.png', $absPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V2 panel (13.3", 1200×1600 portrait-native) + Portrait orientation MUST
|
||||||
|
// NOT rotate the preview — Portrait is the natural scan orientation, the
|
||||||
|
// render pipeline didn't rotate the source, so the preview must mirror
|
||||||
|
// that. The bug before this guard: every V2 portrait preview came out
|
||||||
|
// 90° sideways in the web UI.
|
||||||
|
public function test_preview_v2_portrait_not_rotated(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('ppng-v2p@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:E7', $user);
|
||||||
|
$device->setModel(DeviceModel::V2);
|
||||||
|
$device->setOrientation(Orientation::Portrait);
|
||||||
|
$image = $this->makeImage($user);
|
||||||
|
$device->setCurrentImage($image);
|
||||||
|
|
||||||
|
// 1200×1600 = 1,920,000 nibbles = 960,000 bytes.
|
||||||
|
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
|
||||||
|
$relPath = 'var/storage/images/test-preview-v2-portrait/v2_portrait.bin';
|
||||||
|
$absPath = $projectDir . '/' . $relPath;
|
||||||
|
if (!is_dir(dirname($absPath))) {
|
||||||
|
mkdir(dirname($absPath), 0755, true);
|
||||||
|
}
|
||||||
|
file_put_contents($absPath, str_repeat(chr(0x11), 960000));
|
||||||
|
|
||||||
|
$asset = (new \App\Entity\RenderedAsset())
|
||||||
|
->setImage($image)
|
||||||
|
->setDeviceModel(DeviceModel::V2)
|
||||||
|
->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'));
|
||||||
|
|
||||||
|
// Decode the PNG and confirm orientation — taller than wide means
|
||||||
|
// no spurious 90° rotation happened (would be 1600×1200 if it had).
|
||||||
|
$pngPath = preg_replace('/\.bin$/', '.png', $absPath);
|
||||||
|
$this->assertFileExists($pngPath);
|
||||||
|
$im = new \Imagick($pngPath);
|
||||||
|
$this->assertSame(1200, $im->getImageWidth(), 'V2 portrait PNG must be 1200 wide (not rotated)');
|
||||||
|
$this->assertSame(1600, $im->getImageHeight(), 'V2 portrait PNG must be 1600 tall (not rotated)');
|
||||||
|
$im->destroy();
|
||||||
|
|
||||||
|
@unlink($absPath);
|
||||||
|
@unlink($pngPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// V2 panel + Landscape orientation MUST rotate — Landscape is non-native
|
||||||
|
// for V2 (portrait-native), so the renderer pre-rotated and the preview
|
||||||
|
// needs to rotate back to upright landscape.
|
||||||
|
public function test_preview_v2_landscape_rotated(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser('ppng-v2l@example.com');
|
||||||
|
$device = $this->makeDevice('AA:BB:CC:DD:EE:E8', $user);
|
||||||
|
$device->setModel(DeviceModel::V2);
|
||||||
|
$device->setOrientation(Orientation::Landscape);
|
||||||
|
$image = $this->makeImage($user);
|
||||||
|
$device->setCurrentImage($image);
|
||||||
|
|
||||||
|
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
|
||||||
|
$relPath = 'var/storage/images/test-preview-v2-landscape/v2_landscape.bin';
|
||||||
|
$absPath = $projectDir . '/' . $relPath;
|
||||||
|
if (!is_dir(dirname($absPath))) {
|
||||||
|
mkdir(dirname($absPath), 0755, true);
|
||||||
|
}
|
||||||
|
file_put_contents($absPath, str_repeat(chr(0x11), 960000));
|
||||||
|
|
||||||
|
$asset = (new \App\Entity\RenderedAsset())
|
||||||
|
->setImage($image)
|
||||||
|
->setDeviceModel(DeviceModel::V2)
|
||||||
|
->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();
|
||||||
|
|
||||||
|
// The .bin is panel-native 1200×1600 (tall); after the 90° rotation
|
||||||
|
// for non-native orientation the PNG must be 1600×1200 (wide).
|
||||||
|
$pngPath = preg_replace('/\.bin$/', '.png', $absPath);
|
||||||
|
$im = new \Imagick($pngPath);
|
||||||
|
$this->assertSame(1600, $im->getImageWidth(), 'V2 landscape PNG must be 1600 wide (rotated)');
|
||||||
|
$this->assertSame(1200, $im->getImageHeight(), 'V2 landscape PNG must be 1200 tall (rotated)');
|
||||||
|
$im->destroy();
|
||||||
|
|
||||||
|
@unlink($absPath);
|
||||||
|
@unlink($pngPath);
|
||||||
|
}
|
||||||
|
|
||||||
// ── DELETE /api/devices/{id} (sell/give-away) ────────────────────────
|
// ── DELETE /api/devices/{id} (sell/give-away) ────────────────────────
|
||||||
|
|
||||||
public function test_delete_removes_device_and_purges_history_and_approvals(): void
|
public function test_delete_removes_device_and_purges_history_and_approvals(): void
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class DeviceSerializerTest extends TestCase
|
|||||||
$payload = $this->serializer->serialize($device);
|
$payload = $this->serializer->serialize($device);
|
||||||
|
|
||||||
$this->assertEqualsCanonicalizing(
|
$this->assertEqualsCanonicalizing(
|
||||||
['id', 'mac', 'name', 'orientation', 'rotationIntervalMinutes',
|
['id', 'mac', 'name', 'model', 'orientation', 'rotationIntervalMinutes',
|
||||||
'wakeTimes', 'timezone', 'uniquenessWindow', 'rotationMode',
|
'wakeTimes', 'timezone', 'uniquenessWindow', 'rotationMode',
|
||||||
'prioritizeNeverShown', 'linkedAt', 'lastSeenAt',
|
'prioritizeNeverShown', 'linkedAt', 'lastSeenAt',
|
||||||
'nextPollExpectedAt', 'lockedImageId', 'currentImageId'],
|
'nextPollExpectedAt', 'lockedImageId', 'currentImageId'],
|
||||||
@@ -42,6 +42,14 @@ class DeviceSerializerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_serializes_model_field(): void
|
||||||
|
{
|
||||||
|
$device = $this->makeDevice();
|
||||||
|
$device->setModel(\App\Enum\DeviceModel::V2);
|
||||||
|
$payload = $this->serializer->serialize($device);
|
||||||
|
$this->assertSame('v2', $payload['model']);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_serializes_scalars_in_expected_shapes(): void
|
public function test_serializes_scalars_in_expected_shapes(): void
|
||||||
{
|
{
|
||||||
$device = $this->makeDevice();
|
$device = $this->makeDevice();
|
||||||
|
|||||||
Reference in New Issue
Block a user