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');