forked from mirror/crumbs-original
feat: checklist utility module with parentId and nesting logic
Extract checklist logic into testable utility module: - ChecklistItem type with parentId field for single-level nesting - parseChecklist/serializeChecklist with 2-space prefix for children - toggleItemWithCascade: parent check/uncheck cascades to children - indentItem/outdentItem with sibling adoption rules - 21 unit tests covering parse, serialize, toggle, indent, outdent Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
8155bd4ff5
commit
9fb8acc719
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, type ChecklistItem } from '$lib/utils/checklist.js';
|
||||
|
||||
describe('parseChecklist', () => {
|
||||
it('parses flat checklist items', () => {
|
||||
const items = parseChecklist('- [ ] Milk\n- [x] Eggs');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({ text: 'Milk', checked: false, parentId: null });
|
||||
expect(items[1]).toMatchObject({ text: 'Eggs', checked: true, parentId: null });
|
||||
});
|
||||
|
||||
it('parses nested children with 2-space prefix', () => {
|
||||
const items = parseChecklist('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs');
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0]).toMatchObject({ text: 'Groceries', checked: false, parentId: null });
|
||||
expect(items[1]).toMatchObject({ text: 'Milk', checked: false, parentId: items[0].id });
|
||||
expect(items[2]).toMatchObject({ text: 'Eggs', checked: true, parentId: items[0].id });
|
||||
});
|
||||
|
||||
it('treats orphaned children as top-level', () => {
|
||||
const items = parseChecklist(' - [ ] Orphan\n- [ ] Parent');
|
||||
expect(items[0]).toMatchObject({ text: 'Orphan', parentId: null });
|
||||
});
|
||||
|
||||
it('returns one empty item for blank content', () => {
|
||||
const items = parseChecklist('');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ text: '', checked: false, parentId: null });
|
||||
});
|
||||
|
||||
it('handles mixed top-level and nested items', () => {
|
||||
const content = '- [ ] A\n - [ ] A1\n - [ ] A2\n- [ ] B\n - [x] B1';
|
||||
const items = parseChecklist(content);
|
||||
expect(items).toHaveLength(5);
|
||||
expect(items[1].parentId).toBe(items[0].id);
|
||||
expect(items[2].parentId).toBe(items[0].id);
|
||||
expect(items[4].parentId).toBe(items[3].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeChecklist', () => {
|
||||
it('serializes flat items', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Milk', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Eggs', checked: true, parentId: null }
|
||||
];
|
||||
expect(serializeChecklist(items)).toBe('- [ ] Milk\n- [x] Eggs');
|
||||
});
|
||||
|
||||
it('serializes nested items with 2-space prefix', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Groceries', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Milk', checked: false, parentId: '1' },
|
||||
{ id: '3', text: 'Eggs', checked: true, parentId: '1' }
|
||||
];
|
||||
expect(serializeChecklist(items)).toBe('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs');
|
||||
});
|
||||
|
||||
it('groups children under parents regardless of array order', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '3', text: 'B', checked: false, parentId: null },
|
||||
{ id: '2', text: 'A1', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = serializeChecklist(items);
|
||||
expect(result).toBe('- [ ] A\n - [ ] A1\n- [ ] B');
|
||||
});
|
||||
|
||||
it('round-trips parse then serialize', () => {
|
||||
const original = '- [ ] Groceries\n - [ ] Milk\n - [x] Eggs\n- [ ] Clean';
|
||||
const items = parseChecklist(original);
|
||||
expect(serializeChecklist(items)).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleItemWithCascade', () => {
|
||||
it('checking a parent checks all children', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Child A', checked: false, parentId: '1' },
|
||||
{ id: '3', text: 'Child B', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = toggleItemWithCascade('1', items);
|
||||
expect(result.every((i) => i.checked)).toBe(true);
|
||||
});
|
||||
|
||||
it('unchecking a parent unchecks all children', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: true, parentId: null },
|
||||
{ id: '2', text: 'Child A', checked: true, parentId: '1' },
|
||||
{ id: '3', text: 'Child B', checked: true, parentId: '1' }
|
||||
];
|
||||
const result = toggleItemWithCascade('1', items);
|
||||
expect(result.every((i) => !i.checked)).toBe(true);
|
||||
});
|
||||
|
||||
it('checking a child does not affect parent', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'Parent', checked: false, parentId: null },
|
||||
{ id: '2', text: 'Child', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = toggleItemWithCascade('2', items);
|
||||
expect(result.find((i) => i.id === '1')!.checked).toBe(false);
|
||||
expect(result.find((i) => i.id === '2')!.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('unchecking a child with deleted parent orphans it', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '2', text: 'Orphan', checked: true, parentId: 'deleted-id' }
|
||||
];
|
||||
const result = toggleItemWithCascade('2', items);
|
||||
expect(result[0].checked).toBe(false);
|
||||
expect(result[0].parentId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('indentItem', () => {
|
||||
it('sets parentId to nearest preceding top-level item', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: null }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('2', items, active);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBe('1');
|
||||
});
|
||||
|
||||
it('no-op if already a child', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('2', items, active);
|
||||
expect(result).toEqual(items);
|
||||
});
|
||||
|
||||
it('no-op if first item (no parent above)', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('1', items, active);
|
||||
expect(result[0].parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('reparents children to grandparent when indenting a parent', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: null },
|
||||
{ id: '3', text: 'C', checked: false, parentId: '2' },
|
||||
{ id: '4', text: 'D', checked: false, parentId: '2' }
|
||||
];
|
||||
const active = items.filter(i => !i.checked);
|
||||
const result = indentItem('2', items, active);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBe('1');
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
|
||||
expect(result.find((i) => i.id === '4')!.parentId).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outdentItem', () => {
|
||||
it('sets parentId to null', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = outdentItem('2', items);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('no-op if already top-level', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null }
|
||||
];
|
||||
const result = outdentItem('1', items);
|
||||
expect(result[0].parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('adopts consecutive siblings below with same parentId', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' },
|
||||
{ id: '3', text: 'C', checked: false, parentId: '1' },
|
||||
{ id: '4', text: 'D', checked: false, parentId: '1' },
|
||||
{ id: '5', text: 'E', checked: false, parentId: null }
|
||||
];
|
||||
const result = outdentItem('2', items);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('2');
|
||||
expect(result.find((i) => i.id === '4')!.parentId).toBe('2');
|
||||
expect(result.find((i) => i.id === '5')!.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('stops adoption at first non-sibling', () => {
|
||||
const items: ChecklistItem[] = [
|
||||
{ id: '1', text: 'A', checked: false, parentId: null },
|
||||
{ id: '2', text: 'B', checked: false, parentId: '1' },
|
||||
{ id: '5', text: 'E', checked: false, parentId: null },
|
||||
{ id: '3', text: 'C', checked: false, parentId: '1' }
|
||||
];
|
||||
const result = outdentItem('2', items);
|
||||
expect(result.find((i) => i.id === '2')!.parentId).toBeNull();
|
||||
expect(result.find((i) => i.id === '5')!.parentId).toBeNull();
|
||||
expect(result.find((i) => i.id === '3')!.parentId).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
export interface ChecklistItem {
|
||||
id: string;
|
||||
text: string;
|
||||
checked: boolean;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function isChild(item: ChecklistItem): boolean {
|
||||
return item.parentId !== null;
|
||||
}
|
||||
|
||||
export function getChildren(parentId: string, items: ChecklistItem[]): ChecklistItem[] {
|
||||
return items.filter((i) => i.parentId === parentId);
|
||||
}
|
||||
|
||||
export function getParent(item: ChecklistItem, items: ChecklistItem[]): ChecklistItem | undefined {
|
||||
return item.parentId ? items.find((i) => i.id === item.parentId) : undefined;
|
||||
}
|
||||
|
||||
export function toggleItemWithCascade(id: string, items: ChecklistItem[]): ChecklistItem[] {
|
||||
const target = items.find((i) => i.id === id)!;
|
||||
const newChecked = !target.checked;
|
||||
|
||||
let result = items.map((item) => {
|
||||
if (item.id === id) return { ...item, checked: newChecked };
|
||||
if (item.parentId === id) return { ...item, checked: newChecked };
|
||||
return item;
|
||||
});
|
||||
|
||||
// When unchecking a child from done: if parent no longer exists, orphan it
|
||||
if (!newChecked && target.parentId) {
|
||||
const parentExists = result.some((i) => i.id === target.parentId);
|
||||
if (!parentExists) {
|
||||
result = result.map((i) => (i.id === id ? { ...i, parentId: null } : i));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function indentItem(id: string, items: ChecklistItem[], activeItems: ChecklistItem[]): ChecklistItem[] {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || item.parentId !== null) return items;
|
||||
|
||||
const activeIndex = activeItems.findIndex((i) => i.id === id);
|
||||
let newParentId: string | null = null;
|
||||
for (let i = activeIndex - 1; i >= 0; i--) {
|
||||
if (activeItems[i].parentId === null) {
|
||||
newParentId = activeItems[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!newParentId) return items;
|
||||
|
||||
return items.map((i) => {
|
||||
if (i.id === id) return { ...i, parentId: newParentId };
|
||||
if (i.parentId === id) return { ...i, parentId: newParentId };
|
||||
return i;
|
||||
});
|
||||
}
|
||||
|
||||
export function outdentItem(id: string, items: ChecklistItem[]): ChecklistItem[] {
|
||||
const index = items.findIndex((i) => i.id === id);
|
||||
const item = items[index];
|
||||
if (!item || item.parentId === null) return items;
|
||||
|
||||
const originalParentId = item.parentId;
|
||||
|
||||
const adoptees: string[] = [];
|
||||
for (let i = index + 1; i < items.length; i++) {
|
||||
if (items[i].parentId === originalParentId) {
|
||||
adoptees.push(items[i].id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return items.map((i) => {
|
||||
if (i.id === id) return { ...i, parentId: null };
|
||||
if (adoptees.includes(i.id)) return { ...i, parentId: id };
|
||||
return i;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChecklist(text: string): ChecklistItem[] {
|
||||
if (!text.trim()) return [{ id: generateId(), text: '', checked: false, parentId: null }];
|
||||
|
||||
let lastTopLevelId: string | null = null;
|
||||
return text.split('\n').map((line) => {
|
||||
if (line.startsWith(' - [x] ')) {
|
||||
return { id: generateId(), text: line.slice(8), checked: true, parentId: lastTopLevelId };
|
||||
}
|
||||
if (line.startsWith(' - [ ] ')) {
|
||||
return { id: generateId(), text: line.slice(8), checked: false, parentId: lastTopLevelId };
|
||||
}
|
||||
const item: ChecklistItem = { id: generateId(), text: '', checked: false, parentId: null };
|
||||
if (line.startsWith('- [x] ')) {
|
||||
item.text = line.slice(6);
|
||||
item.checked = true;
|
||||
} else if (line.startsWith('- [ ] ')) {
|
||||
item.text = line.slice(6);
|
||||
} else {
|
||||
item.text = line;
|
||||
}
|
||||
lastTopLevelId = item.id;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export function serializeChecklist(items: ChecklistItem[]): string {
|
||||
const topLevel = items.filter((i) => i.parentId === null);
|
||||
const childMap = new Map<string, ChecklistItem[]>();
|
||||
for (const item of items) {
|
||||
if (item.parentId) {
|
||||
const siblings = childMap.get(item.parentId) ?? [];
|
||||
siblings.push(item);
|
||||
childMap.set(item.parentId, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const parent of topLevel) {
|
||||
lines.push(`- [${parent.checked ? 'x' : ' '}] ${parent.text}`);
|
||||
const children = childMap.get(parent.id) ?? [];
|
||||
for (const child of children) {
|
||||
lines.push(` - [${child.checked ? 'x' : ' '}] ${child.text}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user