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
+46 -18
View File
@@ -12,15 +12,24 @@ import { createNoteModel } from '$lib/models/note.js';
* @typedef {import('$lib/models/task.js').TaskStatus} TaskStatus
*/
const initialState = {
/** @type {Map<string, TypedObject>} */
objects: new Map(),
/** @type {string | null} */
rootId: null,
loading: false,
error: /** @type {string | null} */ (null)
};
function createObjectsStore() {
const { subscribe, set, update } = writable({
/** @type {Map<string, TypedObject>} */
objects: new Map(),
/** @type {string | null} */
rootId: null,
loading: false,
error: /** @type {string | null} */ (null)
});
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.
@@ -131,9 +140,11 @@ function createObjectsStore() {
* @param {string} name
*/
async function updateProjectName(objectId, name) {
const state = get({ subscribe });
const obj = state.objects.get(objectId);
if (!obj || obj.type !== 'project') return;
update((state) => {
const obj = state.objects.get(objectId);
if (!obj || obj.type !== 'project') return state;
const objects = new Map(state.objects);
objects.set(objectId, { ...obj, name });
return { ...state, objects };
@@ -148,9 +159,11 @@ function createObjectsStore() {
* @param {string} text
*/
async function updateText(objectId, text) {
const state = get({ subscribe });
const obj = state.objects.get(objectId);
if (!obj || (obj.type !== 'task' && obj.type !== 'note')) return;
update((state) => {
const obj = state.objects.get(objectId);
if (!obj || (obj.type !== 'task' && obj.type !== 'note')) return state;
const objects = new Map(state.objects);
objects.set(objectId, { ...obj, text });
return { ...state, objects };
@@ -165,9 +178,11 @@ function createObjectsStore() {
* @param {TaskStatus} status
*/
async function updateTaskStatus(objectId, status) {
const state = get({ subscribe });
const obj = state.objects.get(objectId);
if (!obj || obj.type !== 'task') return;
update((state) => {
const obj = state.objects.get(objectId);
if (!obj || obj.type !== 'task') return state;
const objects = new Map(state.objects);
objects.set(objectId, { ...obj, status });
return { ...state, objects };
@@ -189,11 +204,16 @@ function createObjectsStore() {
const obj = state.objects.get(objectId);
if (!obj) return;
// Find parent and remove childId.
for (const [parentId, parent] of state.objects) {
// Find the parent and build an updated version without mutating the original.
let parentId = null;
let updatedParent = null;
for (const [id, parent] of state.objects) {
if (parent.childIds.includes(objectId)) {
parent.childIds = parent.childIds.filter((id) => id !== objectId);
await persistObject(parentId);
parentId = id;
updatedParent = {
...parent,
childIds: parent.childIds.filter((id) => id !== objectId)
};
break;
}
}
@@ -203,12 +223,20 @@ function createObjectsStore() {
update((state) => {
const objects = new Map(state.objects);
objects.delete(objectId);
if (parentId !== null && updatedParent !== null) {
objects.set(parentId, updatedParent);
}
return { ...state, objects };
});
if (parentId !== null) {
await persistObject(parentId);
}
}
return {
subscribe,
reset,
loadObjects,
addObject,
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();
});
});
});