feat(setup): noon-daily default + force-refresh hint + inline remove confirm
CI / test (push) Has been cancelled

Three coordinated UX changes touching defaults and the settings sheet.

1. Server defaults: DeviceService::linkToUser now sets timezone =
   user.timezone and wakeTimes = [12*60] (noon-daily) when creating a
   new Device row OR transferring ownership on takeover. Replaces the
   prior "1440-min interval anchored to last-seen-time" default that
   could land a recipient's first photo at 3 am.

2. PWA propagation note: now mentions "briefly disconnect and reconnect
   the frame's power" as the immediate-refresh gesture. Pairs with the
   existing X-Boot-Reason: cold force-resync — the firmware already
   honors a power-cycle as a deliberate refresh request, but users had
   no way to discover that.

3. Remove-this-frame: replaced the native window.confirm() with an
   in-sheet confirmation panel showing the explanatory text. Inline
   keeps the gesture inside the existing sheet flow and gives the
   destructive button a fixed location, instead of a floating native
   dialog that varies per browser. The confirm body explicitly says
   "this can't be undone" to match the irreversibility.

Tests:
  - DeviceServiceTest: new-device default, takeover-resets-with-default,
    UTC fallback when user has empty timezone.
  - SetupControllerTest: claim-takes-over-defaults updated to assert
    [12*60] wakeTimes.
  - HomeView.test: 4 cases covering open-confirm, yes-confirm, cancel,
    propagation-note text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 16:19:51 -04:00
