diff --git a/app.js b/app.js index a04aae71..fef4555f 100644 --- a/app.js +++ b/app.js @@ -56,6 +56,7 @@ const EDITABLE_FIELDS = [ 'sprint', 'storyPoints' ]; +const ZERO_VALID_FIELDS = new Set(['budget', 'actualCost', 'storyPoints']); const CSV_HEADERS = [ '단계', @@ -870,7 +871,7 @@ function renderEditorField(label, field, value, type = 'text', required = false, if (type === 'text') { input.maxLength = 1000; } - input.value = value || ''; + input.value = value === 0 && ZERO_VALID_FIELDS.has(field) ? '0' : (value || ''); if (required) { input.required = true; input.setAttribute('aria-required', 'true'); @@ -1310,7 +1311,9 @@ 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 val = draft?.[field]; + const normalizedValue = val === 0 && ZERO_VALID_FIELDS.has(field) ? '0' : (val || ''); + sanitized[field] = String(normalizedValue).trim().slice(0, 1000); }); // πŸ›‘οΈ Sentinel: Strictly validate against allowed options to prevent injection if (!sanitized.actualProgressStatus || !ACTUAL_PROGRESS_OPTIONS.includes(sanitized.actualProgressStatus)) { @@ -1496,7 +1499,6 @@ function getDateRangeWarning(startDate, endDate, message) { } const cachedHiddenParentIds = new Set(); - function getVisibleTasks() { const visible = []; cachedHiddenParentIds.clear(); @@ -1813,10 +1815,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 === 0 ? 0 : (defaults.budget || ''))), + actualCost: task.actualCost === 0 ? 0 : (task.actualCost || (defaults.actualCost === 0 ? 0 : (defaults.actualCost || ''))), sprint: task.sprint || defaults.sprint || '', - storyPoints: task.storyPoints || defaults.storyPoints || '' + storyPoints: task.storyPoints === 0 ? 0 : (task.storyPoints || (defaults.storyPoints === 0 ? 0 : (defaults.storyPoints || ''))) }); function getPhaseKey(task, index) { @@ -1962,10 +1964,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 || '') ]; }); @@ -2206,7 +2208,12 @@ function exportJsonArray() { [LEGACY_PLANNED_END_FIELD]: task.plannedEndDate, actualProgressStatus: task.actualProgressStatus, actualStartDate: task.actualStartDate, - actualEndDate: task.actualEndDate + actualEndDate: task.actualEndDate, + predecessors: task.predecessors || '', + budget: task.budget === 0 ? 0 : (task.budget || ''), + actualCost: task.actualCost === 0 ? 0 : (task.actualCost || ''), + sprint: task.sprint || '', + storyPoints: task.storyPoints === 0 ? 0 : (task.storyPoints || '') })); } @@ -2246,7 +2253,6 @@ function trapGanttModalFocus(event) { event.preventDefault(); return; } - const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; const activeElement = document.activeElement; diff --git a/index.html b/index.html index d24b2a88..0c947ae2 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,9 @@ ScopeWeave Planner + + + @@ -46,7 +49,7 @@

ScopeWeave Planner

λΈŒλΌμš°μ € 둜컬 μžλ™μ €μž₯ μ‚¬μš© 쀑 - +
@@ -112,6 +115,7 @@

κ°„νŠΈ 차트

+ diff --git a/json-sync-bootstrap-guard.js b/json-sync-bootstrap-guard.js new file mode 100644 index 00000000..aea2c461 --- /dev/null +++ b/json-sync-bootstrap-guard.js @@ -0,0 +1,38 @@ +const syncButton = document.getElementById('connect-json-sync'); +const taskTableBody = document.getElementById('task-table-body'); + +const LOADING_TITLE = 'ν”„λ‘œμ νŠΈ 데이터λ₯Ό λΆˆλŸ¬μ˜€λŠ” μ€‘μž…λ‹ˆλ‹€.'; +const UNSUPPORTED_TITLE = '이 λΈŒλΌμš°μ €λŠ” wbs.json 직접 μ €μž₯ 연결을 μ§€μ›ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.'; + +/** + * Keep direct wbs.json writes fail-closed until the planner has rendered its + * hydrated state. The table body always receives either task rows or the empty + * state row from renderAll(), so its first rendered child is the bootstrap + * completion signal for both populated and intentionally empty projects. + */ +function updateJsonSyncAvailability() { + const bootstrapRendered = taskTableBody.childElementCount > 0; + const pickerSupported = typeof window.showSaveFilePicker === 'function'; + const ready = bootstrapRendered && pickerSupported; + + syncButton.disabled = !ready; + if (ready) { + syncButton.removeAttribute('aria-disabled'); + syncButton.title = ''; + return; + } + + syncButton.setAttribute('aria-disabled', 'true'); + syncButton.title = bootstrapRendered ? UNSUPPORTED_TITLE : LOADING_TITLE; +} + +updateJsonSyncAvailability(); + +const bootstrapObserver = new MutationObserver(() => { + updateJsonSyncAvailability(); + if (taskTableBody.childElementCount > 0) { + bootstrapObserver.disconnect(); + } +}); + +bootstrapObserver.observe(taskTableBody, { childList: true }); diff --git a/package-lock.json b/package-lock.json index 00a99254..f507096b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@hono/node-server": "^2.1.1", - "hono": "^4.13.0" + "hono": "^4.13.5" }, "devDependencies": { "@playwright/test": "1.62.1", @@ -382,9 +382,9 @@ } }, "node_modules/hono": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", - "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/package.json b/package.json index 8cefdc74..e687c5d4 100644 --- a/package.json +++ b/package.json @@ -12,23 +12,23 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/static-json-sync-guard.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/json-sync-bootstrap-guard.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=json-sync-bootstrap-guard.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/json-sync-bootstrap-guard.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", - "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", + "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js tests/e2e/numeric-zero-roundtrip.spec.js", "test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js", "fuzz": "node --test tests/fuzz/*.mjs" }, "dependencies": { "@hono/node-server": "^2.1.1", - "hono": "^4.13.0" + "hono": "^4.13.5" }, "devDependencies": { "@playwright/test": "1.62.1", "c8": "12.0.0", "fast-check": "4.9.0" } -} +} \ No newline at end of file diff --git a/server/app.mjs b/server/app.mjs index c432a84f..c0fbe2e7 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1302,7 +1302,7 @@ app.get('/api/projects/:id/baselines', requireAuth, (c) => { const baselines = db.prepare( 'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC' ).all(p.id); - return c.json({ baselines }); + return c.json({ baselines, methodology: p.methodology || 'waterfall' }); }); app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => { @@ -1392,6 +1392,7 @@ const STATIC = { '/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'], '/pricing': ['landing.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'], + '/json-sync-bootstrap-guard.js': ['json-sync-bootstrap-guard.js', 'text/javascript; charset=utf-8'], '/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'], '/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'], '/styles.css': ['styles.css', 'text/css; charset=utf-8'], diff --git a/tests/api/static-json-sync-guard.test.mjs b/tests/api/static-json-sync-guard.test.mjs new file mode 100644 index 00000000..8fa3e367 --- /dev/null +++ b/tests/api/static-json-sync-guard.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/app.mjs'); + +test('cloud server serves the JSON sync bootstrap guard as JavaScript', async () => { + const response = await app.request('/json-sync-bootstrap-guard.js'); + + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type') || '', /^text\/javascript\b/); + assert.match(await response.text(), /updateJsonSyncAvailability/); +}); diff --git a/tests/e2e/numeric-zero-roundtrip.spec.js b/tests/e2e/numeric-zero-roundtrip.spec.js new file mode 100644 index 00000000..f29788b6 --- /dev/null +++ b/tests/e2e/numeric-zero-roundtrip.spec.js @@ -0,0 +1,319 @@ +import { test, expect } from '@playwright/test'; + +const STORAGE_KEY = 'scopeweave:planner-state:v1'; + +const createTask = (overrides = {}) => ({ + id: 'task-zero-regression', + parentId: null, + depth: 1, + expanded: true, + pendingDelete: false, + isSynthetic: false, + phase: 'P0000.μ œλ‘œκ°’ νšŒκ·€', + activity: '', + task: '', + categoryLarge: '데이터 무결성', + categoryMedium: '', + documentName: '', + owner: 'QA', + supportTeam: '', + plannedStartDate: '2026-08-25', + plannedEndDate: '2026-08-26', + actualProgressStatus: '미착수(0%)', + actualStartDate: '', + actualEndDate: '', + predecessors: '', + budget: '', + actualCost: '', + sprint: '', + storyPoints: '', + ...overrides, +}); + +const seedPersistedTask = async (page, overrides) => { + await page.addInitScript(({ storageKey, task }) => { + localStorage.setItem(storageKey, JSON.stringify({ + projectName: 'Numeric Zero Integrity', + baseDate: '2026-08-26', + tasks: [task], + })); + }, { storageKey: STORAGE_KEY, task: createTask(overrides) }); + await page.goto('./'); +}; + +const seedExternalTask = async (page, overrides) => { + await page.route('**/wbs.json', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([createTask(overrides)]), + }); + }); + await page.goto('./'); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(1); +}; + +const openEditor = async (page) => { + const row = page.locator('tbody tr[data-task-id]').first(); + await expect(row).toHaveCount(1); + await row.getByRole('button', { name: /^νŽΈμ§‘/ }).click(); +}; + +const saveAndReopen = async (page) => { + await page.getByRole('button', { name: 'μ €μž₯', exact: true }).click(); + await openEditor(page); +}; + +const readDownloadText = async (download) => { + const stream = await download.createReadStream(); + let content = ''; + for await (const chunk of stream) { + content += chunk.toString('utf8'); + } + return content; +}; + +const parseFirstCsvDataRow = (csvText) => { + const [, dataRow] = csvText.trimEnd().split(/\r?\n/); + return dataRow.split(',').map((cell) => cell.slice(1, -1).replace(/""/g, '"')); +}; + +test('blocks wbs.json sync until cloud hydration finishes', async ({ page }) => { + let releaseProjectHydration; + const projectHydration = new Promise((resolve) => { + releaseProjectHydration = resolve; + }); + + await page.route('**/api/projects', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ projects: [{ id: 123, name: 'Hydration Project', archived: false }] }), + }); + }); + await page.route('**/api/notifications', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ notifications: [] }), + }); + }); + await page.route('**/api/projects/123', async (route) => { + await projectHydration; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 123, + orgId: 1, + name: 'Hydration Project', + baseDate: '2026-08-26', + version: 1, + tasks: [createTask({ budget: 0 })], + }), + }); + }); + await page.addInitScript(() => { + localStorage.setItem('scopeweave:token', 'test-token'); + localStorage.setItem('scopeweave:project', '123'); + window.__pickerCalls = 0; + window.showSaveFilePicker = async () => { + window.__pickerCalls += 1; + return { + async createWritable() { + return { + async write() {}, + async close() {}, + }; + }, + }; + }; + }); + + await page.goto('./'); + const syncButton = page.getByRole('button', { name: 'wbs.json μžλ™μ €μž₯ μ—°κ²°' }); + await expect(syncButton).toBeDisabled(); + await syncButton.evaluate((button) => button.click()); + expect(await page.evaluate(() => window.__pickerCalls)).toBe(0); + + releaseProjectHydration(); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(1); + await expect(syncButton).toBeEnabled(); +}); + +for (const { field, testId } of [ + { field: 'budget', testId: 'editor-budget' }, + { field: 'actualCost', testId: 'editor-actual-cost' }, + { field: 'storyPoints', testId: 'editor-story-points' }, +]) { + test(`preserves numeric zero for ${field} through edit/save/reopen`, async ({ page }) => { + await seedPersistedTask(page, { [field]: 0 }); + await openEditor(page); + + await expect(page.getByTestId(testId)).toHaveValue('0'); + await saveAndReopen(page); + await expect(page.getByTestId(testId)).toHaveValue('0'); + + const persistedValue = await page.evaluate(({ storageKey, fieldName }) => { + const saved = JSON.parse(localStorage.getItem(storageKey)); + return saved.tasks[0][fieldName]; + }, { storageKey: STORAGE_KEY, fieldName: field }); + expect(String(persistedValue)).toBe('0'); + }); +} + +test('keeps unrelated falsy fields empty while preserving numeric zero', async ({ page }) => { + await seedPersistedTask(page, { owner: false, budget: 0 }); + await openEditor(page); + + await expect(page.getByTestId('editor-owner')).toHaveValue(''); + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + + await saveAndReopen(page); + await expect(page.getByTestId('editor-owner')).toHaveValue(''); + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + + const persisted = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey)).tasks[0], STORAGE_KEY); + expect(persisted.owner).toBe(''); + expect(String(persisted.budget)).toBe('0'); +}); + +test('keeps numeric zero empty for unrelated text fields', async ({ page }) => { + await seedPersistedTask(page, { owner: 0, budget: 0 }); + await openEditor(page); + + await expect(page.getByTestId('editor-owner')).toHaveValue(''); + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + + await saveAndReopen(page); + await expect(page.getByTestId('editor-owner')).toHaveValue(''); + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + + const persisted = await page.evaluate((storageKey) => JSON.parse(localStorage.getItem(storageKey)).tasks[0], STORAGE_KEY); + expect(persisted.owner).toBe(''); + expect(String(persisted.budget)).toBe('0'); +}); + +test('preserves budget, actual cost, and story points when all are numeric zero', async ({ page }) => { + await seedPersistedTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); + await openEditor(page); + + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + await expect(page.getByTestId('editor-actual-cost')).toHaveValue('0'); + await expect(page.getByTestId('editor-story-points')).toHaveValue('0'); + + await saveAndReopen(page); + + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + await expect(page.getByTestId('editor-actual-cost')).toHaveValue('0'); + await expect(page.getByTestId('editor-story-points')).toHaveValue('0'); +}); + +test('preserves numeric zero while normalizing an external wbs.json record', async ({ page }) => { + await seedExternalTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); + await openEditor(page); + + await expect(page.getByTestId('editor-budget')).toHaveValue('0'); + await expect(page.getByTestId('editor-actual-cost')).toHaveValue('0'); + await expect(page.getByTestId('editor-story-points')).toHaveValue('0'); +}); + +test('preserves planning metadata in wbs.json sync output', async ({ page }) => { + await page.addInitScript(() => { + window.__savedWbsJson = null; + window.showSaveFilePicker = async () => ({ + async createWritable() { + return { + async write(content) { + window.__savedWbsJson = content; + }, + async close() {}, + }; + }, + }); + }); + await seedPersistedTask(page, { + budget: 0, + actualCost: 0, + storyPoints: 0, + sprint: 'Sprint 3', + predecessors: 'P1000,P2000', + }); + + await page.getByRole('button', { name: 'wbs.json μžλ™μ €μž₯ μ—°κ²°' }).click(); + await expect.poll(async () => page.evaluate(() => window.__savedWbsJson)).not.toBeNull(); + const savedPayload = await page.evaluate(() => JSON.parse(window.__savedWbsJson)); + + expect(savedPayload[0]).toMatchObject({ + budget: 0, + actualCost: 0, + storyPoints: 0, + sprint: 'Sprint 3', + predecessors: 'P1000,P2000', + }); +}); + +test('normalizes unrelated falsy planning metadata in wbs.json sync output', async ({ page }) => { + await page.addInitScript(() => { + window.__savedWbsJson = null; + window.showSaveFilePicker = async () => ({ + async createWritable() { + return { + async write(content) { + window.__savedWbsJson = content; + }, + async close() {}, + }; + }, + }); + }); + await seedPersistedTask(page, { + predecessors: 0, + sprint: false, + budget: 0, + actualCost: 0, + storyPoints: 0, + }); + + await page.getByRole('button', { name: 'wbs.json μžλ™μ €μž₯ μ—°κ²°' }).click(); + await expect.poll(async () => page.evaluate(() => window.__savedWbsJson)).not.toBeNull(); + const savedPayload = await page.evaluate(() => JSON.parse(window.__savedWbsJson)); + + expect(savedPayload[0]).toMatchObject({ + predecessors: '', + sprint: '', + budget: 0, + actualCost: 0, + storyPoints: 0, + }); +}); + +test('exports numeric zero values instead of empty CSV cells', async ({ page }) => { + await seedPersistedTask(page, { budget: 0, actualCost: 0, storyPoints: 0 }); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('#export-csv').click(); + const cells = parseFirstCsvDataRow(await readDownloadText(await downloadPromise)); + + expect(cells.slice(-4)).toEqual(['0', '0', '', '0']); +}); + +test('does not serialize boolean false from persisted numeric fields into CSV', async ({ page }) => { + await seedPersistedTask(page, { budget: false, actualCost: false, storyPoints: false }); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('#export-csv').click(); + const cells = parseFirstCsvDataRow(await readDownloadText(await downloadPromise)); + + expect(cells.slice(-4)).toEqual(['', '', '', '']); +}); + +test('does not preserve boolean false from external numeric fields', async ({ page }) => { + await seedExternalTask(page, { budget: false, actualCost: false, storyPoints: false }); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('#export-csv').click(); + const cells = parseFirstCsvDataRow(await readDownloadText(await downloadPromise)); + + expect(cells.slice(-4)).toEqual(['', '', '', '']); +}); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..57cb5fac 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,11 +34,21 @@ assert.match( /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=json-sync-bootstrap-guard\.js/, + 'the JSON sync bootstrap guard is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, 'the Clearfolio signal and HTTP failure regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/json-sync-bootstrap-guard\.test\.mjs/, + 'the JSON sync bootstrap guard behavior executes under c8', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, diff --git a/tests/unit/json-sync-bootstrap-guard.test.mjs b/tests/unit/json-sync-bootstrap-guard.test.mjs new file mode 100644 index 00000000..93caf05d --- /dev/null +++ b/tests/unit/json-sync-bootstrap-guard.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; + +const attributes = new Map(); +const syncButton = { + disabled: false, + title: '', + setAttribute(name, value) { + attributes.set(name, value); + }, + removeAttribute(name) { + attributes.delete(name); + }, +}; +const taskTableBody = { childElementCount: 0 }; + +let observerCallback = null; +let observedTarget = null; +let observedOptions = null; +let disconnectCalls = 0; + +class FakeMutationObserver { + constructor(callback) { + observerCallback = callback; + } + + observe(target, options) { + observedTarget = target; + observedOptions = options; + } + + disconnect() { + disconnectCalls += 1; + } +} + +globalThis.document = { + getElementById(id) { + if (id === 'connect-json-sync') { + return syncButton; + } + if (id === 'task-table-body') { + return taskTableBody; + } + return null; + }, +}; +globalThis.window = {}; +globalThis.MutationObserver = FakeMutationObserver; + +try { + await import('../../json-sync-bootstrap-guard.js'); + + assert.equal(syncButton.disabled, true); + assert.equal(attributes.get('aria-disabled'), 'true'); + assert.equal(syncButton.title, 'ν”„λ‘œμ νŠΈ 데이터λ₯Ό λΆˆλŸ¬μ˜€λŠ” μ€‘μž…λ‹ˆλ‹€.'); + assert.equal(observedTarget, taskTableBody); + assert.deepEqual(observedOptions, { childList: true }); + assert.equal(typeof observerCallback, 'function'); + + observerCallback(); + assert.equal(disconnectCalls, 0); + assert.equal(syncButton.disabled, true); + assert.equal(syncButton.title, 'ν”„λ‘œμ νŠΈ 데이터λ₯Ό λΆˆλŸ¬μ˜€λŠ” μ€‘μž…λ‹ˆλ‹€.'); + + taskTableBody.childElementCount = 1; + observerCallback(); + assert.equal(disconnectCalls, 1); + assert.equal(syncButton.disabled, true); + assert.equal(attributes.get('aria-disabled'), 'true'); + assert.equal(syncButton.title, '이 λΈŒλΌμš°μ €λŠ” wbs.json 직접 μ €μž₯ 연결을 μ§€μ›ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.'); + + globalThis.window.showSaveFilePicker = async () => {}; + observerCallback(); + assert.equal(disconnectCalls, 2); + assert.equal(syncButton.disabled, false); + assert.equal(attributes.has('aria-disabled'), false); + assert.equal(syncButton.title, ''); +} finally { + delete globalThis.document; + delete globalThis.window; + delete globalThis.MutationObserver; +} + +console.log('βœ“ JSON sync bootstrap guard behavior tests passed');