Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions BitwardenKit/Core/Platform/Extensions/Date.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ public extension Date {
return "\(dateString), \(timeString)"
}

/// The date formatted as an ISO 8601 calendar-date string (`yyyy-MM-dd`), e.g. `2023-06-23`.
///
/// This is date-only, with no time component, suitable for storing day/month/year fields such
/// as a driver's license expiration or a passport date of birth.
var iso8601DateOnlyString: String {
Date.iso8601DateOnlyFormatter().string(from: self)
}

/// A convenience initializer for `Date` to specify a specific point in time.
init(
year: Int,
Expand All @@ -42,6 +50,18 @@ public extension Date {
self = dateComponents.date!
}

/// Creates a `Date` from an ISO 8601 calendar-date string (`yyyy-MM-dd`).
///
/// Returns `nil` if the string isn't a valid calendar date (e.g. `2023-02-30`), making it safe
/// to use when converting stored string fields back into dates.
///
/// - Parameter iso8601DateOnlyString: The calendar-date string to parse, e.g. `2023-06-23`.
///
init?(iso8601DateOnlyString string: String) {
guard let date = Date.iso8601DateOnlyFormatter().date(from: string) else { return nil }
self = date
}

// MARK: Methods

/// Returns a date that is set to midnight on the day that is seven days in the future.
Expand All @@ -53,3 +73,20 @@ public extension Date {
return date
}
}

private extension Date {
/// Builds a strict, locale-independent formatter for ISO 8601 calendar dates (`yyyy-MM-dd`).
///
/// A fresh formatter is returned on each call to avoid sharing a non-`Sendable` instance across
/// concurrency domains. Uses the POSIX locale and UTC time zone so output and parsing are
/// deterministic regardless of the device locale, and disables lenient parsing so invalid dates
/// such as `2023-02-30` fail to parse rather than rolling over.
static func iso8601DateOnlyFormatter() -> DateFormatter {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€” How is this different from the built-in ISO8601DateFormatter?

let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
formatter.dateFormat = "yyyy-MM-dd"
formatter.isLenient = false
return formatter
}
}
43 changes: 43 additions & 0 deletions BitwardenKit/Core/Platform/Extensions/DateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import BitwardenKit
import XCTest

