should finally work kinda
This commit is contained in:
Generated
+1074
-1
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,11 +37,21 @@
|
|||||||
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}
|
||||||
<p>You are not logged in. <a href="/">Go home</a></p>
|
<p>You are not logged in. <a href="/">Go home</a></p>
|
||||||
{:else}
|
{:else}
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
<p>Welcome, {userId}.</p>
|
<p>Welcome, {userId}.</p>
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}']
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user