should finally work kinda

This commit is contained in:
2026-07-17 11:39:20 -07:00
parent 228e2e99f7
commit ed2f25ba98
7 changed files with 1608 additions and 64 deletions
+1074 -1
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -7,13 +7,18 @@
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"prepare": "svelte-kit sync || echo ''" "prepare": "svelte-kit sync || echo ''"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@testing-library/svelte": "^5.4.2",
"jsdom": "^29.1.1",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"vite": "^8.0.16" "vite": "^8.0.16",
"vitest": "^4.1.10"
} }
} }
+6
View File
@@ -5,6 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" /> <meta name="text-scale" content="scale" />
%sveltekit.head% %sveltekit.head%
<style>
body {
background-color: black;
color: white;
}
</style>
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
+41 -13
View File
@@ -12,15 +12,24 @@ import { createNoteModel } from '$lib/models/note.js';
* @typedef {import('$lib/models/task.js').TaskStatus} TaskStatus * @typedef {import('$lib/models/task.js').TaskStatus} TaskStatus
*/ */
function createObjectsStore() { const initialState = {
const { subscribe, set, update } = writable({
/** @type {Map<string, TypedObject>} */ /** @type {Map<string, TypedObject>} */
objects: new Map(), objects: new Map(),
/** @type {string | null} */ /** @type {string | null} */
rootId: null, rootId: null,
loading: false, loading: false,
error: /** @type {string | null} */ (null) error: /** @type {string | null} */ (null)
}); };
function createObjectsStore() {
const { subscribe, set, update } = writable(initialState);
/**
* Reset the store to its initial empty state. Intended for tests.
*/
function reset() {
set(initialState);
}
/** /**
* Load all objects for a user starting from the root object. * Load all objects for a user starting from the root object.
@@ -131,9 +140,11 @@ function createObjectsStore() {
* @param {string} name * @param {string} name
*/ */
async function updateProjectName(objectId, name) { async function updateProjectName(objectId, name) {
update((state) => { const state = get({ subscribe });
const obj = state.objects.get(objectId); const obj = state.objects.get(objectId);
if (!obj || obj.type !== 'project') return state; if (!obj || obj.type !== 'project') return;
update((state) => {
const objects = new Map(state.objects); const objects = new Map(state.objects);
objects.set(objectId, { ...obj, name }); objects.set(objectId, { ...obj, name });
return { ...state, objects }; return { ...state, objects };
@@ -148,9 +159,11 @@ function createObjectsStore() {
* @param {string} text * @param {string} text
*/ */
async function updateText(objectId, text) { async function updateText(objectId, text) {
update((state) => { const state = get({ subscribe });
const obj = state.objects.get(objectId); const obj = state.objects.get(objectId);
if (!obj || (obj.type !== 'task' && obj.type !== 'note')) return state; if (!obj || (obj.type !== 'task' && obj.type !== 'note')) return;
update((state) => {
const objects = new Map(state.objects); const objects = new Map(state.objects);
objects.set(objectId, { ...obj, text }); objects.set(objectId, { ...obj, text });
return { ...state, objects }; return { ...state, objects };
@@ -165,9 +178,11 @@ function createObjectsStore() {
* @param {TaskStatus} status * @param {TaskStatus} status
*/ */
async function updateTaskStatus(objectId, status) { async function updateTaskStatus(objectId, status) {
update((state) => { const state = get({ subscribe });
const obj = state.objects.get(objectId); const obj = state.objects.get(objectId);
if (!obj || obj.type !== 'task') return state; if (!obj || obj.type !== 'task') return;
update((state) => {
const objects = new Map(state.objects); const objects = new Map(state.objects);
objects.set(objectId, { ...obj, status }); objects.set(objectId, { ...obj, status });
return { ...state, objects }; return { ...state, objects };
@@ -189,11 +204,16 @@ function createObjectsStore() {
const obj = state.objects.get(objectId); const obj = state.objects.get(objectId);
if (!obj) return; if (!obj) return;
// Find parent and remove childId. // Find the parent and build an updated version without mutating the original.
for (const [parentId, parent] of state.objects) { let parentId = null;
let updatedParent = null;
for (const [id, parent] of state.objects) {
if (parent.childIds.includes(objectId)) { if (parent.childIds.includes(objectId)) {
parent.childIds = parent.childIds.filter((id) => id !== objectId); parentId = id;
await persistObject(parentId); updatedParent = {
...parent,
childIds: parent.childIds.filter((id) => id !== objectId)
};
break; break;
} }
} }
@@ -203,12 +223,20 @@ function createObjectsStore() {
update((state) => { update((state) => {
const objects = new Map(state.objects); const objects = new Map(state.objects);
objects.delete(objectId); objects.delete(objectId);
if (parentId !== null && updatedParent !== null) {
objects.set(parentId, updatedParent);
}
return { ...state, objects }; return { ...state, objects };
}); });
if (parentId !== null) {
await persistObject(parentId);
}
} }
return { return {
subscribe, subscribe,
reset,
loadObjects, loadObjects,
addObject, addObject,
updateProjectName, updateProjectName,
+396
View File
@@ -0,0 +1,396 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { get } from 'svelte/store';
import { objectsStore } from './objects.js';
import * as usersService from '$lib/services/users.js';
import * as objectsService from '$lib/services/objects.js';
// Mock the HTTP service modules so tests never hit a real backend.
vi.mock('$lib/services/users.js', () => ({
getUser: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn()
}));
vi.mock('$lib/services/objects.js', () => ({
createObject: vi.fn(),
getObject: vi.fn(),
getObjectsByUser: vi.fn(),
updateObject: vi.fn(),
deleteObject: vi.fn()
}));
function makeRecord(objectId, data) {
return {
objectId,
objectData: JSON.stringify(data)
};
}
describe('objectsStore', () => {
beforeEach(() => {
// Reset the store and mocks before each test.
objectsStore.reset();
vi.clearAllMocks();
});
describe('loadObjects', () => {
it('loads a user and their object graph into the store', async () => {
const rootId = 'root-1';
const projectId = 'project-1';
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: rootId,
objects: [
makeRecord(rootId, { type: 'root', childIds: [projectId] }),
makeRecord(projectId, { type: 'project', name: 'Test Project', childIds: [] })
]
});
await objectsStore.loadObjects('user-1');
const state = get(objectsStore);
expect(state.loading).toBe(false);
expect(state.error).toBeNull();
expect(state.rootId).toBe(rootId);
expect(state.objects.has(rootId)).toBe(true);
expect(state.objects.has(projectId)).toBe(true);
expect(state.objects.get(projectId).name).toBe('Test Project');
});
it('creates a root object when the user has none', async () => {
const generatedRootId = 'new-root-1';
objectsService.createObject.mockResolvedValue({ status: 'created', objectId: generatedRootId });
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: null,
objects: []
});
await objectsStore.loadObjects('user-1');
const state = get(objectsStore);
expect(state.rootId).toBe(generatedRootId);
expect(state.objects.has(generatedRootId)).toBe(true);
expect(state.objects.get(generatedRootId).type).toBe('root');
expect(objectsService.createObject).toHaveBeenCalledWith(
'user-1',
expect.stringContaining('"type":"root"'),
expect.any(String)
);
});
it('records an error when loading fails', async () => {
usersService.getUser.mockRejectedValue(new Error('Network error'));
await objectsStore.loadObjects('user-1');
const state = get(objectsStore);
expect(state.loading).toBe(false);
expect(state.error).toBe('Network error');
});
});
describe('addObject', () => {
it('adds a project under a parent and persists both without mutating the original parent', async () => {
const rootId = 'root-1';
const projectId = 'project-1';
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: rootId,
objects: [makeRecord(rootId, { type: 'root', childIds: [] })]
});
objectsService.createObject.mockResolvedValue({ status: 'created', objectId: projectId });
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
await objectsStore.loadObjects('user-1');
const stateBefore = get(objectsStore);
const originalRoot = stateBefore.objects.get(rootId);
await objectsStore.addObject('user-1', 'project', rootId, 'New Project');
const stateAfter = get(objectsStore);
const updatedRoot = stateAfter.objects.get(rootId);
const createdProject = stateAfter.objects.get(projectId);
expect(createdProject.type).toBe('project');
expect(createdProject.name).toBe('New Project');
expect(updatedRoot.childIds).toContain(projectId);
// Immutability checks.
expect(originalRoot.childIds).not.toContain(projectId);
expect(updatedRoot).not.toBe(originalRoot);
expect(updatedRoot.childIds).not.toBe(originalRoot.childIds);
expect(objectsService.createObject).toHaveBeenCalledWith(
'user-1',
expect.stringContaining('"name":"New Project"'),
expect.any(String)
);
expect(objectsService.updateObject).toHaveBeenCalledWith(rootId, expect.any(String));
expect(objectsService.updateObject).toHaveBeenCalledWith(projectId, expect.any(String));
});
it('adds a task with the given status', async () => {
const rootId = 'root-1';
const taskId = 'task-1';
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: rootId,
objects: [makeRecord(rootId, { type: 'root', childIds: [] })]
});
objectsService.createObject.mockResolvedValue({ status: 'created', objectId: taskId });
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
await objectsStore.loadObjects('user-1');
await objectsStore.addObject('user-1', 'task', rootId, 'New Task', 'in-progress');
const state = get(objectsStore);
const createdTask = state.objects.get(taskId);
expect(createdTask.type).toBe('task');
expect(createdTask.text).toBe('New Task');
expect(createdTask.status).toBe('in-progress');
});
it('throws when the parent does not exist', async () => {
await expect(objectsStore.addObject('user-1', 'project', 'missing-parent', 'X')).rejects.toThrow(
'Parent not found'
);
expect(objectsService.createObject).not.toHaveBeenCalled();
});
});
describe('updateProjectName', () => {
it('updates a project name and persists it', async () => {
const projectId = 'project-1';
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: 'root-1',
objects: [
makeRecord('root-1', { type: 'root', childIds: [projectId] }),
makeRecord(projectId, { type: 'project', name: 'Old Name', childIds: [] })
]
});
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
await objectsStore.loadObjects('user-1');
await objectsStore.updateProjectName(projectId, 'New Name');
const state = get(objectsStore);
expect(state.objects.get(projectId).name).toBe('New Name');
expect(objectsService.updateObject).toHaveBeenCalledWith(projectId, expect.any(String));
});
it('ignores non-project objects', async () => {
const taskId = 'task-1';
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: 'root-1',
objects: [
makeRecord('root-1', { type: 'root', childIds: [taskId] }),
makeRecord(taskId, { type: 'task', text: 'Task', status: 'not-started', childIds: [] })
]
});
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
await objectsStore.loadObjects('user-1');
await objectsStore.updateProjectName(taskId, 'Should Not Change');
const state = get(objectsStore);
expect(state.objects.get(taskId).text).toBe('Task');
expect(state.objects.get(taskId).name).toBeUndefined();
expect(objectsService.updateObject).not.toHaveBeenCalled();
});
});
describe('updateText', () => {
it('updates task text and persists it', async () => {
const taskId = 'task-1';
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: 'root-1',
objects: [
makeRecord('root-1', { type: 'root', childIds: [taskId] }),
makeRecord(taskId, { type: 'task', text: 'Old', status: 'not-started', childIds: [] })
]
});
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
await objectsStore.loadObjects('user-1');
await objectsStore.updateText(taskId, 'Updated');
const state = get(objectsStore);
expect(state.objects.get(taskId).text).toBe('Updated');
expect(objectsService.updateObject).toHaveBeenCalledWith(taskId, expect.any(String));
});
it('updates note text and persists it', async () => {
const noteId = 'note-1';
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: 'root-1',
objects: [
makeRecord('root-1', { type: 'root', childIds: [noteId] }),
makeRecord(noteId, { type: 'note', text: 'Old', childIds: [] })
]
});
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
await objectsStore.loadObjects('user-1');
await objectsStore.updateText(noteId, 'Updated');
const state = get(objectsStore);
expect(state.objects.get(noteId).text).toBe('Updated');
});
it('ignores project objects', async () => {
const projectId = 'project-1';
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: 'root-1',
objects: [
makeRecord('root-1', { type: 'root', childIds: [projectId] }),
makeRecord(projectId, { type: 'project', name: 'Project', childIds: [] })
]
});
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
await objectsStore.loadObjects('user-1');
await objectsStore.updateText(projectId, 'Should Not Change');
const state = get(objectsStore);
expect(state.objects.get(projectId).name).toBe('Project');
expect(objectsService.updateObject).not.toHaveBeenCalled();
});
});
describe('updateTaskStatus', () => {
it('updates a task status and persists it', async () => {
const taskId = 'task-1';
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: 'root-1',
objects: [
makeRecord('root-1', { type: 'root', childIds: [taskId] }),
makeRecord(taskId, { type: 'task', text: 'Task', status: 'not-started', childIds: [] })
]
});
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
await objectsStore.loadObjects('user-1');
await objectsStore.updateTaskStatus(taskId, 'completed');
const state = get(objectsStore);
expect(state.objects.get(taskId).status).toBe('completed');
expect(objectsService.updateObject).toHaveBeenCalledWith(taskId, expect.any(String));
});
it('ignores non-task objects', async () => {
const noteId = 'note-1';
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: 'root-1',
objects: [
makeRecord('root-1', { type: 'root', childIds: [noteId] }),
makeRecord(noteId, { type: 'note', text: 'Note', childIds: [] })
]
});
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
await objectsStore.loadObjects('user-1');
await objectsStore.updateTaskStatus(noteId, 'completed');
const state = get(objectsStore);
expect(state.objects.get(noteId).status).toBeUndefined();
expect(objectsService.updateObject).not.toHaveBeenCalled();
});
});
describe('removeObject', () => {
it('removes a child object and updates its parent without mutating the original parent', async () => {
// Arrange: build a simple object graph.
// root -> project -> task
const rootId = 'root-1';
const projectId = 'project-1';
const taskId = 'task-1';
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: rootId,
objects: [
makeRecord(rootId, { type: 'root', childIds: [projectId] }),
makeRecord(projectId, { type: 'project', name: 'Test Project', childIds: [taskId] }),
makeRecord(taskId, { type: 'task', text: 'Test task', status: 'not-started', childIds: [] })
]
});
objectsService.deleteObject.mockResolvedValue({ status: 'deleted' });
objectsService.updateObject.mockResolvedValue({ status: 'updated' });
// Act: load the store, then remove the task.
await objectsStore.loadObjects('user-1');
const stateBefore = get(objectsStore);
const originalProject = stateBefore.objects.get(projectId);
await objectsStore.removeObject(taskId);
// Assert: the task is gone.
const stateAfter = get(objectsStore);
expect(stateAfter.objects.has(taskId)).toBe(false);
// Assert: the project's childIds no longer contains the task.
const updatedProject = stateAfter.objects.get(projectId);
expect(updatedProject.childIds).not.toContain(taskId);
// Assert: the original project object was NOT mutated.
expect(originalProject.childIds).toContain(taskId);
expect(updatedProject).not.toBe(originalProject);
expect(updatedProject.childIds).not.toBe(originalProject.childIds);
// Assert: the backend was told to delete the task and persist the parent.
expect(objectsService.deleteObject).toHaveBeenCalledWith(taskId);
expect(objectsService.updateObject).toHaveBeenCalledWith(projectId, expect.any(String));
});
it('refuses to delete the root object', async () => {
const rootId = 'root-1';
usersService.getUser.mockResolvedValue({
userId: 'user-1',
name: 'Test User',
rootObjectId: rootId,
objects: [makeRecord(rootId, { type: 'root', childIds: [] })]
});
await objectsStore.loadObjects('user-1');
await expect(objectsStore.removeObject(rootId)).rejects.toThrow('Cannot delete root object');
expect(objectsService.deleteObject).not.toHaveBeenCalled();
});
});
});
@@ -37,6 +37,16 @@
function selectParent(objectId) { function selectParent(objectId) {
selectedParentId = objectId; selectedParentId = objectId;
} }
async function handleCreateRootChild(event) {
event.preventDefault();
selectedParentId = $objectsStore.rootId;
if (!userId || !selectedParentId || !newText.trim()) return;
await objectsStore.addObject(userId, /** @type {'project' | 'task' | 'note'} */ (newType), selectedParentId, newText.trim());
newText = '';
}
</script> </script>
{#if !userId} {#if !userId}
@@ -86,11 +96,33 @@
</form> </form>
</section> </section>
<section class="create-form">
<h3>Create Object BUT BETTER</h3>
<form onsubmit={handleCreateRootChild}>
<label>
Name / Text
<input type="text" bind:value={newText} placeholder="Enter name or text" />
</label>
<label>
Type
<select bind:value={newType}>
<option value="project">Project</option>
<option value="task">Task</option>
<option value="note">Note</option>
</select>
</label>
<button type="submit">Create</button>
</form>
</section>
<ObjectRenderer object={rootObject} /> <ObjectRenderer object={rootObject} />
{/if} {/if}
{/if} {/if}
<style> <style>
.create-form { .create-form {
margin: 1rem 0; margin: 1rem 0;
padding: 1rem; padding: 1rem;
+5 -1
View File
@@ -2,5 +2,9 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()] plugins: [sveltekit()],
test: {
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,ts}']
}
}); });