From 31022e6e644274673eb0d3df753432ad2b222dd4 Mon Sep 17 00:00:00 2001 From: CheerC Date: Wed, 24 Jun 2026 19:41:20 +0800 Subject: [PATCH] test: ratchet coverage thresholds + fill tests/lib branch gaps (#107 P0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 1: Bump vitest coverage thresholds from 55/65/75/55 to 67/74/82/67 (actuals minus ~3% buffer). Previous buffer was ~5%, tightened to ~3%. Part 2: Add libBranchGaps.test.js with 12 new test cases covering: - undoRedoHelpers.js L33-36: default parameter branches (71.42% → 100%) - dataCollectionHelpers.js: null/missing data, non-array tags, null classroom - scheduleListHelpers.js L59: lastModified falsy branch (96.66% → 100%) Overall branch coverage: 77.38% → 78.14% undoRedoHelpers branch: 71.42% → 100% scheduleListHelpers branch: 96.66% → 100% All 1154 tests pass. Thresholds pass. refs #107 Co-authored-by: Claude Opus 4.6 Agend-Agent: cb-team-impl Agend-Task: t-20260624113612576521-40473-30 Agend-Branch: test/107-p0-coverage-ratchet Agend-Issued-At: 2026-06-24T11:36:44.587554+00:00 --- tests/unit/libBranchGaps.test.js | 240 +++++++++++++++++++++++++++++++ vitest.config.js | 12 +- 2 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 tests/unit/libBranchGaps.test.js diff --git a/tests/unit/libBranchGaps.test.js b/tests/unit/libBranchGaps.test.js new file mode 100644 index 0000000..2853bd3 --- /dev/null +++ b/tests/unit/libBranchGaps.test.js @@ -0,0 +1,240 @@ +/** + * Branch coverage gap tests for tests/lib/ helpers (#107 P0). + * + * Targets the lowest-coverage branches in: + * - undoRedoHelpers.js (71.42% branch → L33-36 default params) + * - dataCollectionHelpers.js (92.06% branch → L58/77-78 null guards) + * - scheduleListHelpers.js (96.66% branch → L59 lastModified falsy) + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createTestableHistoryModule } from '../lib/undoRedoHelpers.js'; +import { + collectFromAllCourses, + getGlobalAllTags, + getGlobalAllCourseNames, + getGlobalAllTeachers, +} from '../lib/dataCollectionHelpers.js'; +import { renameSchedule } from '../lib/scheduleListHelpers.js'; +import { makeScheduleListCtx, makeScheduleListDeps } from './scheduleListHelpers.fixtures.js'; + +// ═══════════════════════════════════════════════════════════════════ +// undoRedoHelpers — default parameter branches (L33-36) +// ═══════════════════════════════════════════════════════════════════ + +describe('undoRedoHelpers — default parameter branches (#107)', () => { + it('uses default no-op callbacks when optional opts are omitted', () => { + // L33-36: onLoadState, onUpdateButtons, onCheckDirty, onUpdateCleanSnapshot + // all default to () => {} when not provided + let stateCounter = 0; + const mod = createTestableHistoryModule({ + getCurrentState: () => { + stateCounter++; + return JSON.stringify({ v: stateCounter }); + }, + // Deliberately omit: onLoadState, onUpdateButtons, onCheckDirty, onUpdateCleanSnapshot + }); + + // saveState should work without optional callbacks + mod.saveState(); + mod.saveState(); + expect(mod.getStack().length).toBe(2); + expect(mod.getIndex()).toBe(1); + + // undo should work — onLoadState default is () => {} (no-op) + mod.undo(); + expect(mod.getIndex()).toBe(0); + + // redo should work + mod.redo(); + expect(mod.getIndex()).toBe(1); + + // resetHistory should work — onUpdateCleanSnapshot default is () => {} + mod.resetHistory(); + // resetHistory captures current state as initial: stack = [currentState], index = 0 + expect(mod.getStack().length).toBe(1); + expect(mod.getIndex()).toBe(0); + }); + + it('uses provided callbacks when all opts are given', () => { + const loadCalls = []; + const updateBtnCalls = []; + const checkDirtyCalls = []; + const updateSnapshotCalls = []; + + const mod = createTestableHistoryModule({ + getCurrentState: () => JSON.stringify({ v: 1 }), + onLoadState: (state) => loadCalls.push(state), + onUpdateButtons: () => updateBtnCalls.push(true), + onCheckDirty: () => checkDirtyCalls.push(true), + onUpdateCleanSnapshot: () => updateSnapshotCalls.push(true), + }); + + mod.saveState(); + // After saveState: onUpdateButtons + onCheckDirty called + expect(updateBtnCalls.length).toBeGreaterThan(0); + expect(checkDirtyCalls.length).toBeGreaterThan(0); + + mod.resetHistory(); + expect(updateSnapshotCalls.length).toBeGreaterThan(0); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// dataCollectionHelpers — null/missing data branches +// ═══════════════════════════════════════════════════════════════════ + +describe('dataCollectionHelpers — branch gaps (#107)', () => { + // L58: schedule.data && schedule.data.scheduleData → false (missing data) + it('collectFromAllCourses skips schedule without data property', () => { + const schedules = { + s1: { name: 'No Data' }, // missing .data entirely + s2: { data: {} }, // .data exists but no .scheduleData + }; + const result = collectFromAllCourses(schedules, (item, set) => { + set.add(item.name); + }); + expect(result).toEqual([]); + }); + + it('collectFromAllCourses handles null schedules', () => { + expect(collectFromAllCourses(null, () => {})).toEqual([]); + }); + + // L77-78: classItem.tags && Array.isArray(classItem.tags) → false + it('getGlobalAllTags skips courses without tags property', () => { + const schedules = { + s1: { + data: { + scheduleData: { + 'Room A': { 0: [{ name: 'Math' }] }, // no .tags + }, + }, + }, + }; + expect(getGlobalAllTags(schedules)).toEqual([]); + }); + + it('getGlobalAllTags skips courses with non-array tags', () => { + const schedules = { + s1: { + data: { + scheduleData: { + 'Room A': { 0: [{ name: 'Math', tags: 'not-an-array' }] }, + }, + }, + }, + }; + expect(getGlobalAllTags(schedules)).toEqual([]); + }); + + // Verify normal flow still works + it('getGlobalAllTags collects tags from valid courses', () => { + const schedules = { + s1: { + data: { + scheduleData: { + 'Room A': { + 0: [ + { name: 'Math', tags: ['algebra', 'core'] }, + { name: 'Art', tags: [] }, + ], + }, + }, + }, + }, + }; + expect(getGlobalAllTags(schedules)).toEqual(['algebra', 'core']); + }); + + // L98: classItem.name falsy branch + it('getGlobalAllCourseNames skips courses without name', () => { + const schedules = { + s1: { + data: { + scheduleData: { + 'Room A': { 0: [{ teacher: 'T1' }] }, // no .name + }, + }, + }, + }; + expect(getGlobalAllCourseNames(schedules)).toEqual([]); + }); + + // L113: classItem.teacher falsy branch + it('getGlobalAllTeachers skips courses without teacher', () => { + const schedules = { + s1: { + data: { + scheduleData: { + 'Room A': { 0: [{ name: 'Math' }] }, // no .teacher + }, + }, + }, + }; + expect(getGlobalAllTeachers(schedules)).toEqual([]); + }); + + // collectFromAllCourses with null classroom entry + it('collectFromAllCourses skips null classroom entries', () => { + const schedules = { + s1: { + data: { + scheduleData: { + 'Room A': null, // L60: if (!classroom) return + 'Room B': { 0: [{ name: 'Art' }] }, + }, + }, + }, + }; + const result = collectFromAllCourses(schedules, (item, set) => { + set.add(item.name); + }); + expect(result).toEqual(['Art']); + }); + + // collectFromAllCourses with non-array day schedule + it('collectFromAllCourses skips non-array day schedules', () => { + const schedules = { + s1: { + data: { + scheduleData: { + 'Room A': { 0: 'not-an-array', 1: [{ name: 'Science' }] }, + }, + }, + }, + }; + const result = collectFromAllCourses(schedules, (item, set) => { + set.add(item.name); + }); + expect(result).toEqual(['Science']); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// scheduleListHelpers — L59 lastModified falsy branch +// ═══════════════════════════════════════════════════════════════════ + +describe('scheduleListHelpers — branch gaps (#107)', () => { + it('renameSchedule does not update scheduleLastModified when backend omits lastModified', async () => { + const ctx = makeScheduleListCtx(); + const originalTimestamp = ctx.scheduleLastModified.schedule_1; + + // Override ServerApi to return result WITHOUT lastModified + const deps = makeScheduleListDeps({ + updateScheduleMetadata: () => ({ + success: true, + newMetadataTimestamp: '2024-01-01T00:02:00.000Z', + // lastModified deliberately omitted → L59 branch false + }), + }); + deps.modals.showScheduleEditor.mockResolvedValue({ name: '新名稱', isDraft: false }); + + await renameSchedule('schedule_1', ctx, deps); + + // Name should be updated + expect(ctx.schedules.schedule_1.name).toBe('新名稱'); + // But scheduleLastModified should NOT have been updated (L59 branch false) + expect(ctx.scheduleLastModified.schedule_1).toBe(originalTimestamp); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 5cae684..c17c24d 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -19,12 +19,12 @@ export default defineConfig({ // production JS surface. See #110 for details. include: ['程式碼.js', 'tests/lib/**/*.js'], thresholds: { - // Ratcheted from 25% to actual values minus ~5% buffer (#109). - // Actuals at time of ratchet: Stmts 60.5%, Branch 69.2%, Funcs 80.3%, Lines 59.9%. - lines: 55, - functions: 75, - branches: 65, - statements: 55, + // Ratcheted from 55/65/75/55 to actual values minus ~3% buffer (#107 P0). + // Actuals at time of ratchet (2026-06-24): Stmts 70.71%, Branch 77.38%, Funcs 84.94%, Lines 70.06%. + lines: 67, + functions: 82, + branches: 74, + statements: 67, }, }, },