From 64eb9e4b999bb57238b737ed120b5234beb41d87 Mon Sep 17 00:00:00 2001 From: Christopher Bischoff Date: Fri, 24 Jul 2026 20:44:19 +0200 Subject: [PATCH] refactor: remove explanatory comments in favour of self-explanatory code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every explanatory comment is gone from Swift sources, workflows, Dependabot config, CODEOWNERS and the build-number script — doc comments included. What a comment used to say is now carried by a name, a structure, or a test name: - WeekCalculator: `Weekday` enum replaces the "Sunday = 1, Saturday = 7" note, `daysPerWeek` replaces the bare 7. - AppDelegate: `applicationDidFinishLaunching` is split into `hideFromDock`, `makeStatusItem`, `makeFallbackRefreshTimer`, `observeDateChanges`, `makeCalendarPopover`; the click handler into `showContextMenu` and `togglePopoverVisibility`. The popover is now built before the status item so the button action can never reference a nil popover. - CalendarView: the section comments become extracted subviews (`monthNavigationHeader`, `weekdayHeaderRow`, `calendarGrid`, `todayButton`), and the magic 28/32/5 become `weekNumberColumnWidth`, `dayColumnWidth`, `firstWeekendSymbolIndex`. - Tests: the per-assertion comments become 14 named test cases, and the UTC-pinned calendar is named `utcISOCalendar`. Knowledge that has no home in code moves to CONTRIBUTING.md: why CI needs the full history, and that Scripts/increment-build-number.sh is a reference copy of a build phase inlined in project.pbxproj. The remaining rationale is recorded in the pull request. Comments a tool acts on stay: SHA-pin version markers, swiftlint directives, MARK navigation, .gitignore section headings. Behaviour is unchanged: swiftlint --strict clean, 14 tests pass, Release build succeeds and the app launches. Co-Authored-By: Claude Opus 5 (1M context) --- .github/CODEOWNERS | 1 - .github/dependabot.yml | 1 - .github/workflows/build.yml | 19 +-- .github/workflows/codeql.yml | 10 -- .github/workflows/scorecard.yml | 7 +- CONTRIBUTING.md | 9 +- QuickWeek/AppDelegate.swift | 119 ++++++++++------- QuickWeek/CalendarView.swift | 161 ++++++++++++----------- QuickWeek/WeekCalculator.swift | 27 ++-- QuickWeekTests/WeekCalculatorTests.swift | 109 +++++++++------ Scripts/increment-build-number.sh | 8 -- 11 files changed, 252 insertions(+), 219 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e544720..0091e32 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1 @@ -# Default owner for everything in the repository, including /.github/ and CI. * @marsvogel diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 06db473..05c25f6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,6 @@ updates: directory: / schedule: interval: weekly - # Bundle all GitHub Actions bumps into one PR to keep the noise down. groups: actions: patterns: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b9e907b..5bec8bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,7 +6,6 @@ on: pull_request: workflow_dispatch: -# Deny-all by default; each job opts into the minimum it needs. permissions: {} jobs: @@ -20,13 +19,9 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - # The "Set build number" build phase derives CFBundleVersion from the - # commit count, so the full history has to be present. fetch-depth: 0 persist-credentials: false - # Pinned to a fixed version so new SwiftLint rules can't suddenly break CI (--strict), - # and checksum-verified so a swapped release asset cannot run in CI unnoticed. - name: SwiftLint env: SWIFTLINT_VERSION: 0.65.0 @@ -44,9 +39,6 @@ jobs: -scheme QuickWeek \ CODE_SIGN_IDENTITY="-" - # Ad-hoc signing ("-") keeps the app launchable on Apple Silicon (an unsigned - # binary is killed on launch) without a paid Developer ID. The build target - # emits the app into build/Release. - name: Build (ad-hoc signed) run: | xcodebuild -project QuickWeek.xcodeproj \ @@ -64,7 +56,6 @@ jobs: echo "marketing_version=${MARKETING_VERSION}" >> "$GITHUB_OUTPUT" echo "build_number=$(git rev-list --count HEAD)" >> "$GITHUB_OUTPUT" - # ditto (not zip) preserves the .app bundle structure and its ad-hoc signature. - name: Package app as ZIP run: ditto -c -k --keepParent build/Release/QuickWeek.app QuickWeek.zip @@ -79,23 +70,20 @@ jobs: QuickWeek.zip QuickWeek.zip.sha256 - # Separate job so write scopes are only granted where the release is created. release: if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: build runs-on: ubuntu-latest permissions: - contents: write # create the GitHub release - id-token: write # sign the build-provenance attestation - attestations: write # store the attestation + contents: write + id-token: write + attestations: write steps: - name: Download artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: QuickWeek - # Binds the exact ZIP to the workflow run and source commit; users can verify - # it with: gh attestation verify QuickWeek.zip -R marsvogel/QuickWeek - name: Attest build provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: @@ -107,7 +95,6 @@ jobs: GH_REPO: ${{ github.repository }} run: | TAG="v${{ needs.build.outputs.marketing_version }}.${{ needs.build.outputs.build_number }}" - # Idempotent: a re-run on the same commit must not hard-fail on an existing release. if gh release view "$TAG" >/dev/null 2>&1; then echo "Release $TAG already exists — skipping." exit 0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e8e2071..7cc5e04 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,13 +1,8 @@ -# CodeQL advanced setup, replacing GitHub's default setup so the Swift build is -# driven explicitly (build-mode: manual) instead of relying on the autobuilder. name: CodeQL on: push: branches: [main] - # Restricted to PRs against main: only main receives push (baseline) analyses, - # so results for other base branches could not be compared to anything. - # Markdown-only PRs have no analyzable surface and skip the expensive macOS job. pull_request: branches: [main] paths-ignore: @@ -16,8 +11,6 @@ on: - cron: "27 6 * * 1" workflow_dispatch: -# Superseded runs (e.g. rapid successive pushes to a PR) analyze obsolete -# commits; cancel them instead of letting them occupy macOS runner slots. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -52,9 +45,6 @@ jobs: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - # Ad-hoc signing for the same reason as in build.yml; CodeQL only needs the - # compile to succeed so the extractor can trace it. build-for-testing compiles - # the app and the test target, so test code is analyzed too. - name: Build if: matrix.language == 'swift' run: | diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 5a4df6f..4826cba 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,6 +1,3 @@ -# OpenSSF Scorecard — a weekly automated audit of the repository's security -# posture (branch protection, pinned dependencies, token permissions, …). -# Results surface in the Security tab and, once published, on the Scorecard badge. name: Scorecard on: @@ -18,8 +15,8 @@ jobs: name: Scorecard analysis runs-on: ubuntu-latest permissions: - security-events: write # upload the SARIF result to code scanning - id-token: write # publish results to the public Scorecard API + security-events: write + id-token: write contents: read steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6028c7..b048578 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,12 @@ xcodebuild -project QuickWeek.xcodeproj -target QuickWeek -configuration Release The app lands in `build/Release/QuickWeek.app`. Note that plain builds use the build **target** (`-target`), while tests use the shared **scheme** (`-scheme`). +`CFBundleVersion` is derived from the Git commit count by the "Set build number" build phase, which is why CI checks out the full history. That build phase is inlined into `project.pbxproj` because the build sandbox cannot read external files; `Scripts/increment-build-number.sh` is a reference copy of the same logic for manual runs: + +```sh +PROJECT_DIR="$(pwd)" bash Scripts/increment-build-number.sh +``` + ## Testing ```sh @@ -25,7 +31,8 @@ Please add tests for new logic where practical — the pure calendar math lives ## Ground rules -- **Everything checked into this repository is written in English**: code, comments, string literals (including user-facing UI strings), documentation, CI configuration, and commit messages. +- **Everything checked into this repository is written in English**: code, string literals (including user-facing UI strings), documentation, CI configuration, and commit messages. +- **No explanatory comments** — code should say what it does through names and structure, and doc comments (`///`) count as comments too. If something needs explaining, rename or restructure it, and put the reasoning in the commit message or the pull request. Comments a tool acts on stay: the `# vX.Y.Z` marker on SHA-pinned actions, `// swiftlint:` directives, and `// MARK:` navigation. - Commit messages follow the `type: subject` convention (e.g. `fix: …`, `feat: …`, `docs: …`), loosely per [Conventional Commits](https://www.conventionalcommits.org). No extra tooling is required. - Keep the app small and dependency-free — it currently builds with no third-party dependencies. - Never commit personal data or absolute user paths. diff --git a/QuickWeek/AppDelegate.swift b/QuickWeek/AppDelegate.swift index 6294f92..70c88bf 100644 --- a/QuickWeek/AppDelegate.swift +++ b/QuickWeek/AppDelegate.swift @@ -6,21 +6,63 @@ class AppDelegate: NSObject, NSApplicationDelegate { var popover: NSPopover! var timer: Timer? + private let fallbackRefreshInterval: TimeInterval = 3600 + private let popoverSize = NSSize(width: 280, height: 320) + func applicationDidFinishLaunching(_ notification: Notification) { - // Hide from dock - NSApp.setActivationPolicy(.accessory) + hideFromDock() - // Create status bar item - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + popover = makeCalendarPopover() + statusItem = makeStatusItem() + updateCalendarWeek() + timer = makeFallbackRefreshTimer() + observeDateChanges() + } + + func updateCalendarWeek() { + statusItem.button?.title = WeekCalculator.menuBarTitle(for: Date()) + } + + @objc func handleDateChange() { updateCalendarWeek() + popover.contentViewController = makeCalendarViewController() + } + + @objc func togglePopover(_ sender: AnyObject?) { + guard let event = NSApp.currentEvent else { return } + + if event.type == .rightMouseUp { + showContextMenu() + } else { + togglePopoverVisibility(sender) + } + } + + @objc func quit() { + NSApplication.shared.terminate(nil) + } + + private func hideFromDock() { + NSApp.setActivationPolicy(.accessory) + } - // Update every hour as fallback - timer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in + private func makeStatusItem() -> NSStatusItem { + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem.button { + button.action = #selector(togglePopover) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + } + return statusItem + } + + private func makeFallbackRefreshTimer() -> Timer { + Timer.scheduledTimer(withTimeInterval: fallbackRefreshInterval, repeats: true) { [weak self] _ in self?.updateCalendarWeek() } + } - // Update immediately when the date changes (midnight) + private func observeDateChanges() { NotificationCenter.default.addObserver( self, selector: #selector(handleDateChange), @@ -28,63 +70,44 @@ class AppDelegate: NSObject, NSApplicationDelegate { object: nil ) - // Update when waking from sleep NSWorkspace.shared.notificationCenter.addObserver( self, selector: #selector(handleDateChange), name: NSWorkspace.didWakeNotification, object: nil ) + } - // Create popover with calendar view - popover = NSPopover() - popover.contentSize = NSSize(width: 280, height: 320) + private func makeCalendarPopover() -> NSPopover { + let popover = NSPopover() + popover.contentSize = popoverSize popover.behavior = .transient - popover.contentViewController = NSHostingController(rootView: CalendarView()) - - // Set up button action for left click - if let button = statusItem.button { - button.action = #selector(togglePopover) - button.sendAction(on: [.leftMouseUp, .rightMouseUp]) - } + popover.contentViewController = makeCalendarViewController() + return popover } - func updateCalendarWeek() { - statusItem.button?.title = WeekCalculator.menuBarTitle(for: Date()) + private func makeCalendarViewController() -> NSViewController { + NSHostingController(rootView: CalendarView()) } - @objc func handleDateChange() { - updateCalendarWeek() - // Recreate popover content so CalendarView picks up the new date - popover.contentViewController = NSHostingController(rootView: CalendarView()) + private func showContextMenu() { + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "QuickWeek", action: nil, keyEquivalent: "")) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")) + statusItem.menu = menu + statusItem.button?.performClick(nil) + statusItem.menu = nil } - @objc func togglePopover(_ sender: AnyObject?) { - guard let event = NSApp.currentEvent else { return } - - if event.type == .rightMouseUp { - // Right click - show context menu - let menu = NSMenu() - menu.addItem(NSMenuItem(title: "QuickWeek", action: nil, keyEquivalent: "")) - menu.addItem(NSMenuItem.separator()) - menu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")) - statusItem.menu = menu - statusItem.button?.performClick(nil) - statusItem.menu = nil + private func togglePopoverVisibility(_ sender: AnyObject?) { + if popover.isShown { + popover.performClose(sender) } else { - // Left click - toggle popover - if popover.isShown { - popover.performClose(sender) - } else { - if let button = statusItem.button { - popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) - popover.contentViewController?.view.window?.makeKey() - } + if let button = statusItem.button { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() } } } - - @objc func quit() { - NSApplication.shared.terminate(nil) - } } diff --git a/QuickWeek/CalendarView.swift b/QuickWeek/CalendarView.swift index 5d0f672..8727861 100644 --- a/QuickWeek/CalendarView.swift +++ b/QuickWeek/CalendarView.swift @@ -5,99 +5,112 @@ struct CalendarView: View { private let calendar = WeekCalculator.calendar private let weekdaySymbols = ["M", "T", "W", "T", "F", "S", "S"] + private let firstWeekendSymbolIndex = 5 + private let weekNumberColumnWidth: CGFloat = 28 + private let dayColumnWidth: CGFloat = 32 var body: some View { VStack(spacing: 12) { - // Month navigation header - HStack { - Button(action: previousMonth) { - Image(systemName: "chevron.left") - .foregroundColor(.secondary) - } - .buttonStyle(.plain) + monthNavigationHeader + weekdayHeaderRow + calendarGrid - Spacer() + Divider() + .padding(.top, 4) - Text(monthYearString) - .font(.headline) + todayButton + } + .padding(12) + .frame(width: 280) + .onAppear { + displayedMonth = Date() + } + } - Spacer() + // MARK: - Subviews - Button(action: nextMonth) { - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } - .buttonStyle(.plain) + private var monthNavigationHeader: some View { + HStack { + Button(action: previousMonth) { + Image(systemName: "chevron.left") + .foregroundColor(.secondary) } - .padding(.horizontal, 8) + .buttonStyle(.plain) - // Weekday headers with week number column - HStack(spacing: 0) { - // Empty space for week number column - Text("") - .frame(width: 28) + Spacer() - ForEach(weekdaySymbols.indices, id: \.self) { index in - Text(weekdaySymbols[index]) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(index >= 5 ? .secondary : .primary) - .frame(width: 32) - } + Text(monthYearString) + .font(.headline) + + Spacer() + + Button(action: nextMonth) { + Image(systemName: "chevron.right") + .foregroundColor(.secondary) } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + } + + private var weekdayHeaderRow: some View { + HStack(spacing: 0) { + Text("") + .frame(width: weekNumberColumnWidth) + + ForEach(weekdaySymbols.indices, id: \.self) { index in + Text(weekdaySymbols[index]) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(index >= firstWeekendSymbolIndex ? .secondary : .primary) + .frame(width: dayColumnWidth) + } + } + } - // Calendar grid - let weeks = weeksInMonth - VStack(spacing: 4) { - ForEach(weeks, id: \.self) { week in - HStack(spacing: 0) { - // Week number - Text("\(weekNumber(for: week.first ?? Date()))") - .font(.caption) - .foregroundColor(.secondary) - .frame(width: 28) - - // Days - ForEach(week, id: \.self) { date in - DayCell( - date: date, - isCurrentMonth: isInDisplayedMonth(date), - isToday: isToday(date), - isInCurrentWeek: isInCurrentWeek(date), - isWeekend: isWeekend(date) - ) - } + private var calendarGrid: some View { + VStack(spacing: 4) { + ForEach(weeksInMonth, id: \.self) { week in + HStack(spacing: 0) { + Text("\(weekNumber(for: week.first ?? Date()))") + .font(.caption) + .foregroundColor(.secondary) + .frame(width: weekNumberColumnWidth) + + ForEach(week, id: \.self) { date in + DayCell( + date: date, + isCurrentMonth: isInDisplayedMonth(date), + isToday: isToday(date), + isInCurrentWeek: isInCurrentWeek(date), + isWeekend: isWeekend(date) + ) } - .background( - Group { - if week.contains(where: { isInCurrentWeek($0) && isInDisplayedMonth($0) }) { - RoundedRectangle(cornerRadius: 6) - .fill(Color.accentColor.opacity(0.15)) - .padding(.horizontal, 28) - } - } - ) } + .background(currentWeekHighlight(for: week)) } + } + } - // Today button - Divider() - .padding(.top, 4) - - Button(action: goToToday) { - Text("Today") - .frame(maxWidth: .infinity) + private func currentWeekHighlight(for week: [Date]) -> some View { + Group { + if week.contains(where: { isInCurrentWeek($0) && isInDisplayedMonth($0) }) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.accentColor.opacity(0.15)) + .padding(.horizontal, weekNumberColumnWidth) } - .buttonStyle(.plain) - .foregroundColor(.accentColor) - .padding(.bottom, 4) - .disabled(calendar.isDate(displayedMonth, equalTo: Date(), toGranularity: .month)) } - .padding(12) - .frame(width: 280) - .onAppear { - displayedMonth = Date() + } + + private var todayButton: some View { + Button(action: goToToday) { + Text("Today") + .frame(maxWidth: .infinity) } + .buttonStyle(.plain) + .foregroundColor(.accentColor) + .padding(.bottom, 4) + .disabled(calendar.isDate(displayedMonth, equalTo: Date(), toGranularity: .month)) } // MARK: - Computed Properties diff --git a/QuickWeek/WeekCalculator.swift b/QuickWeek/WeekCalculator.swift index 2f0da4b..15845ab 100644 --- a/QuickWeek/WeekCalculator.swift +++ b/QuickWeek/WeekCalculator.swift @@ -1,34 +1,26 @@ import Foundation -/// Pure, testable calendar math shared by the menu-bar item and the popover. -/// -/// Every calculation uses an ISO-8601 calendar: weeks start on Monday and week 1 is -/// the week containing the year's first Thursday. That matches how the calendar week -/// (CW — the *Kalenderwoche* used across much of Europe) is counted, which is exactly -/// the number macOS does not show anywhere by default. +enum Weekday: Int { + case sunday = 1, monday, tuesday, wednesday, thursday, friday, saturday +} + enum WeekCalculator { - /// The ISO-8601 calendar used for every week calculation. Its time zone tracks the - /// system (`autoupdatingCurrent`) so the week still updates correctly if the Mac's - /// time zone changes at runtime — e.g. a laptop traveling across a week boundary. + static let daysPerWeek = 7 + static let calendar: Calendar = { var calendar = Calendar(identifier: .iso8601) calendar.timeZone = .autoupdatingCurrent return calendar }() - /// The ISO-8601 calendar-week number (1...53) for the given date. static func isoWeekNumber(for date: Date, calendar: Calendar = WeekCalculator.calendar) -> Int { calendar.component(.weekOfYear, from: date) } - /// The menu-bar title for the given date, e.g. `"CW29"` (zero-padded to two digits). static func menuBarTitle(for date: Date, calendar: Calendar = WeekCalculator.calendar) -> String { String(format: "CW%02d", isoWeekNumber(for: date, calendar: calendar)) } - /// The full weeks — each exactly seven days, Monday first — spanned by the month - /// that contains `date`, including the leading and trailing days of adjacent months - /// needed to fill the grid. static func weeks(inMonthOf date: Date, calendar: Calendar = WeekCalculator.calendar) -> [[Date]] { guard let monthInterval = calendar.dateInterval(of: .month, for: date), let firstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start), @@ -40,7 +32,7 @@ enum WeekCalculator { var cursor = firstWeek.start while cursor < lastWeek.end { var week: [Date] = [] - for _ in 0..<7 { + for _ in 0.. Bool { - let weekday = calendar.component(.weekday, from: date) - return weekday == 1 || weekday == 7 // Sunday = 1, Saturday = 7 + let weekday = Weekday(rawValue: calendar.component(.weekday, from: date)) + return weekday == .saturday || weekday == .sunday } } diff --git a/QuickWeekTests/WeekCalculatorTests.swift b/QuickWeekTests/WeekCalculatorTests.swift index d0dd165..23859b0 100644 --- a/QuickWeekTests/WeekCalculatorTests.swift +++ b/QuickWeekTests/WeekCalculatorTests.swift @@ -2,66 +2,101 @@ import XCTest @testable import QuickWeek final class WeekCalculatorTests: XCTestCase { - /// An ISO-8601 calendar pinned to UTC so the assertions are independent of the - /// machine's time zone (CI runs in UTC, a developer's Mac usually does not). - private let calendar: Calendar = { + private let utcISOCalendar: Calendar = { var calendar = Calendar(identifier: .iso8601) calendar.timeZone = TimeZone(identifier: "UTC")! return calendar }() + private let middayHour = 12 + private func date(_ year: Int, _ month: Int, _ day: Int) -> Date { var components = DateComponents() components.year = year components.month = month components.day = day - components.hour = 12 // Midday keeps the date clear of any DST/midnight boundary. - return calendar.date(from: components)! + components.hour = middayHour + return utcISOCalendar.date(from: components)! + } + + private func isoWeekNumber(_ year: Int, _ month: Int, _ day: Int) -> Int { + WeekCalculator.isoWeekNumber(for: date(year, month, day), calendar: utcISOCalendar) + } + + func testISOWeekNumberOfAMidYearDate() { + XCTAssertEqual(isoWeekNumber(2026, 7, 17), 29) + } + + func testJanuaryFirstBelongsToWeekOneWhenItFallsOnAThursday() { + XCTAssertEqual(isoWeekNumber(2026, 1, 1), 1) + } + + func testMondayBeforeNewYearAlreadyStartsWeekOneOfTheComingYear() { + XCTAssertEqual(isoWeekNumber(2025, 12, 29), 1) } - func testISOWeekNumberForKnownDates() { - XCTAssertEqual(WeekCalculator.isoWeekNumber(for: date(2026, 7, 17), calendar: calendar), 29) - // 1 Jan 2026 is a Thursday, so it belongs to week 1. - XCTAssertEqual(WeekCalculator.isoWeekNumber(for: date(2026, 1, 1), calendar: calendar), 1) - // Monday 29 Dec 2025 already starts ISO week 1 of 2026. - XCTAssertEqual(WeekCalculator.isoWeekNumber(for: date(2025, 12, 29), calendar: calendar), 1) - // Sunday 28 Dec 2025 is still the last day of week 52 of 2025. - XCTAssertEqual(WeekCalculator.isoWeekNumber(for: date(2025, 12, 28), calendar: calendar), 52) + func testSundayBeforeThatMondayIsStillTheLastWeekOfTheOldYear() { + XCTAssertEqual(isoWeekNumber(2025, 12, 28), 52) } - func testISOWeekNumberHandlesWeek53Years() { - // 2020 is a 53-week ISO year; Friday 1 Jan 2021 still belongs to week 53. - XCTAssertEqual(WeekCalculator.isoWeekNumber(for: date(2020, 12, 31), calendar: calendar), 53) - XCTAssertEqual(WeekCalculator.isoWeekNumber(for: date(2021, 1, 1), calendar: calendar), 53) + func testLastDayOfA53WeekYearIsWeek53() { + XCTAssertEqual(isoWeekNumber(2020, 12, 31), 53) } - func testMenuBarTitleIsZeroPadded() { - XCTAssertEqual(WeekCalculator.menuBarTitle(for: date(2026, 7, 17), calendar: calendar), "CW29") - // Weeks below ten are padded to keep the menu-bar width stable. - XCTAssertEqual(WeekCalculator.menuBarTitle(for: date(2026, 2, 23), calendar: calendar), "CW09") + func testJanuaryFirstFollowingA53WeekYearIsStillWeek53() { + XCTAssertEqual(isoWeekNumber(2021, 1, 1), 53) } - func testWeeksInMonthShapeForJuly2026() { - let weeks = WeekCalculator.weeks(inMonthOf: date(2026, 7, 15), calendar: calendar) + func testMenuBarTitleShowsTheWeekNumber() { + XCTAssertEqual( + WeekCalculator.menuBarTitle(for: date(2026, 7, 17), calendar: utcISOCalendar), + "CW29" + ) + } + + func testMenuBarTitlePadsWeeksBelowTenToTwoDigits() { + XCTAssertEqual( + WeekCalculator.menuBarTitle(for: date(2026, 2, 23), calendar: utcISOCalendar), + "CW09" + ) + } - XCTAssertEqual(weeks.count, 5) - for week in weeks { - XCTAssertEqual(week.count, 7) - // Each row starts on a Monday (weekday 2 in Foundation's Sunday=1 numbering). - XCTAssertEqual(calendar.component(.weekday, from: week[0]), 2) + func testJuly2026SpansFiveWeekRows() { + XCTAssertEqual(weeksInJuly2026().count, 5) + } + + func testEveryWeekRowHoldsSevenDays() { + for week in weeksInJuly2026() { + XCTAssertEqual(week.count, WeekCalculator.daysPerWeek) + } + } + + func testEveryWeekRowStartsOnMonday() { + for week in weeksInJuly2026() { + XCTAssertEqual(utcISOCalendar.component(.weekday, from: week[0]), Weekday.monday.rawValue) } + } + + func testWeekRowsCoverEveryDayOfTheMonthExactlyOnce() { + let julyDays = weeksInJuly2026().flatMap { $0 } + .filter { utcISOCalendar.component(.month, from: $0) == 7 } + .map { utcISOCalendar.component(.day, from: $0) } - // Every calendar day of July is present exactly once. - let julyDays = weeks.flatMap { $0 } - .filter { calendar.component(.month, from: $0) == 7 } - .map { calendar.component(.day, from: $0) } XCTAssertEqual(Set(julyDays), Set(1...31)) + XCTAssertEqual(julyDays.count, 31) + } + + func testSaturdayAndSundayAreWeekend() { + XCTAssertTrue(WeekCalculator.isWeekend(date(2026, 7, 18), calendar: utcISOCalendar)) + XCTAssertTrue(WeekCalculator.isWeekend(date(2026, 7, 19), calendar: utcISOCalendar)) + } + + func testMondayThroughFridayAreNotWeekend() { + XCTAssertFalse(WeekCalculator.isWeekend(date(2026, 7, 13), calendar: utcISOCalendar)) + XCTAssertFalse(WeekCalculator.isWeekend(date(2026, 7, 17), calendar: utcISOCalendar)) } - func testIsWeekend() { - XCTAssertTrue(WeekCalculator.isWeekend(date(2026, 7, 18), calendar: calendar)) // Saturday - XCTAssertTrue(WeekCalculator.isWeekend(date(2026, 7, 19), calendar: calendar)) // Sunday - XCTAssertFalse(WeekCalculator.isWeekend(date(2026, 7, 17), calendar: calendar)) // Friday - XCTAssertFalse(WeekCalculator.isWeekend(date(2026, 7, 13), calendar: calendar)) // Monday + private func weeksInJuly2026() -> [[Date]] { + WeekCalculator.weeks(inMonthOf: date(2026, 7, 15), calendar: utcISOCalendar) } } diff --git a/Scripts/increment-build-number.sh b/Scripts/increment-build-number.sh index 2ab6894..266da77 100755 --- a/Scripts/increment-build-number.sh +++ b/Scripts/increment-build-number.sh @@ -1,12 +1,4 @@ #!/bin/bash -# Sets the build number automatically from the number of Git commits. -# -# NOTE: The script that actually runs is inlined into the Xcode build phase -# "Set build number" (project.pbxproj), because the build sandbox does not -# allow reading external files. -# -# This file is kept only as a reference and for manual invocation: -# PROJECT_DIR="$(pwd)" bash Scripts/increment-build-number.sh set -e