diff --git a/app.js b/app.js index a04aae71..1cb0d64d 100644 --- a/app.js +++ b/app.js @@ -1310,7 +1310,8 @@ function sanitizeDraft(draft) { const sanitized = {}; EDITABLE_FIELDS.forEach((field) => { // 🛡️ Sentinel: Enforce string coercion before trim() to prevent DoS via type confusion - sanitized[field] = String(draft?.[field] || '').trim().slice(0, 1000); + const value = draft?.[field]; + sanitized[field] = String(value === 0 ? value : value || '').trim().slice(0, 1000); }); // 🛡️ Sentinel: Strictly validate against allowed options to prevent injection if (!sanitized.actualProgressStatus || !ACTUAL_PROGRESS_OPTIONS.includes(sanitized.actualProgressStatus)) { @@ -1813,10 +1814,10 @@ const createNormalizedExternalRecord = (task, defaults = {}) => ({ actualStartDate: task.actualStartDate || '', actualEndDate: task.actualEndDate || '', predecessors: task.predecessors || defaults.predecessors || '', - budget: task.budget || defaults.budget || '', - actualCost: task.actualCost || defaults.actualCost || '', + budget: task.budget === 0 ? 0 : task.budget || defaults.budget || '', + actualCost: task.actualCost === 0 ? 0 : task.actualCost || defaults.actualCost || '', sprint: task.sprint || defaults.sprint || '', - storyPoints: task.storyPoints || defaults.storyPoints || '' + storyPoints: task.storyPoints === 0 ? 0 : task.storyPoints || defaults.storyPoints || '' }); function getPhaseKey(task, index) { @@ -1962,10 +1963,10 @@ function exportCsv() { task.parentId || '', task.depth, task.predecessors || '', - task.budget || '', - task.actualCost || '', + task.budget === 0 ? 0 : task.budget || '', + task.actualCost === 0 ? 0 : task.actualCost || '', task.sprint || '', - task.storyPoints || '' + task.storyPoints === 0 ? 0 : task.storyPoints || '' ]; }); diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index dc0cda8d..c4ad0eb3 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -90,6 +90,39 @@ test.describe('ScopeWeave Planner', () => { await expect(page).toHaveTitle('My New Project - ScopeWeave Planner'); }); + test('preserves numeric zero values through seed normalization and persistence', async ({ page }) => { + await page.route('**/wbs.json', async (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { __id: 'zero-budget', __depth: '3', phase: 'P5000', activity: 'Data', task: 'Zero budget', budget: 0, actualCost: 100, storyPoints: 1 }, + { __id: 'zero-cost', __depth: '3', phase: 'P5000', activity: 'Data', task: 'Zero cost', budget: 100, actualCost: 0, storyPoints: 1 }, + { __id: 'zero-points', __depth: '3', phase: 'P5000', activity: 'Data', task: 'Zero points', budget: 100, actualCost: 100, storyPoints: 0 }, + { __id: 'all-zero', __depth: '3', phase: 'P5000', activity: 'Data', task: 'All zero', budget: 0, actualCost: 0, storyPoints: 0 }, + { __id: 'missing-values', __depth: '3', phase: 'P5000', activity: 'Data', task: 'Missing values' } + ]) + })); + await page.evaluate(() => localStorage.clear()); + await page.reload(); + + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(5); + await page.getByTestId('project-name-input').fill('Zero preservation round trip'); + await expect.poll(() => page.evaluate(() => localStorage.getItem('scopeweave:planner-state:v1'))).not.toBeNull(); + const savedTasks = await page.evaluate(() => JSON.parse(localStorage.getItem('scopeweave:planner-state:v1')).tasks); + const valuesById = Object.fromEntries(savedTasks.map((task) => [task.id, { + budget: task.budget, + actualCost: task.actualCost, + storyPoints: task.storyPoints + }])); + expect(valuesById).toEqual({ + 'zero-budget': { budget: 0, actualCost: 100, storyPoints: 1 }, + 'zero-cost': { budget: 100, actualCost: 0, storyPoints: 1 }, + 'zero-points': { budget: 100, actualCost: 100, storyPoints: 0 }, + 'all-zero': { budget: 0, actualCost: 0, storyPoints: 0 }, + 'missing-values': { budget: '', actualCost: '', storyPoints: '' } + }); + }); + [ { name: 'desktop', width: 1440, height: 1000 }, { name: 'mobile', width: 375, height: 667 }