parent 6b13312fdd
commit e4f811581a
15 changed files with 190 additions and 51 deletions
+51 -7
View File
@@ -1028,34 +1028,57 @@ describe('HomeView', () => {
})) }))
}) })
it('Remove this frame confirms, calls store.removeDevice, and closes the sheet', async () => { // Two-step confirm: clicking "Remove this frame" opens the in-sheet
// confirmation panel; clicking "Yes, remove" inside it actually fires
// the API + closes the sheet.
it('Remove this frame opens an in-sheet confirmation panel', async () => {
const devicesStore = useDevicesStore() const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, name: 'Den' })] devicesStore.devices = [makeDevice({ id: 5, name: 'Den' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const removeSpy = vi.spyOn(devicesStore, 'removeDevice').mockResolvedValue() vi.spyOn(devicesStore, 'removeDevice').mockResolvedValue()
const confirmSpy = vi.fn().mockReturnValue(true)
vi.stubGlobal('confirm', confirmSpy)
const wrapper = mountView() const wrapper = mountView()
await flushPromises() await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises() await flushPromises()
expect(wrapper.find('.home-view__remove-confirm').exists()).toBe(false)
await wrapper.find('.home-view__remove').trigger('click') await wrapper.find('.home-view__remove').trigger('click')
await flushPromises() await flushPromises()
expect(confirmSpy).toHaveBeenCalled() expect(wrapper.find('.home-view__remove-confirm').exists()).toBe(true)
expect(wrapper.find('.home-view__remove-confirm-body').text()).toContain('selling or giving away')
// The original primary button is replaced by the confirm panel — no
// accidental "Remove" double-click can fire the API directly.
expect(wrapper.find('.home-view__remove').exists()).toBe(false)
})
it('Yes, remove inside the confirm panel calls the store and closes the sheet', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const removeSpy = vi.spyOn(devicesStore, 'removeDevice').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
await wrapper.find('.home-view__remove').trigger('click')
await flushPromises()
await wrapper.find('.home-view__remove-confirm-btn').trigger('click')
await flushPromises()
expect(removeSpy).toHaveBeenCalledWith(5) expect(removeSpy).toHaveBeenCalledWith(5)
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' }) const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(false) expect(sheet.props('modelValue')).toBe(false)
}) })
it('Remove this frame cancel does NOT call removeDevice', async () => { it('Cancel inside the confirm panel returns to the normal sheet without calling removeDevice', async () => {
const devicesStore = useDevicesStore() const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5 })] devicesStore.devices = [makeDevice({ id: 5 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const removeSpy = vi.spyOn(devicesStore, 'removeDevice').mockResolvedValue() const removeSpy = vi.spyOn(devicesStore, 'removeDevice').mockResolvedValue()
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false))
const wrapper = mountView() const wrapper = mountView()
await flushPromises() await flushPromises()
@@ -1063,8 +1086,29 @@ describe('HomeView', () => {
await flushPromises() await flushPromises()
await wrapper.find('.home-view__remove').trigger('click') await wrapper.find('.home-view__remove').trigger('click')
await flushPromises() await flushPromises()
await wrapper.find('.home-view__remove-cancel').trigger('click')
await flushPromises()
expect(removeSpy).not.toHaveBeenCalled() expect(removeSpy).not.toHaveBeenCalled()
expect(wrapper.find('.home-view__remove-confirm').exists()).toBe(false)
expect(wrapper.find('.home-view__remove').exists()).toBe(true)
})
// Update the existing propagation-note text test to assert the new
// "disconnect power" hint that lets users force an immediate refresh.
it('propagation note mentions disconnecting power as an immediate-refresh gesture', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
const note = wrapper.find('.home-view__propagation-note').text()
expect(note).toMatch(/next device update/)
expect(note).toMatch(/disconnect.*reconnect.*power/i)
}) })
it('shows the propagation note in the settings sheet', async () => { it('shows the propagation note in the settings sheet', async () => {
+86 -31
View File
@@ -188,7 +188,9 @@
<p class="home-view__next-update" aria-live="polite">{{ nextUpdatePreview }}</p> <p class="home-view__next-update" aria-live="polite">{{ nextUpdatePreview }}</p>
<p class="home-view__propagation-note"> <p class="home-view__propagation-note">
Changes will only take effect at the next device update. Changes will only take effect at the next device update. To force
an immediate refresh, briefly disconnect and reconnect the frames
power.
</p> </p>
</div> </div>
@@ -224,19 +226,38 @@
{{ saving ? 'Saving…' : 'Save' }} {{ saving ? 'Saving…' : 'Save' }}
</BaseButton> </BaseButton>
<button <template v-if="!removeConfirmOpen">
type="button" <button
class="home-view__remove" type="button"
:disabled="removing" class="home-view__remove"
@click="confirmAndRemove" @click="removeConfirmOpen = true"
> >Remove this frame</button>
{{ removing ? 'Removing…' : 'Remove this frame' }} </template>
</button>
<p class="home-view__remove-hint"> <div v-else class="home-view__remove-confirm" role="alertdialog" aria-labelledby="remove-confirm-title">
Use this if youre selling or giving away the frame. It deletes <p class="home-view__remove-confirm-title" id="remove-confirm-title">
this frame from your account and unlinks it from your photos so the Remove this frame?
next owner can claim it fresh. </p>
</p> <p class="home-view__remove-confirm-body">
Use this if youre selling or giving away the frame. It deletes
this frame from your account and unlinks it from your photos so the
next owner can claim it fresh. This cant be undone.
</p>
<div class="home-view__remove-confirm-actions">
<button
type="button"
class="home-view__remove-cancel"
:disabled="removing"
@click="removeConfirmOpen = false"
>Cancel</button>
<button
type="button"
class="home-view__remove-confirm-btn"
:disabled="removing"
@click="performRemove"
>{{ removing ? 'Removing…' : 'Yes, remove' }}</button>
</div>
</div>
</BaseBottomSheet> </BaseBottomSheet>
</template> </template>
@@ -496,6 +517,7 @@ const TIMEZONE_GROUPS = [
const sheetOpen = ref(false) const sheetOpen = ref(false)
const saving = ref(false) const saving = ref(false)
const removing = ref(false) const removing = ref(false)
const removeConfirmOpen = ref(false)
const editingDevice = ref<Device | null>(null) const editingDevice = ref<Device | null>(null)
const editName = ref('') const editName = ref('')
const editOrientation = ref<Device['orientation']>('landscape') const editOrientation = ref<Device['orientation']>('landscape')
@@ -649,25 +671,17 @@ function onEdit(deviceId: number) {
editFrequencyMode.value = device.wakeTimes.length > 0 ? 'times' : 'interval' editFrequencyMode.value = device.wakeTimes.length > 0 ? 'times' : 'interval'
editRotationMode.value = device.rotationMode editRotationMode.value = device.rotationMode
editPrioritizeNeverShown.value = device.prioritizeNeverShown editPrioritizeNeverShown.value = device.prioritizeNeverShown
removeConfirmOpen.value = false
sheetOpen.value = true sheetOpen.value = true
} }
async function confirmAndRemove() { async function performRemove() {
if (!editingDevice.value) return if (!editingDevice.value) return
const name = editingDevice.value.name || 'this frame'
// Native confirm destructive, irreversible, single-user action. A
// bespoke modal would be polish; native is honest about the weight.
const ok = window.confirm(
`Remove "${name}" from your account?\n\n` +
`This deletes the frame's history and unlinks all photos you'd ` +
`approved for it. Use this when selling or giving the frame away.`,
)
if (!ok) return
removing.value = true removing.value = true
try { try {
await devicesStore.removeDevice(editingDevice.value.id) await devicesStore.removeDevice(editingDevice.value.id)
sheetOpen.value = false sheetOpen.value = false
removeConfirmOpen.value = false
} finally { } finally {
removing.value = false removing.value = false
} }
@@ -1034,12 +1048,53 @@ async function saveSettings() {
} }
} }
&__remove-hint { &__remove-confirm {
margin-top: var(--space-1); margin-top: var(--space-5);
font-size: var(--text-xs); padding: var(--space-3);
color: var(--color-text-muted); border: 1.5px solid var(--color-danger, #c0392b);
line-height: 1.4; border-radius: var(--radius-md);
text-align: center; background: var(--color-surface);
}
&__remove-confirm-title {
font-size: var(--text-md);
font-weight: 700;
color: var(--color-danger, #c0392b);
margin-bottom: var(--space-2);
}
&__remove-confirm-body {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.5;
margin-bottom: var(--space-3);
}
&__remove-confirm-actions {
display: flex;
gap: var(--space-2);
}
&__remove-cancel,
&__remove-confirm-btn {
flex: 1 1 0;
min-height: var(--touch-min);
padding: 0 var(--space-3);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
&:disabled { opacity: 0.6; cursor: not-allowed; }
}
&__remove-confirm-btn {
background: var(--color-danger, #c0392b);
border-color: var(--color-danger, #c0392b);
color: #fff;
} }
} }
</style> </style>
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{_ as e,d as t,f as n,g as r,j as i,k as a,m as o,pt as s,s as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,t as m}from"./BaseBottomSheet-BFb0Ya9R.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=u(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(u,d)=>(a(),n(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>u.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=t(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=t(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),t(`div`,h,[(a(!0),o(c,null,i(l.devices,e=>(a(),o(`label`,{key:e.id,class:`device-picker__row`},[t(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),t(`span`,_,s(e.name),1),t(`span`,v,s(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>u.$emit(`confirm`)},{default:f(()=>[r(s(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t}; import{_ as e,d as t,f as n,g as r,j as i,k as a,m as o,pt as s,s as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,t as m}from"./BaseBottomSheet-C-CffjbC.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=u(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(u,d)=>(a(),n(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>u.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=t(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=t(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),t(`div`,h,[(a(!0),o(c,null,i(l.devices,e=>(a(),o(`label`,{key:e.id,class:`device-picker__row`},[t(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),t(`span`,_,s(e.name),1),t(`span`,v,s(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>u.$emit(`confirm`)},{default:f(()=>[r(s(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{K as e,d as t,dt as n,ft as r,j as i,k as a,m as o,p as s,pt as c,s as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,r as m,t as h}from"./index-CcUL8REP.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=d(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(u,d)=>(a(),o(`main`,g,[d[5]||=t(`h1`,{class:`settings__title`},`Settings`,-1),t(`section`,_,[d[1]||=t(`h2`,{class:`settings__section-title`},`Theme`,-1),t(`div`,v,[(a(!0),o(l,null,i(e(h),e=>(a(),o(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:n([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[d[0]||=t(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[t(`span`,{class:`theme-swatch__bar`}),t(`span`,{class:`theme-swatch__dot`})],-1),t(`span`,b,c(e.label),1),E.value===e.id?(a(),o(`span`,x,``)):s(``,!0)],14,y))),128))])]),t(`section`,S,[d[3]||=t(`h2`,{class:`settings__section-title`},`Account`,-1),t(`div`,C,[d[2]||=t(`span`,{class:`settings__row-label`},`Signed in as`,-1),t(`span`,w,c(e(f).user?.email),1)]),d[4]||=t(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default}; import{K as e,d as t,dt as n,ft as r,j as i,k as a,m as o,p as s,pt as c,s as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,r as m,t as h}from"./index-CPVhLfHG.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=d(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(u,d)=>(a(),o(`main`,g,[d[5]||=t(`h1`,{class:`settings__title`},`Settings`,-1),t(`section`,_,[d[1]||=t(`h2`,{class:`settings__section-title`},`Theme`,-1),t(`div`,v,[(a(!0),o(l,null,i(e(h),e=>(a(),o(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:n([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[d[0]||=t(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[t(`span`,{class:`theme-swatch__bar`}),t(`span`,{class:`theme-swatch__dot`})],-1),t(`span`,b,c(e.label),1),E.value===e.id?(a(),o(`span`,x,``)):s(``,!0)],14,y))),128))])]),t(`section`,S,[d[3]||=t(`h2`,{class:`settings__section-title`},`Account`,-1),t(`div`,C,[d[2]||=t(`span`,{class:`settings__row-label`},`Signed in as`,-1),t(`span`,w,c(e(f).user?.email),1)]),d[4]||=t(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -14,7 +14,7 @@
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" /> <meta name="apple-mobile-web-app-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-CcUL8REP.js"></script> <script type="module" crossorigin src="/build/assets/index-CPVhLfHG.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DRLwVS0w.js"> <link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DRLwVS0w.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css"> <link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head> </head>
+12 -1
View File
@@ -50,8 +50,15 @@ class DeviceService
$device = $this->repo->findOneBy(['mac' => $mac]); $device = $this->repo->findOneBy(['mac' => $mac]);
if ($device === null) { if ($device === null) {
// Default new devices to "once a day at noon, in the user's
// timezone." Prior default was a 1440-minute *interval* anchored
// to last-seen time, which meant gift recipients saw their first
// photo at 3am if the device happened to set up at 3am — bad
// first-impression. Noon-daily is forgiving and predictable.
$device = new Device(); $device = new Device();
$device->setMac($mac); $device->setMac($mac);
$device->setTimezone($newOwner->getTimezone() ?: 'UTC');
$device->setWakeTimes([12 * 60]);
} elseif ($device->getUser() !== null && $device->getUser()->getId() !== $newOwner->getId()) { } elseif ($device->getUser() !== null && $device->getUser()->getId() !== $newOwner->getId()) {
if (!$allowClaim) { if (!$allowClaim) {
throw new DeviceClaimRequiredException(); throw new DeviceClaimRequiredException();
@@ -60,13 +67,17 @@ class DeviceService
$this->purgeDeviceHistory($device); $this->purgeDeviceHistory($device);
// Reset device-specific state so the new owner doesn't inherit // Reset device-specific state so the new owner doesn't inherit
// schedule, locked image, current image, or pending poll info. // schedule, locked image, current image, or pending poll info.
// Schedule reset matches the new-device default (noon-daily in
// the new owner's timezone) so the new owner gets the same
// forgiving first-image behavior.
$device->setLockedImage(null); $device->setLockedImage(null);
$device->setCurrentImage(null); $device->setCurrentImage(null);
$device->setCurrentImageOrientation(null); $device->setCurrentImageOrientation(null);
$device->setCurrentRenderedAt(null); $device->setCurrentRenderedAt(null);
$device->setNextPollExpectedAt(null); $device->setNextPollExpectedAt(null);
$device->setName(''); $device->setName('');
$device->setWakeTimes([]); $device->setTimezone($newOwner->getTimezone() ?: 'UTC');
$device->setWakeTimes([12 * 60]);
} }
$device->setUser($newOwner); $device->setUser($newOwner);
@@ -266,7 +266,7 @@ class SetupControllerTest extends AppWebTestCase
$reloaded = $this->em()->find(Device::class, $deviceId); $reloaded = $this->em()->find(Device::class, $deviceId);
$this->assertSame('new-claim03@example.com', $reloaded->getUser()->getEmail(), 'ownership transferred'); $this->assertSame('new-claim03@example.com', $reloaded->getUser()->getEmail(), 'ownership transferred');
$this->assertSame('', $reloaded->getName(), 'name reset on takeover'); $this->assertSame('', $reloaded->getName(), 'name reset on takeover');
$this->assertSame([], $reloaded->getWakeTimes(), 'wakeTimes reset on takeover'); $this->assertSame([12 * 60], $reloaded->getWakeTimes(), 'wakeTimes reset to noon-daily default');
// Old history is gone. // Old history is gone.
$count = (int) $this->em()->createQueryBuilder() $count = (int) $this->em()->createQueryBuilder()
+30 -1
View File
@@ -119,6 +119,8 @@ class DeviceServiceTest extends AppKernelTestCase
{ {
$oldOwner = $this->createUser('takeover-old@example.com'); $oldOwner = $this->createUser('takeover-old@example.com');
$newOwner = $this->createUser('takeover-new@example.com'); $newOwner = $this->createUser('takeover-new@example.com');
$newOwner->setTimezone('America/Chicago');
$this->em()->flush();
$device = new Device(); $device = new Device();
$device->setMac('AA:BB:CC:DD:EE:06'); $device->setMac('AA:BB:CC:DD:EE:06');
@@ -132,8 +134,35 @@ class DeviceServiceTest extends AppKernelTestCase
$this->em()->refresh($device); $this->em()->refresh($device);
$this->assertSame('', $device->getName(), 'name reset'); $this->assertSame('', $device->getName(), 'name reset');
$this->assertSame([], $device->getWakeTimes(), 'wakeTimes reset'); $this->assertSame([12 * 60], $device->getWakeTimes(), 'wakeTimes default to noon-daily');
$this->assertSame('America/Chicago', $device->getTimezone(), 'timezone matches new owner');
$this->assertNull($device->getCurrentImage(), 'currentImage reset'); $this->assertNull($device->getCurrentImage(), 'currentImage reset');
$this->assertNull($device->getNextPollExpectedAt(), 'next-poll reset'); $this->assertNull($device->getNextPollExpectedAt(), 'next-poll reset');
} }
public function test_link_creates_new_device_with_noon_daily_default(): void
{
$user = $this->createUser('new-device@example.com');
$user->setTimezone('Europe/Stockholm');
$this->em()->flush();
$device = $this->service->linkToUser('AA:BB:CC:DD:EE:09', $user);
$this->assertSame([12 * 60], $device->getWakeTimes(), 'noon-daily default');
$this->assertSame('Europe/Stockholm', $device->getTimezone(), 'inherits user tz');
}
public function test_link_falls_back_to_UTC_when_user_has_no_timezone(): void
{
$user = $this->createUser('no-tz@example.com');
// Force the user's timezone to empty to exercise the fallback —
// some legacy User rows may have NULL/blank timezone.
$ref = new \ReflectionProperty(\App\Entity\User::class, 'timezone');
$ref->setAccessible(true);
$ref->setValue($user, '');
$this->em()->flush();
$device = $this->service->linkToUser('AA:BB:CC:DD:EE:0A', $user);
$this->assertSame('UTC', $device->getTimezone());
}
} }