class DateTests: BitwardenTestCase {
// MARK: Tests

/// `iso8601DateOnlyString` formats a date as a `yyyy-MM-dd` calendar-date string.
func test_iso8601DateOnlyString() {
let date = Date(year: 2023, month: 6, day: 23)
XCTAssertEqual(date.iso8601DateOnlyString, "2023-06-23")
}

/// `iso8601DateOnlyString` zero-pads single-digit months and days.
func test_iso8601DateOnlyString_zeroPadded() {
let date = Date(year: 2024, month: 1, day: 5)
XCTAssertEqual(date.iso8601DateOnlyString, "2024-01-05")
}

/// `init(iso8601DateOnlyString:)` parses a valid calendar-date string into a date.
func test_initISO8601DateOnlyString_valid() {
let date = Date(iso8601DateOnlyString: "2023-06-23")
XCTAssertEqual(date, Date(year: 2023, month: 6, day: 23))
}

/// `init(iso8601DateOnlyString:)` round-trips with `iso8601DateOnlyString`.
func test_initISO8601DateOnlyString_roundTrip() {
let original = Date(year: 1999, month: 12, day: 31)
let parsed = Date(iso8601DateOnlyString: original.iso8601DateOnlyString)
XCTAssertEqual(parsed, original)
}

/// `init(iso8601DateOnlyString:)` returns `nil` for an impossible calendar date.
func test_initISO8601DateOnlyString_invalidDate() {
XCTAssertNil(Date(iso8601DateOnlyString: "2023-02-30"))
}

/// `init(iso8601DateOnlyString:)` returns `nil` for a malformed string.
func test_initISO8601DateOnlyString_malformed() {
XCTAssertNil(Date(iso8601DateOnlyString: ""))
XCTAssertNil(Date(iso8601DateOnlyString: "not-a-date"))
XCTAssertNil(Date(iso8601DateOnlyString: "06/23/2023"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// swiftlint:disable:this file_name
import SnapshotTesting
import SwiftUI
import XCTest

@testable import BitwardenKit

class DateFieldPickerSnapshotTests: BitwardenTestCase {
// MARK: Properties

/// A fixed date used so snapshots are deterministic.
let date = Date(year: 2023, month: 6, day: 23)

var subject: DateFieldPicker!

// MARK: Setup & Teardown

override func tearDown() {
super.tearDown()
subject = nil
}

// MARK: Tests

/// Test a snapshot of the collapsed empty field in light mode.
func disabletest_snapshot_collapsedEmpty_lightMode() {
subject = DateFieldPicker(title: "Date of birth", date: .constant(nil))

assertSnapshot(of: subject, as: .defaultPortrait)
}

/// Test a snapshot of the collapsed empty field in dark mode.
func disabletest_snapshot_collapsedEmpty_darkMode() {
subject = DateFieldPicker(title: "Date of birth", date: .constant(nil))

assertSnapshot(of: subject, as: .defaultPortraitDark)
}

/// Test a snapshot of the collapsed empty field with large dynamic type.
func disabletest_snapshot_collapsedEmpty_largeDynamicType() {
subject = DateFieldPicker(title: "Date of birth", date: .constant(nil))

assertSnapshot(of: subject, as: .defaultPortraitAX5)
}

/// Test a snapshot of a collapsed field with a selected date in light mode.
func disabletest_snapshot_collapsedSelected_lightMode() {
subject = DateFieldPicker(title: "Expiration date", date: .constant(date))

assertSnapshot(of: subject, as: .defaultPortrait)
}

/// Test a snapshot of a collapsed field with a selected date in dark mode.
func disabletest_snapshot_collapsedSelected_darkMode() {
subject = DateFieldPicker(title: "Expiration date", date: .constant(date))

assertSnapshot(of: subject, as: .defaultPortraitDark)
}

/// Test a snapshot of a collapsed field with a footer in light mode.
func disabletest_snapshot_withFooter_lightMode() {
subject = DateFieldPicker(
title: "Expiration date",
date: .constant(date),
footer: "The date this document expires.",
)

assertSnapshot(of: subject, as: .defaultPortrait)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// swiftlint:disable:this file_name
import BitwardenKit
import BitwardenResources
import SwiftUI
import ViewInspector
import XCTest

class DateFieldPickerTests: BitwardenTestCase {
// MARK: Properties

/// The default date used to seed the picker when a date is first selected.
let defaultDate = Date(year: 2023, month: 6, day: 23)

/// The backing value for the field's date binding.
var date: Date?

var subject: DateFieldPicker!

/// A binding to the test's backing `date` value.
private var bindingDate: Binding<Date?> {
Binding {
self.date
} set: { newValue in
self.date = newValue
}
}

// MARK: Setup & Teardown

override func setUp() {
super.setUp()
date = nil
subject = DateFieldPicker(
title: "Date of birth",
date: bindingDate,
defaultDate: defaultDate,
)
}

override func tearDown() {
super.tearDown()
date = nil
subject = nil
}

// MARK: Tests

/// When collapsed and empty, the field shows its title and no inline date picker.
func test_collapsedEmpty_showsTitleAndNoPicker() throws {
XCTAssertNoThrow(try subject.inspect().find(text: "Date of birth"))
XCTAssertThrowsError(try subject.inspect().find(ViewType.DatePicker.self))
}

/// When a date is selected, the collapsed field shows the formatted date.
func test_collapsedSelected_showsFormattedDate() throws {
date = defaultDate
let expected = defaultDate.formatted(date: .long, time: .omitted)
XCTAssertNoThrow(try subject.inspect().find(text: expected))
}

/// The collapsed header is a button so a single tap expands the picker.
func test_headerButton_exists() throws {
XCTAssertNoThrow(try subject.inspect().find(viewWithAccessibilityIdentifier: "DateFieldHeaderButton"))
}

/// The header button carries an accessibility hint telling VoiceOver users it selects a date.
func test_headerButton_hasSelectDateHint() throws {
let header = try subject.inspect().find(viewWithAccessibilityIdentifier: "DateFieldHeaderButton")
XCTAssertEqual(try header.accessibilityHint().string(), Localizations.selectDate)
}

/// The clear control's accessibility label names the field so VoiceOver users know what it clears.
func test_clearButton_accessibilityLabel_namesField() throws {
date = defaultDate
XCTAssertNoThrow(
try subject.inspect().find(viewWithAccessibilityLabel: Localizations.clearFieldName("Date of birth")),
)
}

/// When a date is selected, a clear control is shown and tapping it resets the value to `nil`.
func test_clearButton_clearsDate() throws {
date = defaultDate
let clearButton = try subject.inspect().find(viewWithAccessibilityIdentifier: "DateFieldClearButton")
try clearButton.button().tap()
XCTAssertNil(date)
}

/// No clear control is shown when the field is empty.
func test_clearButton_hiddenWhenEmpty() throws {
XCTAssertThrowsError(try subject.inspect().find(viewWithAccessibilityIdentifier: "DateFieldClearButton"))
}

/// A provided footer is rendered below the field.
func test_footer_isRendered() throws {
subject = DateFieldPicker(
title: "Date of birth",
date: bindingDate,
defaultDate: defaultDate,
footer: "A footer",
)
XCTAssertNoThrow(try subject.inspect().find(text: "A footer"))
}

/// The field applies the provided accessibility identifier.
func test_accessibilityIdentifier_custom() throws {
subject = DateFieldPicker(
title: "Date of birth",
accessibilityIdentifier: "DateOfBirthField",
date: bindingDate,
defaultDate: defaultDate,
)
XCTAssertNoThrow(try subject.inspect().find(viewWithAccessibilityIdentifier: "DateOfBirthField"))
}
}
Loading
Loading