diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e184b6a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +node_modules/ +coverage/ +dist/ +.github/dependabot.yml +.github/FUNDING.yml +.github/workflows/sonarcloud.yml +.vscode/extensions.json +index.html +CONTRIBUTING.md +README.md \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +{} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 46a3ead..8cf9606 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ + "esbenp.prettier-vscode", "SonarSource.sonarlint-vscode" ] -} +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 021fd2e..251050e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,14 +1,14 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "chrome", - "request": "launch", - "name": "Launch Website", - "file": "${workspaceFolder}/index.html" - } - ] + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Website", + "file": "${workspaceFolder}/index.html" + } + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c867f6..3594a67 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,9 @@ { - "sonarlint.connectedMode.project": { - "connectionId": "bellmano", - "projectKey": "bellmano_CountUpTimer" - } + "git.autofetch": true, + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "sonarlint.connectedMode.project": { + "connectionId": "bellmano", + "projectKey": "bellmano_CountUpTimer" + } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b1400c..26b8204 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,16 +3,22 @@ 1. Fork the repository 2. Create a feature branch: `git checkout -b feature/your-feature` 3. Make your changes and add or update tests -4. Run the tests: `npm test` or `npm run test:coverage` -5. Commit your changes: `git commit -m 'Add some feature'` -6. Push to the branch: `git push origin feature/your-feature` -7. Open a Pull Request +4. Format the code with [Prettier](https://prettier.io): `npm run format` +5. Run the tests: `npm test` or `npm run test:coverage` +6. Commit your changes: `git commit -m 'Add some feature'` +7. Push to the branch: `git push origin feature/your-feature` +8. Open a Pull Request ## Development Setup 1. Clone the repository: `git clone https://github.com/bellmano/CountUpTimer.git` 2. Install dependencies: `npm install` +## Code Style + +- All code must be formatted with [Prettier](https://prettier.io) before it is committed or pushed. Run `npm run format` from the repository root. +- Write meaningful commit messages + ## Testing - Add or update unit tests for new features or bug fixes @@ -20,4 +26,4 @@ ## Issues -If you find a bug or have a feature request, please [open an issue](https://github.com/bellmano/CountUpTimer/issues). +If you find a bug or have a feature request, please [open an issue](https://github.com/bellmano/CountUpTimer/issues). \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e0d6804..60cf1fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "devDependencies": { "jest": "^30.5.1", - "jest-environment-jsdom": "^30.5.1" + "jest-environment-jsdom": "^30.5.1", + "prettier": "^3.9.6" } }, "node_modules/@asamuzakjp/css-color": { @@ -4313,6 +4314,22 @@ "node": ">=8" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "30.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", diff --git a/package.json b/package.json index 4bab4a7..332d737 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,14 @@ "description": "Website that is counting up from the date/time specified in the code", "scripts": { "test": "jest", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "devDependencies": { "jest": "^30.5.1", - "jest-environment-jsdom": "^30.5.1" + "jest-environment-jsdom": "^30.5.1", + "prettier": "^3.9.6" }, "jest": { "testEnvironment": "jsdom", @@ -16,11 +19,15 @@ "src/script.js" ], "coverageDirectory": "coverage", - "coverageReporters": ["text", "lcov", "html"], + "coverageReporters": [ + "text", + "lcov", + "html" + ], "coveragePathIgnorePatterns": [ "/node_modules/", "/tests/" ], "transform": {} } -} \ No newline at end of file +} diff --git a/src/script.js b/src/script.js index 509ed6b..f90a9ae 100644 --- a/src/script.js +++ b/src/script.js @@ -1,6 +1,6 @@ function handleWindowOnload() { // Set time and date where the timer should count from - countUpFromTime("Oct 21, 2001 17:00:00", 'countup1'); + countUpFromTime("Oct 21, 2001 17:00:00", "countup1"); } window.onload = handleWindowOnload; @@ -19,7 +19,11 @@ function countUpFromTime(countFrom, id) { return; } - let years = 0, remainingDays = 0, hours = 0, minutes = 0, seconds = 0; + let years = 0, + remainingDays = 0, + hours = 0, + minutes = 0, + seconds = 0; if (isValidDate) { // Calculate timezone offsets for both dates const countFromOffset = countFromDate.getTimezoneOffset() * 60000; // Offset in milliseconds @@ -41,8 +45,13 @@ function countUpFromTime(countFrom, id) { // Calculate years and remaining days, accounting for leap years and DST remainingDays = totalDays; - for (let year = countFromDate.getFullYear(); year < now.getFullYear(); year++) { - const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); + for ( + let year = countFromDate.getFullYear(); + year < now.getFullYear(); + year++ + ) { + const isLeapYear = + (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; const daysInYear = isLeapYear ? 366 : 365; if (remainingDays >= daysInYear) { years++; @@ -59,15 +68,19 @@ function countUpFromTime(countFrom, id) { remainingDays = exactDays; hours = Math.floor((timeDifference % msInADay) / msInAnHour); - minutes = Math.floor(((timeDifference % msInADay) % msInAnHour) / (60 * 1000)); - seconds = Math.floor((((timeDifference % msInADay) % msInAnHour) % (60 * 1000)) / 1000); + minutes = Math.floor( + ((timeDifference % msInADay) % msInAnHour) / (60 * 1000), + ); + seconds = Math.floor( + (((timeDifference % msInADay) % msInAnHour) % (60 * 1000)) / 1000, + ); } - idEl.getElementsByClassName('years')[0].innerHTML = years; - idEl.getElementsByClassName('days')[0].innerHTML = remainingDays; - idEl.getElementsByClassName('hours')[0].innerHTML = hours; - idEl.getElementsByClassName('minutes')[0].innerHTML = minutes; - idEl.getElementsByClassName('seconds')[0].innerHTML = seconds; + idEl.getElementsByClassName("years")[0].innerHTML = years; + idEl.getElementsByClassName("days")[0].innerHTML = remainingDays; + idEl.getElementsByClassName("hours")[0].innerHTML = hours; + idEl.getElementsByClassName("minutes")[0].innerHTML = minutes; + idEl.getElementsByClassName("seconds")[0].innerHTML = seconds; clearTimeout(countUpFromTime.interval); countUpFromTime.interval = setTimeout(countUpFromTime, 1000, countFrom, id); @@ -75,6 +88,6 @@ function countUpFromTime(countFrom, id) { // Export for Node.js/CommonJS (test environment) /* istanbul ignore next */ -if (typeof module !== 'undefined' && module.exports) { +if (typeof module !== "undefined" && module.exports) { module.exports = { countUpFromTime, handleWindowOnload }; -} \ No newline at end of file +} diff --git a/src/style.css b/src/style.css index 3d10150..c3564b7 100644 --- a/src/style.css +++ b/src/style.css @@ -1,120 +1,120 @@ * { - margin: 0; - padding: 0; - box-sizing: border-box + margin: 0; + padding: 0; + box-sizing: border-box; } /* Ensure HTML and body elements handle the full viewport properly on mobile */ html { - height: 100%; - height: 100dvh; /* Dynamic viewport height for iOS 15+ */ - background-color: #000; /* Fallback color to match background */ + height: 100%; + height: 100dvh; /* Dynamic viewport height for iOS 15+ */ + background-color: #000; /* Fallback color to match background */ } body { - font-family: 'Kanit', sans-serif; - overflow: hidden; - /* Ensure body fills the entire viewport on iOS */ - min-height: 100vh; - min-height: 100dvh; /* Dynamic viewport height for iOS 15+ */ - margin: 0; - padding: 0; + font-family: "Kanit", sans-serif; + overflow: hidden; + /* Ensure body fills the entire viewport on iOS */ + min-height: 100vh; + min-height: 100dvh; /* Dynamic viewport height for iOS 15+ */ + margin: 0; + padding: 0; } h2 { - font-size: 2.5em; - font-weight: 700; - padding: 3rem; - color: #2196F3; - background-color: rgba(0, 0, 0, .5); - text-align: center; - white-space: nowrap; - margin-bottom: 20px; - font-family: 'Kanit', sans-serif; + font-size: 2.5em; + font-weight: 700; + padding: 3rem; + color: #2196f3; + background-color: rgba(0, 0, 0, 0.5); + text-align: center; + white-space: nowrap; + margin-bottom: 20px; + font-family: "Kanit", sans-serif; } .background { - position: fixed; - top: 0; - left: 0; - width: 100vw; - width: 100dvw; /* Dynamic viewport width */ - height: 100vh; - height: 100dvh; /* Dynamic viewport height for iOS 15+ */ - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - background-image: url(../img/background.jpg); - background-size: cover; - background-repeat: no-repeat; - background-position: center; - margin: 0; - padding: 0; - overflow: hidden; - /* Handle safe areas for devices with notches/dynamic island */ - padding-top: env(safe-area-inset-top); - padding-bottom: env(safe-area-inset-bottom); - padding-left: env(safe-area-inset-left); - padding-right: env(safe-area-inset-right); + position: fixed; + top: 0; + left: 0; + width: 100vw; + width: 100dvw; /* Dynamic viewport width */ + height: 100vh; + height: 100dvh; /* Dynamic viewport height for iOS 15+ */ + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + background-image: url(../img/background.jpg); + background-size: cover; + background-repeat: no-repeat; + background-position: center; + margin: 0; + padding: 0; + overflow: hidden; + /* Handle safe areas for devices with notches/dynamic island */ + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); } .optimized-background { - background-size: cover; - background-position: center; - background-attachment: fixed; + background-size: cover; + background-position: center; + background-attachment: fixed; } .countup { - display: flex; - justify-content: center; - align-items: center; - flex-wrap: nowrap; - gap: 20px; - text-align: center; - margin: 0 auto; - color: #2196F3; - background-color: rgba(0, 0, 0, .5); - padding: 20px; - max-width: 90%; + display: flex; + justify-content: center; + align-items: center; + flex-wrap: nowrap; + gap: 20px; + text-align: center; + margin: 0 auto; + color: #2196f3; + background-color: rgba(0, 0, 0, 0.5); + padding: 20px; + max-width: 90%; } @media (max-width: 768px) { - .countup { - gap: 10px; - } - - .countup h3 { - font-size: 1.8em; - } - - .countup .label { - font-size: 1.3em; - } - - h2 { - font-size: 1.6em; - padding: 1.5rem; - } + .countup { + gap: 10px; + } + + .countup h3 { + font-size: 1.8em; + } + + .countup .label { + font-size: 1.3em; + } + + h2 { + font-size: 1.6em; + padding: 1.5rem; + } } @media (max-width: 480px) { - .countup { - gap: 8px; - } - - .countup h3 { - font-size: 1.6em; - } - - .countup .label { - font-size: 1.2em; - } - - h2 { - font-size: 1.3em; - padding: 1rem; - } + .countup { + gap: 8px; + } + + .countup h3 { + font-size: 1.6em; + } + + .countup .label { + font-size: 1.2em; + } + + h2 { + font-size: 1.3em; + padding: 1rem; + } } .countup .container-years, @@ -122,19 +122,19 @@ h2 { .countup .container-hours, .countup .container-minutes, .countup .container-seconds { - text-align: center; - margin: 5px; + text-align: center; + margin: 5px; } .countup h3 { - font-size: 2em; - margin: 5px 0; - font-family: 'Kanit', sans-serif; + font-size: 2em; + margin: 5px 0; + font-family: "Kanit", sans-serif; } .countup .label { - font-size: 1.5em; - margin-top: 8px; + font-size: 1.5em; + margin-top: 8px; } .years, @@ -142,38 +142,38 @@ h2 { .hours, .minutes, .seconds { - font-size: 3rem + font-size: 3rem; } @media (min-width: 1024px) { - .background { - background-size: cover; - background-position: top; - } - - h2 { - font-size: 3.5em; - padding: 5rem; - } - - .countup { - gap: 30px; - padding: 40px; - } - - .countup h3 { - font-size: 3em; - } - - .countup .label { - font-size: 2.5em; - } - - .years, - .days, - .hours, - .minutes, - .seconds { - font-size: 5rem; - } + .background { + background-size: cover; + background-position: top; + } + + h2 { + font-size: 3.5em; + padding: 5rem; + } + + .countup { + gap: 30px; + padding: 40px; + } + + .countup h3 { + font-size: 3em; + } + + .countup .label { + font-size: 2.5em; + } + + .years, + .days, + .hours, + .minutes, + .seconds { + font-size: 5rem; + } } diff --git a/tests/script.test.js b/tests/script.test.js index a2e327e..2267ab7 100644 --- a/tests/script.test.js +++ b/tests/script.test.js @@ -1,251 +1,261 @@ -describe('Main Script Tests', () => { - test('exports countUpFromTime for Node.js', () => { - const exported = require('../src/script.js'); - expect(exported).toHaveProperty('countUpFromTime'); - expect(typeof exported.countUpFromTime).toBe('function'); - }); - test('exports handleWindowOnload for Node.js', () => { - const exported = require('../src/script.js'); - expect(exported).toHaveProperty('handleWindowOnload'); - expect(typeof exported.handleWindowOnload).toBe('function'); - }); - test('window.onload is covered', () => { - global.window = global.window || {}; - global.window.onload = require('../src/script.js').handleWindowOnload; - expect(typeof global.window.onload).toBe('function'); - global.window.onload(); - }); - test('handleWindowOnload is covered', () => { - const { handleWindowOnload } = require('../src/script.js'); - expect(typeof handleWindowOnload).toBe('function'); - handleWindowOnload(); - }); +describe("Main Script Tests", () => { + test("exports countUpFromTime for Node.js", () => { + const exported = require("../src/script.js"); + expect(exported).toHaveProperty("countUpFromTime"); + expect(typeof exported.countUpFromTime).toBe("function"); + }); + test("exports handleWindowOnload for Node.js", () => { + const exported = require("../src/script.js"); + expect(exported).toHaveProperty("handleWindowOnload"); + expect(typeof exported.handleWindowOnload).toBe("function"); + }); + test("window.onload is covered", () => { + global.window = global.window || {}; + global.window.onload = require("../src/script.js").handleWindowOnload; + expect(typeof global.window.onload).toBe("function"); + global.window.onload(); + }); + test("handleWindowOnload is covered", () => { + const { handleWindowOnload } = require("../src/script.js"); + expect(typeof handleWindowOnload).toBe("function"); + handleWindowOnload(); + }); }); // Mock document.getElementById and DOM structure function createMockDOM() { - const years = { innerHTML: null }; - const days = { innerHTML: null }; - const hours = { innerHTML: null }; - const minutes = { innerHTML: null }; - const seconds = { innerHTML: null }; - return { - getElementsByClassName: (cls) => { - if (cls === 'years') return [years]; - if (cls === 'days') return [days]; - if (cls === 'hours') return [hours]; - if (cls === 'minutes') return [minutes]; - if (cls === 'seconds') return [seconds]; - return [{}]; - }, - years, days, hours, minutes, seconds - }; + const years = { innerHTML: null }; + const days = { innerHTML: null }; + const hours = { innerHTML: null }; + const minutes = { innerHTML: null }; + const seconds = { innerHTML: null }; + return { + getElementsByClassName: (cls) => { + if (cls === "years") return [years]; + if (cls === "days") return [days]; + if (cls === "hours") return [hours]; + if (cls === "minutes") return [minutes]; + if (cls === "seconds") return [seconds]; + return [{}]; + }, + years, + days, + hours, + minutes, + seconds, + }; } -describe('countUpFromTime', () => { - let script; - let originalGetElementById; - let originalSetTimeout; - let mockTimeout; - - beforeAll(() => { - // Load script.js into global scope - script = require('../src/script.js'); - }); - - beforeEach(() => { - // Mock document.getElementById - originalGetElementById = global.document.getElementById; - const domMap = {}; - global.document.getElementById = jest.fn((id) => { - if (!domMap[id]) { - domMap[id] = createMockDOM(); - } - return domMap[id]; - }); - // Mock setTimeout - originalSetTimeout = global.setTimeout; - mockTimeout = jest.fn(); - global.setTimeout = mockTimeout; - // Clear interval - script.countUpFromTime.interval = undefined; - }); - - afterEach(() => { - global.document.getElementById = originalGetElementById; - global.setTimeout = originalSetTimeout; - }); - - test('handles invalid date input gracefully', () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('Sep 13, 2025 12:00:00')); - const mockEl = createMockDOM(); - global.document.getElementById = jest.fn(() => mockEl); - expect(() => { - script.countUpFromTime('not a date', 'countup1'); - }).not.toThrow(); - // All values should be zero or NaN - const yearsVal = Number(mockEl.years.innerHTML); - const daysVal = Number(mockEl.days.innerHTML); - const hoursVal = Number(mockEl.hours.innerHTML); - const minutesVal = Number(mockEl.minutes.innerHTML); - const secondsVal = Number(mockEl.seconds.innerHTML); - expect(yearsVal === 0 || Number.isNaN(yearsVal)).toBe(true); - expect(daysVal === 0 || Number.isNaN(daysVal)).toBe(true); - expect(hoursVal === 0 || Number.isNaN(hoursVal)).toBe(true); - expect(minutesVal === 0 || Number.isNaN(minutesVal)).toBe(true); - expect(secondsVal === 0 || Number.isNaN(secondsVal)).toBe(true); - jest.useRealTimers(); - }); - - test('handles missing DOM elements gracefully', () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('Sep 13, 2025 12:00:00')); - // getElementById returns null - global.document.getElementById = jest.fn(() => null); - expect(() => { - script.countUpFromTime('Sep 13, 2025 12:00:00', 'countup1'); - }).not.toThrow(); - jest.useRealTimers(); - }); - - test('calls clearTimeout when resetting interval', () => { - const mockEl = createMockDOM(); - global.document.getElementById = jest.fn(() => mockEl); - const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); - script.countUpFromTime('Sep 13, 2025 11:59:00', 'countup1'); - expect(clearTimeoutSpy).toHaveBeenCalled(); - clearTimeoutSpy.mockRestore(); - }); - - test('calculates years, days, hours, minutes, seconds correctly for normal date', () => { - const mockEl = createMockDOM(); - global.document.getElementById = jest.fn(() => mockEl); - // Use a fixed date for now - // Use real Date objects - script.countUpFromTime('Oct 21, 2001 17:00:00', 'countup1'); - // Years since 2001-10-21 to now - const start = new Date('Oct 21, 2001 17:00:00'); - const now = new Date(); - let years = now.getFullYear() - start.getFullYear(); - if ( - now.getMonth() < start.getMonth() || - (now.getMonth() === start.getMonth() && now.getDate() < start.getDate()) - ) { - years--; - } - expect(Number(mockEl.years.innerHTML)).toBeGreaterThanOrEqual(years); - expect(Number(mockEl.days.innerHTML)).toBeGreaterThanOrEqual(0); - expect(Number(mockEl.hours.innerHTML)).toBeGreaterThanOrEqual(0); - expect(Number(mockEl.minutes.innerHTML)).toBeGreaterThanOrEqual(0); - expect(Number(mockEl.seconds.innerHTML)).toBeGreaterThanOrEqual(0); - expect(mockTimeout).toHaveBeenCalled(); - }); - - test('handles leap years correctly', () => { - const mockEl = createMockDOM(); - global.document.getElementById = jest.fn(() => mockEl); - // Leap year: 2004 - script.countUpFromTime('Jan 1, 2004 00:00:00', 'countup1'); - // Should be years since 2004-01-01 to now - const start = new Date('Jan 1, 2004 00:00:00'); - const now = new Date(); - let years = now.getFullYear() - start.getFullYear(); - // If current date is before Jan 1, subtract one year - if ( - now.getMonth() < start.getMonth() || - (now.getMonth() === start.getMonth() && now.getDate() < start.getDate()) - ) { - years--; - } - expect(Number(mockEl.years.innerHTML)).toBe(years); - expect(Number(mockEl.days.innerHTML)).toBeGreaterThanOrEqual(0); - }); - - test('handles DST changes', () => { - const mockEl = createMockDOM(); - global.document.getElementById = jest.fn(() => mockEl); - // DST change: last Sunday in March (Europe) - script.countUpFromTime('Mar 27, 2022 01:59:59', 'countup1'); - expect(Number(mockEl.hours.innerHTML)).toBeGreaterThanOrEqual(0); - }); - - test('covers for-loop else branch (break) when remainingDays < daysInYear', () => { - // Freeze time to a known date so the loop runs exactly once and hits the else branch - jest.useFakeTimers(); - jest.setSystemTime(new Date('Nov 01, 2025 12:00:00')); - const mockEl = createMockDOM(); - global.document.getElementById = jest.fn(() => mockEl); - // Choose a date in the previous year but with remaining days less than a full year - script.countUpFromTime('Dec 31, 2024 00:00:00', 'countup1'); - // The function should have written numeric values to the DOM - expect(Number(mockEl.years.innerHTML)).toBeGreaterThanOrEqual(0); - expect(Number(mockEl.days.innerHTML)).toBeGreaterThanOrEqual(0); - jest.useRealTimers(); - }); - - test('updates DOM elements with correct values', () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('Sep 13, 2025 11:59:00')); - const mockEl = createMockDOM(); - global.document.getElementById = jest.fn(() => mockEl); - script.countUpFromTime('Sep 13, 2025 11:59:00', 'countup1'); - expect(mockEl.years.innerHTML).toBe(0); - expect(Number(mockEl.days.innerHTML)).toBe(0); - expect(Number(mockEl.hours.innerHTML)).toBe(0); - expect(Number(mockEl.minutes.innerHTML)).toBe(0); - expect(Number(mockEl.seconds.innerHTML)).toBe(0); - jest.useRealTimers(); - }); - - test('clears previous interval and sets new one', () => { - const mockEl = createMockDOM(); - global.document.getElementById = jest.fn(() => mockEl); - script.countUpFromTime('Sep 13, 2025 11:59:00', 'countup1'); - expect(mockTimeout).toHaveBeenCalled(); - }); - - test('handles edge case: same date', () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('Sep 13, 2025 12:00:00')); - const mockEl = createMockDOM(); - global.document.getElementById = jest.fn(() => mockEl); - script.countUpFromTime('Sep 13, 2025 12:00:00', 'countup1'); - expect(mockEl.years.innerHTML).toBe(0); - expect(mockEl.days.innerHTML).toBe(0); - jest.useRealTimers(); - }); +describe("countUpFromTime", () => { + let script; + let originalGetElementById; + let originalSetTimeout; + let mockTimeout; + + beforeAll(() => { + // Load script.js into global scope + script = require("../src/script.js"); + }); + + beforeEach(() => { + // Mock document.getElementById + originalGetElementById = global.document.getElementById; + const domMap = {}; + global.document.getElementById = jest.fn((id) => { + if (!domMap[id]) { + domMap[id] = createMockDOM(); + } + return domMap[id]; + }); + // Mock setTimeout + originalSetTimeout = global.setTimeout; + mockTimeout = jest.fn(); + global.setTimeout = mockTimeout; + // Clear interval + script.countUpFromTime.interval = undefined; + }); + + afterEach(() => { + global.document.getElementById = originalGetElementById; + global.setTimeout = originalSetTimeout; + }); + + test("handles invalid date input gracefully", () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("Sep 13, 2025 12:00:00")); + const mockEl = createMockDOM(); + global.document.getElementById = jest.fn(() => mockEl); + expect(() => { + script.countUpFromTime("not a date", "countup1"); + }).not.toThrow(); + // All values should be zero or NaN + const yearsVal = Number(mockEl.years.innerHTML); + const daysVal = Number(mockEl.days.innerHTML); + const hoursVal = Number(mockEl.hours.innerHTML); + const minutesVal = Number(mockEl.minutes.innerHTML); + const secondsVal = Number(mockEl.seconds.innerHTML); + expect(yearsVal === 0 || Number.isNaN(yearsVal)).toBe(true); + expect(daysVal === 0 || Number.isNaN(daysVal)).toBe(true); + expect(hoursVal === 0 || Number.isNaN(hoursVal)).toBe(true); + expect(minutesVal === 0 || Number.isNaN(minutesVal)).toBe(true); + expect(secondsVal === 0 || Number.isNaN(secondsVal)).toBe(true); + jest.useRealTimers(); + }); + + test("handles missing DOM elements gracefully", () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("Sep 13, 2025 12:00:00")); + // getElementById returns null + global.document.getElementById = jest.fn(() => null); + expect(() => { + script.countUpFromTime("Sep 13, 2025 12:00:00", "countup1"); + }).not.toThrow(); + jest.useRealTimers(); + }); + + test("calls clearTimeout when resetting interval", () => { + const mockEl = createMockDOM(); + global.document.getElementById = jest.fn(() => mockEl); + const clearTimeoutSpy = jest.spyOn(global, "clearTimeout"); + script.countUpFromTime("Sep 13, 2025 11:59:00", "countup1"); + expect(clearTimeoutSpy).toHaveBeenCalled(); + clearTimeoutSpy.mockRestore(); + }); + + test("calculates years, days, hours, minutes, seconds correctly for normal date", () => { + const mockEl = createMockDOM(); + global.document.getElementById = jest.fn(() => mockEl); + // Use a fixed date for now + // Use real Date objects + script.countUpFromTime("Oct 21, 2001 17:00:00", "countup1"); + // Years since 2001-10-21 to now + const start = new Date("Oct 21, 2001 17:00:00"); + const now = new Date(); + let years = now.getFullYear() - start.getFullYear(); + if ( + now.getMonth() < start.getMonth() || + (now.getMonth() === start.getMonth() && now.getDate() < start.getDate()) + ) { + years--; + } + expect(Number(mockEl.years.innerHTML)).toBeGreaterThanOrEqual(years); + expect(Number(mockEl.days.innerHTML)).toBeGreaterThanOrEqual(0); + expect(Number(mockEl.hours.innerHTML)).toBeGreaterThanOrEqual(0); + expect(Number(mockEl.minutes.innerHTML)).toBeGreaterThanOrEqual(0); + expect(Number(mockEl.seconds.innerHTML)).toBeGreaterThanOrEqual(0); + expect(mockTimeout).toHaveBeenCalled(); + }); + + test("handles leap years correctly", () => { + const mockEl = createMockDOM(); + global.document.getElementById = jest.fn(() => mockEl); + // Leap year: 2004 + script.countUpFromTime("Jan 1, 2004 00:00:00", "countup1"); + // Should be years since 2004-01-01 to now + const start = new Date("Jan 1, 2004 00:00:00"); + const now = new Date(); + let years = now.getFullYear() - start.getFullYear(); + // If current date is before Jan 1, subtract one year + if ( + now.getMonth() < start.getMonth() || + (now.getMonth() === start.getMonth() && now.getDate() < start.getDate()) + ) { + years--; + } + expect(Number(mockEl.years.innerHTML)).toBe(years); + expect(Number(mockEl.days.innerHTML)).toBeGreaterThanOrEqual(0); + }); + + test("handles DST changes", () => { + const mockEl = createMockDOM(); + global.document.getElementById = jest.fn(() => mockEl); + // DST change: last Sunday in March (Europe) + script.countUpFromTime("Mar 27, 2022 01:59:59", "countup1"); + expect(Number(mockEl.hours.innerHTML)).toBeGreaterThanOrEqual(0); + }); + + test("covers for-loop else branch (break) when remainingDays < daysInYear", () => { + // Freeze time to a known date so the loop runs exactly once and hits the else branch + jest.useFakeTimers(); + jest.setSystemTime(new Date("Nov 01, 2025 12:00:00")); + const mockEl = createMockDOM(); + global.document.getElementById = jest.fn(() => mockEl); + // Choose a date in the previous year but with remaining days less than a full year + script.countUpFromTime("Dec 31, 2024 00:00:00", "countup1"); + // The function should have written numeric values to the DOM + expect(Number(mockEl.years.innerHTML)).toBeGreaterThanOrEqual(0); + expect(Number(mockEl.days.innerHTML)).toBeGreaterThanOrEqual(0); + jest.useRealTimers(); + }); + + test("updates DOM elements with correct values", () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("Sep 13, 2025 11:59:00")); + const mockEl = createMockDOM(); + global.document.getElementById = jest.fn(() => mockEl); + script.countUpFromTime("Sep 13, 2025 11:59:00", "countup1"); + expect(mockEl.years.innerHTML).toBe(0); + expect(Number(mockEl.days.innerHTML)).toBe(0); + expect(Number(mockEl.hours.innerHTML)).toBe(0); + expect(Number(mockEl.minutes.innerHTML)).toBe(0); + expect(Number(mockEl.seconds.innerHTML)).toBe(0); + jest.useRealTimers(); + }); + + test("clears previous interval and sets new one", () => { + const mockEl = createMockDOM(); + global.document.getElementById = jest.fn(() => mockEl); + script.countUpFromTime("Sep 13, 2025 11:59:00", "countup1"); + expect(mockTimeout).toHaveBeenCalled(); + }); + + test("handles edge case: same date", () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("Sep 13, 2025 12:00:00")); + const mockEl = createMockDOM(); + global.document.getElementById = jest.fn(() => mockEl); + script.countUpFromTime("Sep 13, 2025 12:00:00", "countup1"); + expect(mockEl.years.innerHTML).toBe(0); + expect(mockEl.days.innerHTML).toBe(0); + jest.useRealTimers(); + }); }); -describe('window.onload coverage', () => { - let script; - let originalGetElementById; - let mockTimeout; - - beforeAll(() => { - script = require('../src/script.js'); - }); - - beforeEach(() => { - originalGetElementById = global.document.getElementById; - global.document.getElementById = jest.fn(() => createMockDOM()); - mockTimeout = jest.fn(); - global.setTimeout = mockTimeout; - }); - - afterEach(() => { - global.document.getElementById = originalGetElementById; - }); - - test('covers window.onload and initial countUpFromTime call', () => { - // Simulate window.onload - if (typeof script.window !== 'undefined' && typeof script.window.onload === 'function') { - script.window.onload(); - } else if (typeof global.window !== 'undefined' && typeof global.window.onload === 'function') { - global.window.onload(); - } else { - // Directly call the function as fallback - script.countUpFromTime("Oct 21, 2001 17:00:00", 'countup1'); - } - expect(global.document.getElementById).toHaveBeenCalledWith('countup1'); - }); -}); \ No newline at end of file +describe("window.onload coverage", () => { + let script; + let originalGetElementById; + let mockTimeout; + + beforeAll(() => { + script = require("../src/script.js"); + }); + + beforeEach(() => { + originalGetElementById = global.document.getElementById; + global.document.getElementById = jest.fn(() => createMockDOM()); + mockTimeout = jest.fn(); + global.setTimeout = mockTimeout; + }); + + afterEach(() => { + global.document.getElementById = originalGetElementById; + }); + + test("covers window.onload and initial countUpFromTime call", () => { + // Simulate window.onload + if ( + typeof script.window !== "undefined" && + typeof script.window.onload === "function" + ) { + script.window.onload(); + } else if ( + typeof global.window !== "undefined" && + typeof global.window.onload === "function" + ) { + global.window.onload(); + } else { + // Directly call the function as fallback + script.countUpFromTime("Oct 21, 2001 17:00:00", "countup1"); + } + expect(global.document.getElementById).toHaveBeenCalledWith("countup1"); + }); +});