From ad7dae5a5e4608ef03e8e57044bf81b2e0142636 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Thu, 16 Apr 2026 11:30:14 +0300 Subject: [PATCH] feat: allow adding one-off list items --- lib/Controller/ChecklistController.php | 12 ++++- lib/Db/ChecklistItem.php | 6 +++ lib/Migration/Version3Date20260416000000.php | 42 ++++++++++++++++ lib/ResponseDefinitions.php | 1 + lib/Service/ChecklistService.php | 11 ++++ openapi.json | 15 ++++++ src/api/lists.ts | 1 + src/api/types.ts | 1 + .../ChecklistAddForm/ChecklistAddForm.test.ts | 35 +++++++++++++ .../ChecklistAddForm/ChecklistAddForm.vue | 25 ++++++++-- .../ChecklistImagePreview.test.ts | 1 + .../ChecklistItemEditDialog.test.ts | 32 ++++++++++++ .../ChecklistItemEditDialog.vue | 38 ++++++++++++-- .../ChecklistItemRow/ChecklistItemRow.test.ts | 1 + .../ChecklistItemViewDialog.test.ts | 1 + src/composables/useChecklist.test.ts | 46 +++++++++++++++++ src/composables/useChecklist.ts | 20 ++++++-- tests/unit/Service/ChecklistServiceTest.php | 50 +++++++++++++++++++ 18 files changed, 325 insertions(+), 13 deletions(-) create mode 100644 lib/Migration/Version3Date20260416000000.php diff --git a/lib/Controller/ChecklistController.php b/lib/Controller/ChecklistController.php index df34843..c2453b5 100644 --- a/lib/Controller/ChecklistController.php +++ b/lib/Controller/ChecklistController.php @@ -211,6 +211,7 @@ final class ChecklistController extends OCSController { * @param string|null $quantity Optional quantity string. * @param string|null $rrule Optional RFC 5545 RRULE for recurrence. * @param bool $repeatFromCompletion If true, the next occurrence is measured from when the item is marked done; if false, the schedule is anchored at item creation. + * @param bool $deleteOnDone If true, the item is deleted when marked done. * @param int|null $sortOrder Optional sort order. * * @return DataResponse @@ -228,9 +229,10 @@ final class ChecklistController extends OCSController { ?string $quantity = null, ?string $rrule = null, bool $repeatFromCompletion = false, + bool $deleteOnDone = false, ?int $sortOrder = null, ): DataResponse { - return $this->runAction(function () use ($houseId, $listId, $name, $description, $categoryId, $quantity, $rrule, $repeatFromCompletion, $sortOrder): DataResponse { + return $this->runAction(function () use ($houseId, $listId, $name, $description, $categoryId, $quantity, $rrule, $repeatFromCompletion, $deleteOnDone, $sortOrder): DataResponse { $this->auth->requireMember($houseId, $this->requireUid()); $list = $this->lists->getList($listId); $this->assertListInHouse($list->getHouseId(), $houseId); @@ -244,6 +246,7 @@ final class ChecklistController extends OCSController { 'quantity' => $quantity, 'rrule' => $rrule, 'repeatFromCompletion' => $repeatFromCompletion, + 'deleteOnDone' => $deleteOnDone, 'sortOrder' => $sortOrder ?? 0, ]); $this->notifications->notifyItemAdded($houseId, $this->requireUid(), $item->getName(), $list->getName()); @@ -263,6 +266,7 @@ final class ChecklistController extends OCSController { * @param string|null $quantity New quantity (empty string clears). * @param string|null $rrule New RRULE (empty string clears). * @param bool|null $repeatFromCompletion New recurrence anchor mode. + * @param bool|null $deleteOnDone If true, the item is deleted when marked done. * @param int|null $imageFileId File id of attached image (0 or negative clears). * @param int|null $sortOrder New sort order. * @param int|null $targetListId Move item to a different list (must belong to the same house). @@ -283,11 +287,12 @@ final class ChecklistController extends OCSController { ?string $quantity = null, ?string $rrule = null, ?bool $repeatFromCompletion = null, + ?bool $deleteOnDone = null, ?int $imageFileId = null, ?int $sortOrder = null, ?int $targetListId = null, ): DataResponse { - return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $description, $categoryId, $quantity, $rrule, $repeatFromCompletion, $imageFileId, $sortOrder, $targetListId): DataResponse { + return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $description, $categoryId, $quantity, $rrule, $repeatFromCompletion, $deleteOnDone, $imageFileId, $sortOrder, $targetListId): DataResponse { $this->auth->requireMember($houseId, $this->requireUid()); $item = $this->lists->getItem($itemId); $list = $this->lists->getList($item->getListId()); @@ -319,6 +324,9 @@ final class ChecklistController extends OCSController { if ($repeatFromCompletion !== null) { $patch['repeatFromCompletion'] = $repeatFromCompletion; } + if ($deleteOnDone !== null) { + $patch['deleteOnDone'] = $deleteOnDone; + } if ($imageFileId !== null) { $patch['imageFileId'] = $imageFileId > 0 ? $imageFileId : null; } diff --git a/lib/Db/ChecklistItem.php b/lib/Db/ChecklistItem.php index d925c10..3f7122c 100644 --- a/lib/Db/ChecklistItem.php +++ b/lib/Db/ChecklistItem.php @@ -30,6 +30,8 @@ use OCP\AppFramework\Db\Entity; * @method void setRrule(?string $rrule) * @method bool getRepeatFromCompletion() * @method void setRepeatFromCompletion(bool $repeatFromCompletion) + * @method bool getDeleteOnDone() + * @method void setDeleteOnDone(bool $deleteOnDone) * @method int|null getNextDueAt() * @method void setNextDueAt(?int $nextDueAt) * @method int|null getImageFileId() @@ -54,6 +56,7 @@ class ChecklistItem extends Entity implements \JsonSerializable { protected ?string $doneBy = null; protected ?string $rrule = null; protected bool $repeatFromCompletion = false; + protected bool $deleteOnDone = false; protected ?int $nextDueAt = null; protected ?int $imageFileId = null; protected ?string $imageUploadedBy = null; @@ -67,6 +70,7 @@ class ChecklistItem extends Entity implements \JsonSerializable { $this->addType('done', 'boolean'); $this->addType('doneAt', 'integer'); $this->addType('repeatFromCompletion', 'boolean'); + $this->addType('deleteOnDone', 'boolean'); $this->addType('nextDueAt', 'integer'); $this->addType('imageFileId', 'integer'); $this->addType('sortOrder', 'integer'); @@ -78,6 +82,7 @@ class ChecklistItem extends Entity implements \JsonSerializable { // fromRow() resets updated fields after hydration, so reads are unaffected. $this->markFieldUpdated('done'); $this->markFieldUpdated('repeatFromCompletion'); + $this->markFieldUpdated('deleteOnDone'); } public function jsonSerialize(): array { @@ -93,6 +98,7 @@ class ChecklistItem extends Entity implements \JsonSerializable { 'doneBy' => $this->doneBy, 'rrule' => $this->rrule, 'repeatFromCompletion' => $this->repeatFromCompletion, + 'deleteOnDone' => $this->deleteOnDone, 'nextDueAt' => $this->nextDueAt, 'imageFileId' => $this->imageFileId, 'imageUploadedBy' => $this->imageUploadedBy, diff --git a/lib/Migration/Version3Date20260416000000.php b/lib/Migration/Version3Date20260416000000.php new file mode 100644 index 0000000..938ef98 --- /dev/null +++ b/lib/Migration/Version3Date20260416000000.php @@ -0,0 +1,42 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Pantry\Migration; + +use Closure; +use OCA\Pantry\AppInfo\Application; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Add delete_on_done flag to checklist items ("Once" items that are removed + * from the list when marked done). + */ +class Version3Date20260416000000 extends SimpleMigrationStep { + /** + * @param Closure():ISchemaWrapper $schemaClosure + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $itemsTable = Application::tableName('list_items'); + if ($schema->hasTable($itemsTable)) { + $table = $schema->getTable($itemsTable); + if (!$table->hasColumn('delete_on_done')) { + $table->addColumn('delete_on_done', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + } + } + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index ef976a9..1d5c43f 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -50,6 +50,7 @@ namespace OCA\Pantry; * doneBy: string|null, * rrule: string|null, * repeatFromCompletion: bool, + * deleteOnDone: bool, * nextDueAt: int|null, * imageFileId: int|null, * imageUploadedBy: string|null, diff --git a/lib/Service/ChecklistService.php b/lib/Service/ChecklistService.php index f880689..4e605fa 100644 --- a/lib/Service/ChecklistService.php +++ b/lib/Service/ChecklistService.php @@ -143,6 +143,7 @@ class ChecklistService { $item->setRrule($rrule); $repeatFromCompletion = !empty($data['repeatFromCompletion']); $item->setRepeatFromCompletion($repeatFromCompletion); + $item->setDeleteOnDone(!empty($data['deleteOnDone'])); // For fixed-schedule items, compute the first due time immediately. if ($rrule !== null && !$repeatFromCompletion) { $item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp()); @@ -194,6 +195,9 @@ class ChecklistService { if (array_key_exists('repeatFromCompletion', $patch)) { $item->setRepeatFromCompletion((bool)$patch['repeatFromCompletion']); } + if (array_key_exists('deleteOnDone', $patch)) { + $item->setDeleteOnDone((bool)$patch['deleteOnDone']); + } if (array_key_exists('imageFileId', $patch)) { $item->setImageFileId($this->intOrNull($patch['imageFileId'])); } @@ -256,6 +260,13 @@ class ChecklistService { $item->setDone(true); $item->setDoneAt($now); $item->setDoneBy($uid); + // "Once" items are removed from the list when marked done. We still + // return the transient done state so callers (notifications, API + // response) can reflect the completion before the row is gone. + if ($item->getDeleteOnDone()) { + $this->itemMapper->delete($item); + return $item; + } if ($item->getRrule() !== null) { $item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp()); } diff --git a/openapi.json b/openapi.json index d40814b..cc1d3bb 100644 --- a/openapi.json +++ b/openapi.json @@ -295,6 +295,7 @@ "doneBy", "rrule", "repeatFromCompletion", + "deleteOnDone", "nextDueAt", "imageFileId", "imageUploadedBy", @@ -346,6 +347,9 @@ "repeatFromCompletion": { "type": "boolean" }, + "deleteOnDone": { + "type": "boolean" + }, "nextDueAt": { "type": "integer", "format": "int64", @@ -1950,6 +1954,11 @@ "default": false, "description": "If true, the next occurrence is measured from when the item is marked done; if false, the schedule is anchored at item creation." }, + "deleteOnDone": { + "type": "boolean", + "default": false, + "description": "If true, the item is deleted when marked done." + }, "sortOrder": { "type": "integer", "format": "int64", @@ -2115,6 +2124,12 @@ "default": null, "description": "New recurrence anchor mode." }, + "deleteOnDone": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "If true, the item is deleted when marked done." + }, "imageFileId": { "type": "integer", "format": "int64", diff --git a/src/api/lists.ts b/src/api/lists.ts index 8603745..028747e 100644 --- a/src/api/lists.ts +++ b/src/api/lists.ts @@ -56,6 +56,7 @@ export interface ItemInput { quantity?: string | null rrule?: string | null repeatFromCompletion?: boolean + deleteOnDone?: boolean sortOrder?: number targetListId?: number } diff --git a/src/api/types.ts b/src/api/types.ts index 5376213..bada71a 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -53,6 +53,7 @@ export interface ChecklistItem { doneBy: string | null rrule: string | null repeatFromCompletion: boolean + deleteOnDone: boolean nextDueAt: number | null imageFileId: number | null imageUploadedBy: string | null diff --git a/src/components/ChecklistAddForm/ChecklistAddForm.test.ts b/src/components/ChecklistAddForm/ChecklistAddForm.test.ts index 44c1f41..ad5959a 100644 --- a/src/components/ChecklistAddForm/ChecklistAddForm.test.ts +++ b/src/components/ChecklistAddForm/ChecklistAddForm.test.ts @@ -25,6 +25,15 @@ vi.mock('@nextcloud/vue/components/NcTextField', () => ({ emits: ['update:modelValue'], }, })) +vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => ({ + default: { + name: 'NcCheckboxRadioSwitch', + template: + '', + props: ['modelValue'], + emits: ['update:modelValue'], + }, +})) vi.mock('@/components/AutoResizeTextarea', () => ({ AutoResizeTextarea: { name: 'AutoResizeTextarea', @@ -112,9 +121,35 @@ describe('ChecklistAddForm', () => { categoryId: null, rrule: null, repeatFromCompletion: false, + deleteOnDone: false, }) }) + it('emits deleteOnDone=true when the "Once" checkbox is ticked', async () => { + const wrapper = mountForm() + await wrapper.findAll('.nc-text-field').at(0)!.setValue('Milk') + + const onceCheckbox = wrapper.find('.nc-checkbox input[type="checkbox"]') + await onceCheckbox.setValue(true) + + await wrapper.find('form').trigger('submit') + + const payload = wrapper.emitted('add')![0][0] + expect(payload.deleteOnDone).toBe(true) + }) + + it('resets the "Once" checkbox after submit', async () => { + const wrapper = mountForm() + await wrapper.findAll('.nc-text-field').at(0)!.setValue('Milk') + const onceCheckbox = wrapper.find('.nc-checkbox input[type="checkbox"]') + await onceCheckbox.setValue(true) + + await wrapper.find('form').trigger('submit') + + const after = wrapper.find('.nc-checkbox input[type="checkbox"]').element as HTMLInputElement + expect(after.checked).toBe(false) + }) + it('resets all fields after submit', async () => { const wrapper = mountForm() const textFields = wrapper.findAll('.nc-text-field') diff --git a/src/components/ChecklistAddForm/ChecklistAddForm.vue b/src/components/ChecklistAddForm/ChecklistAddForm.vue index 2f534e5..262b0e0 100644 --- a/src/components/ChecklistAddForm/ChecklistAddForm.vue +++ b/src/components/ChecklistAddForm/ChecklistAddForm.vue @@ -17,7 +17,12 @@ :house-id="houseId" :placeholder="strings.categoryPlaceholder" /> - +
+ + {{ strings.once }} + +
+ @@ -63,6 +68,7 @@ import { ref } from 'vue' import { t } from '@nextcloud/l10n' import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import NcTextField from '@nextcloud/vue/components/NcTextField' import PlusIcon from '@icons/Plus.vue' import RepeatIcon from '@icons/Repeat.vue' @@ -87,19 +93,23 @@ const quantity = ref('') const categoryId = ref(null) const rrule = ref(null) const repeatFromCompletion = ref(false) +const deleteOnDone = ref(false) const showDescription = ref(false) const showRecurrenceEditor = ref(false) function submitAdd() { const trimmedName = name.value.trim() if (!trimmedName) return + // "Once" items can't recur — ignore any locally-retained recurrence settings. + const once = deleteOnDone.value emit('add', { name: trimmedName, description: description.value.trim() || null, quantity: quantity.value.trim() || null, categoryId: categoryId.value, - rrule: rrule.value, - repeatFromCompletion: repeatFromCompletion.value, + rrule: once ? null : rrule.value, + repeatFromCompletion: once ? false : repeatFromCompletion.value, + deleteOnDone: once, }) name.value = '' description.value = '' @@ -107,6 +117,7 @@ function submitAdd() { categoryId.value = null rrule.value = null repeatFromCompletion.value = false + deleteOnDone.value = false showDescription.value = false } @@ -119,6 +130,8 @@ const strings = { categoryPlaceholder: t('pantry', 'Category'), recurrenceButton: t('pantry', 'Repeat …'), recurrenceSet: t('pantry', 'Repeat: set'), + once: t('pantry', 'Once'), + onceHint: t('pantry', 'Delete this item once it is marked as done.'), descriptionLabel: t('pantry', 'Description'), descriptionPlaceholder: t('pantry', 'Add a description …'), descriptionToggle: t('pantry', 'Toggle description'), @@ -128,7 +141,7 @@ const strings = { diff --git a/src/components/ChecklistItemRow/ChecklistItemRow.test.ts b/src/components/ChecklistItemRow/ChecklistItemRow.test.ts index 489c48f..2c324ac 100644 --- a/src/components/ChecklistItemRow/ChecklistItemRow.test.ts +++ b/src/components/ChecklistItemRow/ChecklistItemRow.test.ts @@ -75,6 +75,7 @@ function makeItem(overrides: Partial = {}): ChecklistItem { doneBy: null, rrule: null, repeatFromCompletion: false, + deleteOnDone: false, nextDueAt: null, imageFileId: null, imageUploadedBy: null, diff --git a/src/components/ChecklistItemViewDialog/ChecklistItemViewDialog.test.ts b/src/components/ChecklistItemViewDialog/ChecklistItemViewDialog.test.ts index 54c7dc9..561f451 100644 --- a/src/components/ChecklistItemViewDialog/ChecklistItemViewDialog.test.ts +++ b/src/components/ChecklistItemViewDialog/ChecklistItemViewDialog.test.ts @@ -64,6 +64,7 @@ function makeItem(overrides: Partial = {}): ChecklistItem { doneBy: null, rrule: null, repeatFromCompletion: false, + deleteOnDone: false, nextDueAt: null, imageFileId: null, imageUploadedBy: null, diff --git a/src/composables/useChecklist.test.ts b/src/composables/useChecklist.test.ts index 693bd2b..f65f10b 100644 --- a/src/composables/useChecklist.test.ts +++ b/src/composables/useChecklist.test.ts @@ -48,6 +48,7 @@ function makeItem(overrides: Partial = {}): ChecklistItem { doneBy: null, rrule: null, repeatFromCompletion: false, + deleteOnDone: false, nextDueAt: null, imageFileId: null, imageUploadedBy: null, @@ -286,6 +287,51 @@ describe('useChecklistItems', () => { await expect(c.toggle(1)).rejects.toThrow('fail') expect(c.items.value[0].done).toBe(false) }) + + it('removes a "Once" item from local state when marked done', async () => { + const once = makeItem({ id: 1, done: false, deleteOnDone: true }) + const other = makeItem({ id: 2, done: false }) + mockApi.listItems.mockResolvedValue([once, other]) + // Backend returns the (now-deleted) item flagged as done. + mockApi.toggleItem.mockResolvedValue({ ...once, done: true }) + + const c = useChecklistItems(1, 10) + await c.load() + + // Optimistic removal — the row is gone immediately. + const togglePromise = c.toggle(1) + expect(c.items.value.map((i) => i.id)).toEqual([2]) + + await togglePromise + // And it stays gone after the server confirms. + expect(c.items.value.map((i) => i.id)).toEqual([2]) + }) + + it('restores a "Once" item on server failure when marking done', async () => { + const once = makeItem({ id: 1, done: false, deleteOnDone: true }) + mockApi.listItems.mockResolvedValue([once]) + mockApi.toggleItem.mockRejectedValue(new Error('fail')) + + const c = useChecklistItems(1, 10) + await c.load() + + await expect(c.toggle(1)).rejects.toThrow('fail') + expect(c.items.value.map((i) => i.id)).toEqual([1]) + expect(c.items.value[0].done).toBe(false) + }) + + it('does not delete a "Once" item when unchecking (done → not done)', async () => { + const once = makeItem({ id: 1, done: true, deleteOnDone: true }) + mockApi.listItems.mockResolvedValue([once]) + mockApi.toggleItem.mockResolvedValue({ ...once, done: false }) + + const c = useChecklistItems(1, 10) + await c.load() + + await c.toggle(1) + expect(c.items.value.map((i) => i.id)).toEqual([1]) + expect(c.items.value[0].done).toBe(false) + }) }) describe('remove', () => { diff --git a/src/composables/useChecklist.ts b/src/composables/useChecklist.ts index e2089c0..35c0e57 100644 --- a/src/composables/useChecklist.ts +++ b/src/composables/useChecklist.ts @@ -108,18 +108,30 @@ export function useChecklistItems(houseId: number, listId: number) { } async function toggle(itemId: number): Promise { - // Optimistic flip. + // Optimistic flip. "Once" items disappear when marked done — the backend + // deletes them in the same call, so we drop them locally too. const prev = items.value.find((i) => i.id === itemId) + const willDelete = !!prev && !prev.done && prev.deleteOnDone if (prev) { - items.value = items.value.map((i) => (i.id === itemId ? { ...i, done: !i.done } : i)) + if (willDelete) { + items.value = items.value.filter((i) => i.id !== itemId) + } else { + items.value = items.value.map((i) => (i.id === itemId ? { ...i, done: !i.done } : i)) + } } try { const updated = await api.toggleItem(houseId, listId, itemId) - items.value = items.value.map((i) => (i.id === itemId ? updated : i)) + if (!willDelete) { + items.value = items.value.map((i) => (i.id === itemId ? updated : i)) + } } catch (e) { // Roll back on failure. if (prev) { - items.value = items.value.map((i) => (i.id === itemId ? prev : i)) + if (willDelete) { + items.value = [...items.value, prev] + } else { + items.value = items.value.map((i) => (i.id === itemId ? prev : i)) + } } throw e } diff --git a/tests/unit/Service/ChecklistServiceTest.php b/tests/unit/Service/ChecklistServiceTest.php index 60164c2..1929f4b 100644 --- a/tests/unit/Service/ChecklistServiceTest.php +++ b/tests/unit/Service/ChecklistServiceTest.php @@ -44,6 +44,7 @@ class ChecklistServiceTest extends TestCase { $item->setDoneBy($overrides['doneBy'] ?? null); $item->setRrule($overrides['rrule'] ?? null); $item->setRepeatFromCompletion($overrides['repeatFromCompletion'] ?? false); + $item->setDeleteOnDone($overrides['deleteOnDone'] ?? false); $item->setNextDueAt($overrides['nextDueAt'] ?? null); $item->setSortOrder($overrides['sortOrder'] ?? 0); $item->setCreatedAt($overrides['createdAt'] ?? 0); @@ -161,4 +162,53 @@ class ChecklistServiceTest extends TestCase { $this->expectException(\InvalidArgumentException::class); $this->svc->addItem(1, ['name' => 'Eggs', 'rrule' => 'not valid']); } + + public function testToggleItemDeletesOnceItemWhenMarkingDone(): void { + $now = 1_700_000_000; + $item = $this->makeItem([ + 'deleteOnDone' => true, + ]); + $this->itemMapper->method('findById')->willReturn($item); + // Once items are deleted rather than updated when marked done. + $this->itemMapper->expects($this->once())->method('delete')->with($item); + $this->itemMapper->expects($this->never())->method('update'); + + $toggled = $this->svc->toggleItem(42, 'alice', $now); + $this->assertTrue($toggled->getDone()); + $this->assertSame($now, $toggled->getDoneAt()); + $this->assertSame('alice', $toggled->getDoneBy()); + } + + public function testToggleItemOnceItemIgnoresFlagWhenUnchecking(): void { + // An already-done once item can still be unchecked (e.g., via an + // already-cached client) — the flag only triggers on the done transition. + $item = $this->makeItem([ + 'done' => true, + 'doneAt' => 123, + 'doneBy' => 'alice', + 'deleteOnDone' => true, + ]); + $this->itemMapper->method('findById')->willReturn($item); + $this->itemMapper->expects($this->once())->method('update')->willReturn($item); + $this->itemMapper->expects($this->never())->method('delete'); + + $toggled = $this->svc->toggleItem(42, 'alice', 999); + $this->assertFalse($toggled->getDone()); + $this->assertNull($toggled->getDoneAt()); + $this->assertNull($toggled->getDoneBy()); + } + + public function testAddItemStoresDeleteOnDoneFlag(): void { + $this->listMapper->method('findById')->willReturn(new Checklist()); + $captured = null; + $this->itemMapper->method('insert') + ->willReturnCallback(function (ChecklistItem $i) use (&$captured) { + $captured = $i; + return $i; + }); + + $this->svc->addItem(1, ['name' => 'Lightbulb', 'deleteOnDone' => true]); + $this->assertNotNull($captured); + $this->assertTrue($captured->getDeleteOnDone()); + } }