Rebrand from Casera/MyCrib to honeyDue
Total rebrand across KMM project: - Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations) - Gradle: rootProject.name, namespace, applicationId - Android: manifest, strings.xml (all languages), widget resources - iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig - iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc. - Swift source: all class/struct/enum renames - Deep links: casera:// -> honeydue://, .casera -> .honeydue - App icons replaced with honeyDue honeycomb icon - Domains: casera.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - Database table names preserved Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
iosApp/HoneyDueTests/CaseraTests.swift
Normal file
16
iosApp/HoneyDueTests/CaseraTests.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// honeyDueTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Created by Trey Tartt on 11/12/25.
|
||||
//
|
||||
|
||||
import Testing
|
||||
|
||||
struct honeyDueTests {
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
|
||||
}
|
||||
572
iosApp/HoneyDueTests/DataLayerTests.swift
Normal file
572
iosApp/HoneyDueTests/DataLayerTests.swift
Normal file
@@ -0,0 +1,572 @@
|
||||
//
|
||||
// DataLayerTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for the DATA layer domain (DATA-001 through DATA-007).
|
||||
// Exercises Kotlin DataManager directly from Swift without launching the app.
|
||||
//
|
||||
// IMPORTANT: All suites that mutate DataManager (a shared singleton) are nested
|
||||
// inside a .serialized parent suite to prevent concurrent test interference.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import honeyDue
|
||||
import ComposeApp
|
||||
|
||||
// MARK: - Serialized Parent Suite (prevents concurrent DataManager mutations)
|
||||
|
||||
@Suite(.serialized)
|
||||
struct DataLayerTests {
|
||||
|
||||
// MARK: - DATA-004: Cache Validation Tests
|
||||
|
||||
@Suite struct CacheValidationTests {
|
||||
|
||||
@Test func cacheTimeZeroIsInvalid() {
|
||||
#expect(DataManager.shared.isCacheValid(cacheTime: 0, ttlMs: DataManager.shared.CACHE_TIMEOUT_MS) == false)
|
||||
}
|
||||
|
||||
@Test func recentCacheTimeIsValid() {
|
||||
// 5 minutes ago should be valid (well within the 1-hour timeout)
|
||||
let fiveMinutesAgo = Int64(Date().timeIntervalSince1970 * 1000) - (5 * 60 * 1000)
|
||||
#expect(DataManager.shared.isCacheValid(cacheTime: fiveMinutesAgo, ttlMs: DataManager.shared.CACHE_TIMEOUT_MS) == true)
|
||||
}
|
||||
|
||||
@Test func expiredCacheTimeIsInvalid() {
|
||||
// 2 hours ago should be invalid (past the 1-hour timeout)
|
||||
let twoHoursAgo = Int64(Date().timeIntervalSince1970 * 1000) - (2 * 60 * 60 * 1000)
|
||||
#expect(DataManager.shared.isCacheValid(cacheTime: twoHoursAgo, ttlMs: DataManager.shared.CACHE_TIMEOUT_MS) == false)
|
||||
}
|
||||
|
||||
@Test func cacheTimeoutConstantIsOneHour() {
|
||||
#expect(DataManager.shared.CACHE_TIMEOUT_MS == 3_600_000)
|
||||
}
|
||||
|
||||
@Test func allCacheTimestampsStartAtZeroAfterClear() {
|
||||
DataManager.shared.clear()
|
||||
#expect(DataManager.shared.residencesCacheTime == 0)
|
||||
#expect(DataManager.shared.myResidencesCacheTime == 0)
|
||||
#expect(DataManager.shared.tasksCacheTime == 0)
|
||||
#expect(DataManager.shared.contractorsCacheTime == 0)
|
||||
#expect(DataManager.shared.documentsCacheTime == 0)
|
||||
#expect(DataManager.shared.summaryCacheTime == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DATA-005: Clear & ClearUserData Tests
|
||||
|
||||
@Suite struct ClearTests {
|
||||
|
||||
@Test func clearResetsAllCacheTimestamps() {
|
||||
let cat = TaskCategory(id: 1, name: "Test", description: "", icon: "", color: "", displayOrder: 0)
|
||||
DataManager.shared.setTaskCategories(categories: [cat])
|
||||
|
||||
DataManager.shared.clear()
|
||||
|
||||
#expect(DataManager.shared.residencesCacheTime == 0)
|
||||
#expect(DataManager.shared.myResidencesCacheTime == 0)
|
||||
#expect(DataManager.shared.tasksCacheTime == 0)
|
||||
#expect(DataManager.shared.contractorsCacheTime == 0)
|
||||
#expect(DataManager.shared.documentsCacheTime == 0)
|
||||
#expect(DataManager.shared.summaryCacheTime == 0)
|
||||
}
|
||||
|
||||
@Test func clearEmptiesLookupLists() {
|
||||
// Populate all 5 lookup types
|
||||
DataManager.shared.setTaskCategories(categories: [
|
||||
TaskCategory(id: 1, name: "Cat", description: "", icon: "", color: "", displayOrder: 0)
|
||||
])
|
||||
DataManager.shared.setTaskPriorities(priorities: [
|
||||
TaskPriority(id: 1, name: "High", level: 1, color: "red", displayOrder: 0)
|
||||
])
|
||||
DataManager.shared.setTaskFrequencies(frequencies: [
|
||||
TaskFrequency(id: 1, name: "Weekly", days: nil, displayOrder: 0)
|
||||
])
|
||||
DataManager.shared.setResidenceTypes(types: [
|
||||
ResidenceType(id: 1, name: "House")
|
||||
])
|
||||
DataManager.shared.setContractorSpecialties(specialties: [
|
||||
ContractorSpecialty(id: 1, name: "Plumbing", description: nil, icon: nil, displayOrder: 0)
|
||||
])
|
||||
|
||||
DataManager.shared.clear()
|
||||
|
||||
let categories = DataManager.shared.taskCategories.value as! [TaskCategory]
|
||||
let priorities = DataManager.shared.taskPriorities.value as! [TaskPriority]
|
||||
let frequencies = DataManager.shared.taskFrequencies.value as! [TaskFrequency]
|
||||
let residenceTypes = DataManager.shared.residenceTypes.value as! [ResidenceType]
|
||||
let specialties = DataManager.shared.contractorSpecialties.value as! [ContractorSpecialty]
|
||||
|
||||
#expect(categories.isEmpty)
|
||||
#expect(priorities.isEmpty)
|
||||
#expect(frequencies.isEmpty)
|
||||
#expect(residenceTypes.isEmpty)
|
||||
#expect(specialties.isEmpty)
|
||||
}
|
||||
|
||||
@Test func clearResetsLookupsInitializedFlag() {
|
||||
DataManager.shared.markLookupsInitialized()
|
||||
let before = DataManager.shared.lookupsInitialized.value as! Bool
|
||||
#expect(before == true)
|
||||
|
||||
DataManager.shared.clear()
|
||||
|
||||
let after = DataManager.shared.lookupsInitialized.value as! Bool
|
||||
#expect(after == false)
|
||||
}
|
||||
|
||||
@Test func clearUserDataKeepsLookups() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
// Populate lookups
|
||||
DataManager.shared.setTaskCategories(categories: [
|
||||
TaskCategory(id: 1, name: "Plumbing", description: "", icon: "", color: "", displayOrder: 0)
|
||||
])
|
||||
DataManager.shared.markLookupsInitialized()
|
||||
|
||||
// Clear user data only
|
||||
DataManager.shared.clearUserData()
|
||||
|
||||
// Lookups should remain
|
||||
let categories = DataManager.shared.taskCategories.value as! [TaskCategory]
|
||||
#expect(categories.count == 1)
|
||||
#expect(categories.first?.name == "Plumbing")
|
||||
|
||||
let initialized = DataManager.shared.lookupsInitialized.value as! Bool
|
||||
#expect(initialized == true)
|
||||
|
||||
// Clean up
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func clearUserDataResetsCacheTimestamps() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
DataManager.shared.setTaskCategories(categories: [
|
||||
TaskCategory(id: 1, name: "Test", description: "", icon: "", color: "", displayOrder: 0)
|
||||
])
|
||||
|
||||
DataManager.shared.clearUserData()
|
||||
|
||||
#expect(DataManager.shared.residencesCacheTime == 0)
|
||||
#expect(DataManager.shared.myResidencesCacheTime == 0)
|
||||
#expect(DataManager.shared.tasksCacheTime == 0)
|
||||
#expect(DataManager.shared.contractorsCacheTime == 0)
|
||||
#expect(DataManager.shared.documentsCacheTime == 0)
|
||||
#expect(DataManager.shared.summaryCacheTime == 0)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DATA-001, DATA-007: Lookup Setter & O(1) Getter Tests
|
||||
|
||||
@Suite struct LookupSetterTests {
|
||||
|
||||
@Test func setTaskCategoriesPopulatesList() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
let categories = [
|
||||
TaskCategory(id: 1, name: "Plumbing", description: "Water", icon: "wrench", color: "blue", displayOrder: 1),
|
||||
TaskCategory(id: 2, name: "HVAC", description: "Air", icon: "fan", color: "green", displayOrder: 2)
|
||||
]
|
||||
DataManager.shared.setTaskCategories(categories: categories)
|
||||
|
||||
let stored = DataManager.shared.taskCategories.value as! [TaskCategory]
|
||||
#expect(stored.count == 2)
|
||||
#expect(stored[0].name == "Plumbing")
|
||||
#expect(stored[1].name == "HVAC")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func setTaskCategoriesBuildsMappedLookup() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
let categories = [
|
||||
TaskCategory(id: 10, name: "Electrical", description: "", icon: "", color: "", displayOrder: 0),
|
||||
TaskCategory(id: 20, name: "Landscaping", description: "", icon: "", color: "", displayOrder: 1)
|
||||
]
|
||||
DataManager.shared.setTaskCategories(categories: categories)
|
||||
|
||||
// Verify the O(1) map-based getter works
|
||||
let result10 = DataManager.shared.getTaskCategory(id: 10)
|
||||
let result20 = DataManager.shared.getTaskCategory(id: 20)
|
||||
#expect(result10?.name == "Electrical")
|
||||
#expect(result20?.name == "Landscaping")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func setTaskPrioritiesPopulatesListAndMap() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
let priorities = [
|
||||
TaskPriority(id: 1, name: "High", level: 3, color: "red", displayOrder: 0),
|
||||
TaskPriority(id: 2, name: "Medium", level: 2, color: "yellow", displayOrder: 1),
|
||||
TaskPriority(id: 3, name: "Low", level: 1, color: "green", displayOrder: 2)
|
||||
]
|
||||
DataManager.shared.setTaskPriorities(priorities: priorities)
|
||||
|
||||
let stored = DataManager.shared.taskPriorities.value as! [TaskPriority]
|
||||
#expect(stored.count == 3)
|
||||
|
||||
let high = DataManager.shared.getTaskPriority(id: 1)
|
||||
#expect(high?.name == "High")
|
||||
#expect(high?.level == 3)
|
||||
|
||||
let low = DataManager.shared.getTaskPriority(id: 3)
|
||||
#expect(low?.name == "Low")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func setTaskFrequenciesPopulatesListAndMap() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
let frequencies = [
|
||||
TaskFrequency(id: 1, name: "Daily", days: 1, displayOrder: 0),
|
||||
TaskFrequency(id: 2, name: "Weekly", days: 7, displayOrder: 1)
|
||||
]
|
||||
DataManager.shared.setTaskFrequencies(frequencies: frequencies)
|
||||
|
||||
let stored = DataManager.shared.taskFrequencies.value as! [TaskFrequency]
|
||||
#expect(stored.count == 2)
|
||||
|
||||
let daily = DataManager.shared.getTaskFrequency(id: 1)
|
||||
#expect(daily?.name == "Daily")
|
||||
|
||||
let weekly = DataManager.shared.getTaskFrequency(id: 2)
|
||||
#expect(weekly?.name == "Weekly")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func setResidenceTypesPopulatesListAndMap() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
let types = [
|
||||
ResidenceType(id: 1, name: "Single Family"),
|
||||
ResidenceType(id: 2, name: "Condo"),
|
||||
ResidenceType(id: 3, name: "Townhouse")
|
||||
]
|
||||
DataManager.shared.setResidenceTypes(types: types)
|
||||
|
||||
let stored = DataManager.shared.residenceTypes.value as! [ResidenceType]
|
||||
#expect(stored.count == 3)
|
||||
|
||||
let condo = DataManager.shared.getResidenceType(id: 2)
|
||||
#expect(condo?.name == "Condo")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func setContractorSpecialtiesPopulatesListAndMap() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
let specialties = [
|
||||
ContractorSpecialty(id: 1, name: "Plumbing", description: "Pipes", icon: "wrench", displayOrder: 0),
|
||||
ContractorSpecialty(id: 2, name: "Electrical", description: "Wiring", icon: "bolt", displayOrder: 1)
|
||||
]
|
||||
DataManager.shared.setContractorSpecialties(specialties: specialties)
|
||||
|
||||
let stored = DataManager.shared.contractorSpecialties.value as! [ContractorSpecialty]
|
||||
#expect(stored.count == 2)
|
||||
|
||||
let plumbing = DataManager.shared.getContractorSpecialty(id: 1)
|
||||
#expect(plumbing?.name == "Plumbing")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func markLookupsInitializedSetsFlag() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
let before = DataManager.shared.lookupsInitialized.value as! Bool
|
||||
#expect(before == false)
|
||||
|
||||
DataManager.shared.markLookupsInitialized()
|
||||
|
||||
let after = DataManager.shared.lookupsInitialized.value as! Bool
|
||||
#expect(after == true)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func getterReturnsNilForMissingId() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
DataManager.shared.setTaskCategories(categories: [
|
||||
TaskCategory(id: 1, name: "Plumbing", description: "", icon: "", color: "", displayOrder: 0)
|
||||
])
|
||||
|
||||
let result = DataManager.shared.getTaskCategory(id: 9999)
|
||||
#expect(result == nil)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func getterReturnsNilForNilId() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
DataManager.shared.setTaskCategories(categories: [
|
||||
TaskCategory(id: 1, name: "Plumbing", description: "", icon: "", color: "", displayOrder: 0)
|
||||
])
|
||||
|
||||
let result = DataManager.shared.getTaskCategory(id: nil)
|
||||
#expect(result == nil)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DATA-002: ETag Tests (requires local backend)
|
||||
|
||||
struct DataLayerETagTests {
|
||||
|
||||
/// Base URL for direct HTTP calls to the static_data endpoint.
|
||||
/// Uses localhost for iOS simulator (127.0.0.1).
|
||||
private static let staticDataURL = "http://127.0.0.1:8000/api/static_data/"
|
||||
|
||||
/// Synchronous HTTP GET with optional headers. Returns (statusCode, headers, data).
|
||||
private func syncRequest(
|
||||
url: String,
|
||||
headers: [String: String] = [:]
|
||||
) -> (statusCode: Int, headers: [String: String], data: Data?) {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var statusCode = 0
|
||||
var responseHeaders: [String: String] = [:]
|
||||
var responseData: Data?
|
||||
|
||||
var request = URLRequest(url: URL(string: url)!)
|
||||
request.timeoutInterval = 10
|
||||
for (key, value) in headers {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, _ in
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
statusCode = httpResponse.statusCode
|
||||
for (key, value) in httpResponse.allHeaderFields {
|
||||
responseHeaders["\(key)"] = "\(value)"
|
||||
}
|
||||
}
|
||||
responseData = data
|
||||
semaphore.signal()
|
||||
}
|
||||
task.resume()
|
||||
semaphore.wait()
|
||||
return (statusCode, responseHeaders, responseData)
|
||||
}
|
||||
|
||||
/// Check if the local backend is reachable before running network tests.
|
||||
private func isBackendReachable() -> Bool {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var reachable = false
|
||||
|
||||
var request = URLRequest(url: URL(string: Self.staticDataURL)!)
|
||||
request.timeoutInterval = 3
|
||||
request.httpMethod = "HEAD"
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { _, response, _ in
|
||||
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode > 0 {
|
||||
reachable = true
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
task.resume()
|
||||
semaphore.wait()
|
||||
return reachable
|
||||
}
|
||||
|
||||
@Test func staticDataEndpointReturnsETag() throws {
|
||||
try #require(isBackendReachable(), "Local backend not reachable — skipping ETag test")
|
||||
|
||||
let (statusCode, headers, _) = syncRequest(url: Self.staticDataURL)
|
||||
#expect(statusCode == 200)
|
||||
|
||||
// ETag header should be present (case-insensitive check)
|
||||
let etag = headers["Etag"] ?? headers["ETag"] ?? headers["etag"]
|
||||
#expect(etag != nil, "Response should include an ETag header")
|
||||
#expect(etag?.isEmpty == false)
|
||||
}
|
||||
|
||||
@Test func conditionalRequestReturns304WhenDataUnchanged() throws {
|
||||
try #require(isBackendReachable(), "Local backend not reachable — skipping ETag test")
|
||||
|
||||
// First request: get the ETag
|
||||
let (statusCode1, headers1, _) = syncRequest(url: Self.staticDataURL)
|
||||
#expect(statusCode1 == 200)
|
||||
|
||||
let etag = headers1["Etag"] ?? headers1["ETag"] ?? headers1["etag"]
|
||||
try #require(etag != nil, "First response must include ETag")
|
||||
|
||||
// Second request: send If-None-Match with valid ETag
|
||||
let (statusCode2, headers2, _) = syncRequest(
|
||||
url: Self.staticDataURL,
|
||||
headers: ["If-None-Match": etag!]
|
||||
)
|
||||
|
||||
// Server should return 304 (data unchanged) or 200 with a new ETag
|
||||
// (if data was modified between requests). Both are valid ETag behavior.
|
||||
let validResponses: Set<Int> = [200, 304]
|
||||
#expect(validResponses.contains(statusCode2),
|
||||
"Conditional request should return 304 (unchanged) or 200 (new data), got \(statusCode2)")
|
||||
|
||||
if statusCode2 == 200 {
|
||||
// If server returned 200, it should include a new ETag
|
||||
let newEtag = headers2["Etag"] ?? headers2["ETag"] ?? headers2["etag"]
|
||||
#expect(newEtag != nil, "200 response should include a new ETag")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func staleETagReturns200() throws {
|
||||
try #require(isBackendReachable(), "Local backend not reachable — skipping ETag test")
|
||||
|
||||
// Send a bogus ETag — server should return 200 with fresh data
|
||||
let (statusCode, _, data) = syncRequest(
|
||||
url: Self.staticDataURL,
|
||||
headers: ["If-None-Match": "\"bogus-etag-value\""]
|
||||
)
|
||||
#expect(statusCode == 200, "Stale/bogus ETag should result in 200 with fresh data")
|
||||
#expect(data != nil && (data?.count ?? 0) > 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DATA-003, DATA-007: API Schema Validation Tests (requires local backend)
|
||||
|
||||
struct DataLayerAPISchemaTests {
|
||||
|
||||
private static let staticDataURL = "http://127.0.0.1:8000/api/static_data/"
|
||||
|
||||
/// Synchronous HTTP GET returning decoded JSON dictionary.
|
||||
private func fetchStaticData() -> [String: Any]? {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var result: [String: Any]?
|
||||
|
||||
var request = URLRequest(url: URL(string: Self.staticDataURL)!)
|
||||
request.timeoutInterval = 10
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, _ in
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200,
|
||||
let data = data {
|
||||
result = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
task.resume()
|
||||
semaphore.wait()
|
||||
return result
|
||||
}
|
||||
|
||||
private func isBackendReachable() -> Bool {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var reachable = false
|
||||
|
||||
var request = URLRequest(url: URL(string: Self.staticDataURL)!)
|
||||
request.timeoutInterval = 3
|
||||
request.httpMethod = "HEAD"
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { _, response, _ in
|
||||
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode > 0 {
|
||||
reachable = true
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
task.resume()
|
||||
semaphore.wait()
|
||||
return reachable
|
||||
}
|
||||
|
||||
@Test func staticDataContainsAllRequiredLookupTypes() throws {
|
||||
try #require(isBackendReachable(), "Local backend not reachable — skipping API schema test")
|
||||
|
||||
let data = try #require(fetchStaticData(), "Failed to fetch static data")
|
||||
|
||||
let requiredKeys = [
|
||||
"residence_types",
|
||||
"task_frequencies",
|
||||
"task_priorities",
|
||||
"task_categories",
|
||||
"contractor_specialties"
|
||||
]
|
||||
|
||||
for key in requiredKeys {
|
||||
#expect(data[key] != nil, "static_data response missing required key: \(key)")
|
||||
let array = data[key] as? [[String: Any]]
|
||||
#expect(array != nil, "\(key) should be an array of objects")
|
||||
#expect((array?.count ?? 0) > 0, "\(key) should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func allLookupItemsHaveIdAndName() throws {
|
||||
try #require(isBackendReachable(), "Local backend not reachable — skipping API schema test")
|
||||
|
||||
let data = try #require(fetchStaticData(), "Failed to fetch static data")
|
||||
|
||||
let lookupKeys = [
|
||||
"residence_types",
|
||||
"task_frequencies",
|
||||
"task_priorities",
|
||||
"task_categories",
|
||||
"contractor_specialties"
|
||||
]
|
||||
|
||||
for key in lookupKeys {
|
||||
guard let items = data[key] as? [[String: Any]] else { continue }
|
||||
for (index, item) in items.enumerated() {
|
||||
#expect(item["id"] != nil, "\(key)[\(index)] missing 'id'")
|
||||
#expect(item["name"] != nil, "\(key)[\(index)] missing 'name'")
|
||||
#expect(item["name"] as? String != "", "\(key)[\(index)] has empty 'name'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func allIdsAreUniquePerLookupType() throws {
|
||||
try #require(isBackendReachable(), "Local backend not reachable — skipping API schema test")
|
||||
|
||||
let data = try #require(fetchStaticData(), "Failed to fetch static data")
|
||||
|
||||
let lookupKeys = [
|
||||
"residence_types",
|
||||
"task_frequencies",
|
||||
"task_priorities",
|
||||
"task_categories",
|
||||
"contractor_specialties"
|
||||
]
|
||||
|
||||
for key in lookupKeys {
|
||||
guard let items = data[key] as? [[String: Any]] else { continue }
|
||||
let ids = items.compactMap { $0["id"] as? Int }
|
||||
let uniqueIds = Set(ids)
|
||||
#expect(ids.count == uniqueIds.count, "\(key) has duplicate IDs — would break associateBy map")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func taskCategoriesHaveColorAndIcon() throws {
|
||||
try #require(isBackendReachable(), "Local backend not reachable — skipping API schema test")
|
||||
|
||||
let data = try #require(fetchStaticData(), "Failed to fetch static data")
|
||||
let categories = try #require(data["task_categories"] as? [[String: Any]], "Missing task_categories")
|
||||
|
||||
for (index, cat) in categories.enumerated() {
|
||||
#expect(cat["color"] != nil, "task_categories[\(index)] missing 'color'")
|
||||
#expect(cat["icon"] != nil, "task_categories[\(index)] missing 'icon'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func taskPrioritiesHaveLevelAndColor() throws {
|
||||
try #require(isBackendReachable(), "Local backend not reachable — skipping API schema test")
|
||||
|
||||
let data = try #require(fetchStaticData(), "Failed to fetch static data")
|
||||
let priorities = try #require(data["task_priorities"] as? [[String: Any]], "Missing task_priorities")
|
||||
|
||||
for (index, pri) in priorities.enumerated() {
|
||||
#expect(pri["level"] != nil, "task_priorities[\(index)] missing 'level'")
|
||||
#expect(pri["color"] != nil, "task_priorities[\(index)] missing 'color'")
|
||||
}
|
||||
}
|
||||
}
|
||||
364
iosApp/HoneyDueTests/DataManagerExtendedTests.swift
Normal file
364
iosApp/HoneyDueTests/DataManagerExtendedTests.swift
Normal file
@@ -0,0 +1,364 @@
|
||||
//
|
||||
// DataManagerExtendedTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Extended unit tests covering TASK-005, TASK-012, THEME-001, TCOMP-003, and QA-002.
|
||||
//
|
||||
// IMPORTANT: DataManager-mutating suites are nested inside the DataLayerTests
|
||||
// serialized parent (defined in DataLayerTests.swift) via extension, so ALL
|
||||
// DataManager tests serialize together and avoid concurrent state interference.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import honeyDue
|
||||
import ComposeApp
|
||||
|
||||
// MARK: - Extension of DataLayerTests (serialized parent in DataLayerTests.swift)
|
||||
|
||||
extension DataLayerTests {
|
||||
|
||||
// MARK: - TASK-005: Template Search Tests
|
||||
|
||||
@Suite struct TemplateSearchTests {
|
||||
|
||||
// Helper to build a TaskTemplate with the fields that searchTaskTemplates inspects.
|
||||
// categoryId and frequencyId are nullable Int in Kotlin; pass nil directly.
|
||||
private func makeTemplate(
|
||||
id: Int32,
|
||||
title: String,
|
||||
description: String = "",
|
||||
tags: [String] = []
|
||||
) -> TaskTemplate {
|
||||
TaskTemplate(
|
||||
id: id,
|
||||
title: title,
|
||||
description: description,
|
||||
categoryId: nil,
|
||||
category: nil,
|
||||
frequencyId: nil,
|
||||
frequency: nil,
|
||||
iconIos: "",
|
||||
iconAndroid: "",
|
||||
tags: tags,
|
||||
displayOrder: 0,
|
||||
isActive: true
|
||||
)
|
||||
}
|
||||
|
||||
@Test func searchWithSingleCharReturnsEmpty() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Appliance check")
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "A")
|
||||
#expect(results.isEmpty)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func searchWithTwoCharsMatchesTitles() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Plumbing repair"),
|
||||
makeTemplate(id: 2, title: "HVAC inspection"),
|
||||
makeTemplate(id: 3, title: "Lawn care")
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "Pl")
|
||||
#expect(results.count == 1)
|
||||
#expect(results.first?.title == "Plumbing repair")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func searchIsCaseInsensitive() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Plumbing repair"),
|
||||
makeTemplate(id: 2, title: "Electrical panel check")
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "plumbing")
|
||||
#expect(results.count == 1)
|
||||
#expect(results.first?.title == "Plumbing repair")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func searchMatchesDescription() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Pipe maintenance", description: "Fix water leaks and pipe corrosion"),
|
||||
makeTemplate(id: 2, title: "Roof inspection", description: "Check shingles and gutters")
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "water")
|
||||
#expect(results.count == 1)
|
||||
#expect(results.first?.title == "Pipe maintenance")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func searchMatchesTags() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Air filter replacement", tags: ["hvac", "filter", "air quality"]),
|
||||
makeTemplate(id: 2, title: "Lawn mowing", tags: ["landscaping", "outdoor"])
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "hvac")
|
||||
#expect(results.count == 1)
|
||||
#expect(results.first?.title == "Air filter replacement")
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func searchReturnsMaxTenResults() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
// Create 15 templates all matching "maintenance"
|
||||
var templates: [TaskTemplate] = []
|
||||
for i in 1...15 {
|
||||
templates.append(makeTemplate(id: Int32(i), title: "maintenance task \(i)"))
|
||||
}
|
||||
DataManager.shared.setTaskTemplates(templates: templates)
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "maintenance")
|
||||
#expect(results.count == 10)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func searchNoMatchReturnsEmpty() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Plumbing repair"),
|
||||
makeTemplate(id: 2, title: "Roof inspection")
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "xyz")
|
||||
#expect(results.isEmpty)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func emptyQueryReturnsEmpty() {
|
||||
DataManager.shared.clear()
|
||||
DataManager.shared.setTaskTemplates(templates: [
|
||||
makeTemplate(id: 1, title: "Plumbing repair")
|
||||
])
|
||||
|
||||
let results = DataManager.shared.searchTaskTemplates(query: "")
|
||||
#expect(results.isEmpty)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TASK-012: Remove Task Cache Updates
|
||||
|
||||
@Suite struct RemoveTaskTests {
|
||||
|
||||
@Test func allTasksIsNilAfterClear() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
let value = DataManager.shared.allTasks.value
|
||||
#expect(value == nil)
|
||||
}
|
||||
|
||||
@Test func removeTaskOnNilAllTasksIsNoOp() {
|
||||
// When allTasks is nil, removeTask should not crash and allTasks remains nil.
|
||||
DataManager.shared.clear()
|
||||
|
||||
DataManager.shared.removeTask(taskId: 42)
|
||||
|
||||
let value = DataManager.shared.allTasks.value
|
||||
#expect(value == nil)
|
||||
|
||||
DataManager.shared.clear()
|
||||
}
|
||||
|
||||
@Test func tasksByResidenceIsEmptyAfterClear() {
|
||||
// After clear, tasksByResidence is empty — removeTask has no residence caches to update.
|
||||
// Calling removeTask with no tasks cached should be a no-op (no crash, allTasks stays nil).
|
||||
DataManager.shared.clear()
|
||||
|
||||
// removeTask with empty caches must not throw or crash
|
||||
DataManager.shared.removeTask(taskId: 999)
|
||||
|
||||
// allTasks should remain nil after a no-op removeTask on empty state
|
||||
#expect(DataManager.shared.allTasks.value == nil)
|
||||
}
|
||||
|
||||
// NOTE: Full integration coverage for removeTask (removing from kanban columns and
|
||||
// residence caches) is exercised in the UI test suite (TaskIntegrationTests) where
|
||||
// real TaskColumnsResponse objects are constructed through the API layer.
|
||||
// Constructing TaskColumnsResponse directly from Swift requires bridging complex
|
||||
// Kotlin generics (Map<String,String> for icons) that are impractical in unit tests.
|
||||
}
|
||||
|
||||
// MARK: - THEME-001: Theme Persistence Tests
|
||||
|
||||
@Suite struct ThemePersistenceTests {
|
||||
|
||||
@Test func defaultThemeIdIsDefault() {
|
||||
// Explicitly set to "default" first since clear() does NOT reset themeId.
|
||||
DataManager.shared.setThemeId(id: "default")
|
||||
DataManager.shared.clear()
|
||||
|
||||
let themeId = DataManager.shared.themeId.value as! String
|
||||
#expect(themeId == "default")
|
||||
}
|
||||
|
||||
@Test func setThemeIdUpdatesValue() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
DataManager.shared.setThemeId(id: "ocean")
|
||||
|
||||
let themeId = DataManager.shared.themeId.value as! String
|
||||
#expect(themeId == "ocean")
|
||||
|
||||
// Restore to default so other tests are unaffected
|
||||
DataManager.shared.setThemeId(id: "default")
|
||||
}
|
||||
|
||||
@Test func setThemeIdToMultipleThemes() {
|
||||
DataManager.shared.clear()
|
||||
|
||||
DataManager.shared.setThemeId(id: "forest")
|
||||
let intermediate = DataManager.shared.themeId.value as! String
|
||||
#expect(intermediate == "forest")
|
||||
|
||||
DataManager.shared.setThemeId(id: "midnight")
|
||||
let final_ = DataManager.shared.themeId.value as! String
|
||||
#expect(final_ == "midnight")
|
||||
|
||||
// Restore
|
||||
DataManager.shared.setThemeId(id: "default")
|
||||
}
|
||||
|
||||
@Test func clearDoesNotResetTheme() {
|
||||
// Per CLAUDE.md architecture, clear() does NOT reset themeId.
|
||||
// The Kotlin source confirms _themeId is absent from the clear() method.
|
||||
DataManager.shared.setThemeId(id: "ocean")
|
||||
|
||||
DataManager.shared.clear()
|
||||
|
||||
// Theme should be preserved after clear — verify it is still a non-empty string.
|
||||
let themeId = DataManager.shared.themeId.value as! String
|
||||
#expect(!themeId.isEmpty)
|
||||
|
||||
// Restore
|
||||
DataManager.shared.setThemeId(id: "default")
|
||||
}
|
||||
|
||||
@Test func themeIdIsPreservedAsOceanAfterClear() {
|
||||
// Stronger assertion: the exact value set before clear() survives clear().
|
||||
DataManager.shared.setThemeId(id: "ocean")
|
||||
|
||||
DataManager.shared.clear()
|
||||
|
||||
let themeId = DataManager.shared.themeId.value as! String
|
||||
#expect(themeId == "ocean")
|
||||
|
||||
// Restore
|
||||
DataManager.shared.setThemeId(id: "default")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TCOMP-003: Task Completion Form Validation
|
||||
// These tests use only ValidationHelpers (pure Swift, no DataManager mutation).
|
||||
|
||||
struct TaskCompletionValidationTests {
|
||||
|
||||
@Test func completedByFieldRequired() {
|
||||
let result = ValidationHelpers.validateRequired("", fieldName: "Completed by")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Completed by is required")
|
||||
}
|
||||
|
||||
@Test func completedByFieldWithValuePasses() {
|
||||
let result = ValidationHelpers.validateRequired("Trey Tartt", fieldName: "Completed by")
|
||||
#expect(result.isValid)
|
||||
#expect(result.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test func completedByFieldWhitespaceOnlyFails() {
|
||||
let result = ValidationHelpers.validateRequired(" ", fieldName: "Completed by")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Completed by is required")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QA-002: JSON Unknown Fields Resilience
|
||||
// Swift's JSONDecoder ignores unknown keys by default — these tests confirm that behaviour
|
||||
// holds for WidgetDataManager.WidgetTask, which uses a custom CodingKeys enum.
|
||||
|
||||
struct JSONUnknownFieldsResilienceTests {
|
||||
|
||||
@Test func widgetTaskDecodesWithExtraFields() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Test",
|
||||
"description": null,
|
||||
"priority": "high",
|
||||
"in_progress": false,
|
||||
"due_date": null,
|
||||
"category": "test",
|
||||
"residence_name": "Home",
|
||||
"is_overdue": false,
|
||||
"is_due_within_7_days": false,
|
||||
"is_due_8_to_30_days": false,
|
||||
"unknown_field": "should be ignored",
|
||||
"another_unknown": 42
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let task = try JSONDecoder().decode(WidgetDataManager.WidgetTask.self, from: json)
|
||||
#expect(task.id == 1)
|
||||
#expect(task.title == "Test")
|
||||
#expect(task.priority == "high")
|
||||
#expect(task.isOverdue == false)
|
||||
#expect(task.isDueWithin7Days == false)
|
||||
#expect(task.isDue8To30Days == false)
|
||||
#expect(task.residenceName == "Home")
|
||||
}
|
||||
|
||||
@Test func widgetTaskIgnoresUnknownNestedObjects() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 99,
|
||||
"title": "Nested unknown test",
|
||||
"description": "Some description",
|
||||
"priority": "low",
|
||||
"in_progress": true,
|
||||
"due_date": "2026-03-01",
|
||||
"category": "plumbing",
|
||||
"residence_name": null,
|
||||
"is_overdue": true,
|
||||
"is_due_within_7_days": false,
|
||||
"is_due_8_to_30_days": false,
|
||||
"unknown_nested": {
|
||||
"key1": "value1",
|
||||
"key2": 123,
|
||||
"key3": true
|
||||
},
|
||||
"unknown_array": [1, 2, 3]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let task = try JSONDecoder().decode(WidgetDataManager.WidgetTask.self, from: json)
|
||||
#expect(task.id == 99)
|
||||
#expect(task.title == "Nested unknown test")
|
||||
#expect(task.description == "Some description")
|
||||
#expect(task.inProgress == true)
|
||||
#expect(task.dueDate == "2026-03-01")
|
||||
#expect(task.category == "plumbing")
|
||||
#expect(task.residenceName == nil)
|
||||
#expect(task.isOverdue == true)
|
||||
}
|
||||
}
|
||||
286
iosApp/HoneyDueTests/DateUtilsTests.swift
Normal file
286
iosApp/HoneyDueTests/DateUtilsTests.swift
Normal file
@@ -0,0 +1,286 @@
|
||||
//
|
||||
// DateUtilsTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for DateUtils formatting, parsing, and timezone utilities.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import honeyDue
|
||||
|
||||
// MARK: - DateUtils.formatDate Tests
|
||||
|
||||
struct DateUtilsFormatDateTests {
|
||||
|
||||
@Test func todayReturnsToday() {
|
||||
let today = DateUtils.todayString()
|
||||
let result = DateUtils.formatDate(today)
|
||||
#expect(result == "Today")
|
||||
}
|
||||
|
||||
@Test func nilReturnsEmpty() {
|
||||
let result = DateUtils.formatDate(nil)
|
||||
#expect(result == "")
|
||||
}
|
||||
|
||||
@Test func emptyReturnsEmpty() {
|
||||
let result = DateUtils.formatDate("")
|
||||
#expect(result == "")
|
||||
}
|
||||
|
||||
@Test func invalidDateReturnsSelf() {
|
||||
let result = DateUtils.formatDate("not-a-date")
|
||||
#expect(result == "not-a-date")
|
||||
}
|
||||
|
||||
@Test func dateWithTimePartExtractsDate() {
|
||||
let today = DateUtils.todayString()
|
||||
let result = DateUtils.formatDate("\(today)T12:00:00Z")
|
||||
#expect(result == "Today")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateUtils.formatDateMedium Tests
|
||||
|
||||
struct DateUtilsFormatDateMediumTests {
|
||||
|
||||
@Test func validDateFormatsAsMedium() {
|
||||
let result = DateUtils.formatDateMedium("2024-01-15")
|
||||
#expect(result == "Jan 15, 2024")
|
||||
}
|
||||
|
||||
@Test func nilReturnsEmpty() {
|
||||
let result = DateUtils.formatDateMedium(nil)
|
||||
#expect(result == "")
|
||||
}
|
||||
|
||||
@Test func invalidDateReturnsSelf() {
|
||||
let result = DateUtils.formatDateMedium("bad-date")
|
||||
#expect(result == "bad-date")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateUtils.isOverdue Tests
|
||||
|
||||
struct DateUtilsIsOverdueTests {
|
||||
|
||||
@Test func pastDateIsOverdue() {
|
||||
let result = DateUtils.isOverdue("2020-01-01")
|
||||
#expect(result == true)
|
||||
}
|
||||
|
||||
@Test func futureDateIsNotOverdue() {
|
||||
let result = DateUtils.isOverdue("2099-12-31")
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func nilIsNotOverdue() {
|
||||
let result = DateUtils.isOverdue(nil)
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func emptyIsNotOverdue() {
|
||||
let result = DateUtils.isOverdue("")
|
||||
#expect(result == false)
|
||||
}
|
||||
|
||||
@Test func todayIsNotOverdue() {
|
||||
let today = DateUtils.todayString()
|
||||
let result = DateUtils.isOverdue(today)
|
||||
#expect(result == false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateUtils.parseDate Tests
|
||||
|
||||
struct DateUtilsParseDateTests {
|
||||
|
||||
@Test func validDateStringParsed() {
|
||||
let date = DateUtils.parseDate("2024-06-15")
|
||||
#expect(date != nil)
|
||||
}
|
||||
|
||||
@Test func nilReturnsNil() {
|
||||
let date = DateUtils.parseDate(nil)
|
||||
#expect(date == nil)
|
||||
}
|
||||
|
||||
@Test func emptyReturnsNil() {
|
||||
let date = DateUtils.parseDate("")
|
||||
#expect(date == nil)
|
||||
}
|
||||
|
||||
@Test func invalidReturnsNil() {
|
||||
let date = DateUtils.parseDate("not-a-date")
|
||||
#expect(date == nil)
|
||||
}
|
||||
|
||||
@Test func dateTimeStringExtractsDate() {
|
||||
let date = DateUtils.parseDate("2024-06-15T10:30:00Z")
|
||||
#expect(date != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateUtils.formatHour Tests
|
||||
|
||||
struct DateUtilsFormatHourTests {
|
||||
|
||||
@Test func midnightFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(0) == "12:00 AM")
|
||||
}
|
||||
|
||||
@Test func morningFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(8) == "8:00 AM")
|
||||
}
|
||||
|
||||
@Test func noonFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(12) == "12:00 PM")
|
||||
}
|
||||
|
||||
@Test func afternoonFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(14) == "2:00 PM")
|
||||
}
|
||||
|
||||
@Test func elevenPMFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(23) == "11:00 PM")
|
||||
}
|
||||
|
||||
@Test func oneAMFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(1) == "1:00 AM")
|
||||
}
|
||||
|
||||
@Test func elevenAMFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(11) == "11:00 AM")
|
||||
}
|
||||
|
||||
@Test func onePMFormatsCorrectly() {
|
||||
#expect(DateUtils.formatHour(13) == "1:00 PM")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateUtils.formatRelativeDate Tests
|
||||
|
||||
struct DateUtilsFormatRelativeDateTests {
|
||||
|
||||
@Test func nilReturnsEmpty() {
|
||||
let result = DateUtils.formatRelativeDate(nil)
|
||||
#expect(result == "")
|
||||
}
|
||||
|
||||
@Test func emptyReturnsEmpty() {
|
||||
let result = DateUtils.formatRelativeDate("")
|
||||
#expect(result == "")
|
||||
}
|
||||
|
||||
@Test func todayReturnsToday() {
|
||||
let today = DateUtils.todayString()
|
||||
let result = DateUtils.formatRelativeDate(today)
|
||||
#expect(result == "Today")
|
||||
}
|
||||
|
||||
@Test func invalidReturnsSelf() {
|
||||
let result = DateUtils.formatRelativeDate("bad")
|
||||
#expect(result == "bad")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateUtils.localHourToUtc / utcHourToLocal Round-Trip Tests
|
||||
|
||||
struct TimezoneConversionTests {
|
||||
|
||||
@Test func roundTripLocalToUtcToLocal() {
|
||||
let localHour = 10
|
||||
let utc = DateUtils.localHourToUtc(localHour)
|
||||
let backToLocal = DateUtils.utcHourToLocal(utc)
|
||||
#expect(backToLocal == localHour)
|
||||
}
|
||||
|
||||
@Test func utcAndLocalDifferByTimezoneOffset() {
|
||||
// Just verify it returns a value in 0-23 range
|
||||
let utc = DateUtils.localHourToUtc(15)
|
||||
#expect(utc >= 0 && utc <= 23)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DateExtensions Tests
|
||||
|
||||
struct DateExtensionsTests {
|
||||
|
||||
@Test func todayIsToday() {
|
||||
#expect(Date().isToday)
|
||||
}
|
||||
|
||||
@Test func todayRelativeDescriptionIsToday() {
|
||||
#expect(Date().relativeDescription == "Today")
|
||||
}
|
||||
|
||||
@Test func apiFormatHasCorrectPattern() {
|
||||
let formatted = Date().formattedAPI()
|
||||
// Should match yyyy-MM-dd pattern
|
||||
let parts = formatted.split(separator: "-")
|
||||
#expect(parts.count == 3)
|
||||
#expect(parts[0].count == 4)
|
||||
#expect(parts[1].count == 2)
|
||||
#expect(parts[2].count == 2)
|
||||
}
|
||||
|
||||
@Test func distantPastIsPast() {
|
||||
let past = Date.distantPast
|
||||
#expect(past.isPast)
|
||||
}
|
||||
|
||||
@Test func distantFutureIsNotPast() {
|
||||
let future = Date.distantFuture
|
||||
#expect(!future.isPast)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String Date Extension Tests
|
||||
|
||||
struct StringDateExtensionTests {
|
||||
|
||||
@Test func validAPIDateParsed() {
|
||||
let date = "2024-06-15".toDate()
|
||||
#expect(date != nil)
|
||||
}
|
||||
|
||||
@Test func invalidDateReturnsNil() {
|
||||
let date = "invalid".toDate()
|
||||
#expect(date == nil)
|
||||
}
|
||||
|
||||
@Test func dateTimeStringParsed() {
|
||||
let date = "2024-06-15T10:30:00Z".toDate()
|
||||
#expect(date != nil)
|
||||
}
|
||||
|
||||
@Test func pastDateIsOverdue() {
|
||||
#expect("2020-01-01".isOverdue())
|
||||
}
|
||||
|
||||
@Test func futureDateIsNotOverdue() {
|
||||
#expect(!"2099-12-31".isOverdue())
|
||||
}
|
||||
|
||||
@Test func toFormattedDateReturnsFormatted() {
|
||||
let result = "2024-01-15".toFormattedDate()
|
||||
#expect(result == "Jan 15, 2024")
|
||||
}
|
||||
|
||||
@Test func invalidDateToFormattedReturnsSelf() {
|
||||
let result = "bad-date".toFormattedDate()
|
||||
#expect(result == "bad-date")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper for date tests
|
||||
|
||||
private extension DateUtils {
|
||||
/// Returns today's date as an API-formatted string (yyyy-MM-dd)
|
||||
static func todayString() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
}
|
||||
115
iosApp/HoneyDueTests/DocumentHelpersTests.swift
Normal file
115
iosApp/HoneyDueTests/DocumentHelpersTests.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// DocumentHelpersTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for DocumentTypeHelper and DocumentCategoryHelper.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import honeyDue
|
||||
|
||||
// MARK: - DocumentTypeHelper Tests
|
||||
|
||||
struct DocumentTypeHelperTests {
|
||||
|
||||
@Test func warrantyDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "warranty") == "Warranty")
|
||||
}
|
||||
|
||||
@Test func manualDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "manual") == "User Manual")
|
||||
}
|
||||
|
||||
@Test func receiptDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "receipt") == "Receipt/Invoice")
|
||||
}
|
||||
|
||||
@Test func inspectionDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "inspection") == "Inspection Report")
|
||||
}
|
||||
|
||||
@Test func insuranceDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "insurance") == "Insurance")
|
||||
}
|
||||
|
||||
@Test func permitDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "permit") == "Permit")
|
||||
}
|
||||
|
||||
@Test func deedDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "deed") == "Deed/Title")
|
||||
}
|
||||
|
||||
@Test func contractDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "contract") == "Contract")
|
||||
}
|
||||
|
||||
@Test func photoDisplayName() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "photo") == "Photo")
|
||||
}
|
||||
|
||||
@Test func unknownTypeDefaultsToOther() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "unknown") == "Other")
|
||||
}
|
||||
|
||||
@Test func emptyTypeDefaultsToOther() {
|
||||
#expect(DocumentTypeHelper.displayName(for: "") == "Other")
|
||||
}
|
||||
|
||||
@Test func allTypesArrayNotEmpty() {
|
||||
#expect(!DocumentTypeHelper.allTypes.isEmpty)
|
||||
}
|
||||
|
||||
@Test func allTypesContainsWarranty() {
|
||||
#expect(DocumentTypeHelper.allTypes.contains("warranty"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DocumentCategoryHelper Tests
|
||||
|
||||
struct DocumentCategoryHelperTests {
|
||||
|
||||
@Test func applianceDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "appliance") == "Appliance")
|
||||
}
|
||||
|
||||
@Test func hvacDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "hvac") == "HVAC")
|
||||
}
|
||||
|
||||
@Test func plumbingDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "plumbing") == "Plumbing")
|
||||
}
|
||||
|
||||
@Test func electricalDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "electrical") == "Electrical")
|
||||
}
|
||||
|
||||
@Test func roofingDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "roofing") == "Roofing")
|
||||
}
|
||||
|
||||
@Test func structuralDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "structural") == "Structural")
|
||||
}
|
||||
|
||||
@Test func landscapingDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "landscaping") == "Landscaping")
|
||||
}
|
||||
|
||||
@Test func generalDisplayName() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "general") == "General")
|
||||
}
|
||||
|
||||
@Test func unknownCategoryDefaultsToOther() {
|
||||
#expect(DocumentCategoryHelper.displayName(for: "xyz") == "Other")
|
||||
}
|
||||
|
||||
@Test func allCategoriesArrayNotEmpty() {
|
||||
#expect(!DocumentCategoryHelper.allCategories.isEmpty)
|
||||
}
|
||||
|
||||
@Test func allCategoriesContainsHVAC() {
|
||||
#expect(DocumentCategoryHelper.allCategories.contains("hvac"))
|
||||
}
|
||||
}
|
||||
150
iosApp/HoneyDueTests/DoubleExtensionsTests.swift
Normal file
150
iosApp/HoneyDueTests/DoubleExtensionsTests.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// DoubleExtensionsTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for Double, Int number formatting extensions.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import honeyDue
|
||||
|
||||
// MARK: - Double.toCurrency Tests
|
||||
|
||||
struct DoubleCurrencyTests {
|
||||
|
||||
@Test func wholeNumberCurrency() {
|
||||
let result = 100.0.toCurrency()
|
||||
#expect(result.contains("100"))
|
||||
#expect(result.contains("$"))
|
||||
}
|
||||
|
||||
@Test func decimalCurrency() {
|
||||
let result = 49.99.toCurrency()
|
||||
#expect(result.contains("49.99"))
|
||||
}
|
||||
|
||||
@Test func zeroCurrency() {
|
||||
let result = 0.0.toCurrency()
|
||||
#expect(result.contains("0"))
|
||||
#expect(result.contains("$"))
|
||||
}
|
||||
|
||||
@Test func largeNumberCurrency() {
|
||||
let result = 1234567.89.toCurrency()
|
||||
#expect(result.contains("1,234,567"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Double.toFileSize Tests
|
||||
|
||||
struct FileSizeTests {
|
||||
|
||||
@Test func bytesSize() {
|
||||
let result = 512.0.toFileSize()
|
||||
#expect(result == "512.0 B")
|
||||
}
|
||||
|
||||
@Test func kilobytesSize() {
|
||||
let result = 2048.0.toFileSize()
|
||||
#expect(result == "2.0 KB")
|
||||
}
|
||||
|
||||
@Test func megabytesSize() {
|
||||
let result = (5.0 * 1024 * 1024).toFileSize()
|
||||
#expect(result == "5.0 MB")
|
||||
}
|
||||
|
||||
@Test func gigabytesSize() {
|
||||
let result = (2.5 * 1024 * 1024 * 1024).toFileSize()
|
||||
#expect(result == "2.5 GB")
|
||||
}
|
||||
|
||||
@Test func zeroBytes() {
|
||||
let result = 0.0.toFileSize()
|
||||
#expect(result == "0.0 B")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Double.rounded Tests
|
||||
|
||||
struct DoubleRoundedTests {
|
||||
|
||||
@Test func roundToTwoPlaces() {
|
||||
#expect(3.14159.rounded(to: 2) == 3.14)
|
||||
}
|
||||
|
||||
@Test func roundToZeroPlaces() {
|
||||
#expect(3.7.rounded(to: 0) == 4.0)
|
||||
}
|
||||
|
||||
@Test func roundToOnePlaceDown() {
|
||||
#expect(3.14.rounded(to: 1) == 3.1)
|
||||
}
|
||||
|
||||
@Test func roundToOnePlaceUp() {
|
||||
#expect(3.15.rounded(to: 1) == 3.2)
|
||||
}
|
||||
|
||||
@Test func zeroRoundsToZero() {
|
||||
#expect(0.0.rounded(to: 3) == 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Double.toPercentage Tests
|
||||
|
||||
struct PercentageTests {
|
||||
|
||||
@Test func fiftyPercent() {
|
||||
let result = 50.0.toPercentage()
|
||||
#expect(result.contains("50"))
|
||||
#expect(result.contains("%"))
|
||||
}
|
||||
|
||||
@Test func zeroPercent() {
|
||||
let result = 0.0.toPercentage()
|
||||
#expect(result.contains("0"))
|
||||
#expect(result.contains("%"))
|
||||
}
|
||||
|
||||
@Test func hundredPercent() {
|
||||
let result = 100.0.toPercentage()
|
||||
#expect(result.contains("100"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Int.pluralSuffix Tests
|
||||
|
||||
struct PluralSuffixTests {
|
||||
|
||||
@Test func singularNoSuffix() {
|
||||
#expect(1.pluralSuffix() == "")
|
||||
}
|
||||
|
||||
@Test func pluralDefaultSuffix() {
|
||||
#expect(2.pluralSuffix() == "s")
|
||||
}
|
||||
|
||||
@Test func zeroIsPlural() {
|
||||
#expect(0.pluralSuffix() == "s")
|
||||
}
|
||||
|
||||
@Test func customSuffixes() {
|
||||
#expect(1.pluralSuffix("y", "ies") == "y")
|
||||
#expect(3.pluralSuffix("y", "ies") == "ies")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Int.toFileSize Tests
|
||||
|
||||
struct IntFileSizeTests {
|
||||
|
||||
@Test func intBytesToFileSize() {
|
||||
let result = 1024.toFileSize()
|
||||
#expect(result == "1.0 KB")
|
||||
}
|
||||
|
||||
@Test func intZeroBytes() {
|
||||
let result = 0.toFileSize()
|
||||
#expect(result == "0.0 B")
|
||||
}
|
||||
}
|
||||
221
iosApp/HoneyDueTests/ErrorMessageParserTests.swift
Normal file
221
iosApp/HoneyDueTests/ErrorMessageParserTests.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
//
|
||||
// ErrorMessageParserTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for ErrorMessageParser error code mapping, network error detection,
|
||||
// and message parsing logic.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import honeyDue
|
||||
|
||||
// MARK: - API Error Code Mapping Tests
|
||||
|
||||
struct ErrorCodeMappingTests {
|
||||
|
||||
@Test func invalidCredentialsCode() {
|
||||
let result = ErrorMessageParser.parse("error.invalid_credentials")
|
||||
#expect(result == "Invalid username or password. Please try again.")
|
||||
}
|
||||
|
||||
@Test func invalidTokenCode() {
|
||||
let result = ErrorMessageParser.parse("error.invalid_token")
|
||||
#expect(result == "Your session has expired. Please log in again.")
|
||||
}
|
||||
|
||||
@Test func usernameTakenCode() {
|
||||
let result = ErrorMessageParser.parse("error.username_taken")
|
||||
#expect(result == "This username is already taken. Please choose another.")
|
||||
}
|
||||
|
||||
@Test func emailTakenCode() {
|
||||
let result = ErrorMessageParser.parse("error.email_taken")
|
||||
#expect(result == "This email is already registered. Try logging in instead.")
|
||||
}
|
||||
|
||||
@Test func invalidVerificationCodeCode() {
|
||||
let result = ErrorMessageParser.parse("error.invalid_verification_code")
|
||||
#expect(result == "Invalid verification code. Please check and try again.")
|
||||
}
|
||||
|
||||
@Test func verificationCodeExpiredCode() {
|
||||
let result = ErrorMessageParser.parse("error.verification_code_expired")
|
||||
#expect(result == "Your verification code has expired. Please request a new one.")
|
||||
}
|
||||
|
||||
@Test func rateLimitExceededCode() {
|
||||
let result = ErrorMessageParser.parse("error.rate_limit_exceeded")
|
||||
#expect(result == "Too many attempts. Please wait a few minutes and try again.")
|
||||
}
|
||||
|
||||
@Test func taskNotFoundCode() {
|
||||
let result = ErrorMessageParser.parse("error.task_not_found")
|
||||
#expect(result == "Task not found. It may have been deleted.")
|
||||
}
|
||||
|
||||
@Test func residenceNotFoundCode() {
|
||||
let result = ErrorMessageParser.parse("error.residence_not_found")
|
||||
#expect(result == "Property not found. It may have been deleted.")
|
||||
}
|
||||
|
||||
@Test func accessDeniedCode() {
|
||||
let result = ErrorMessageParser.parse("error.access_denied")
|
||||
#expect(result == "You don't have permission for this action.")
|
||||
}
|
||||
|
||||
@Test func shareCodeInvalidCode() {
|
||||
let result = ErrorMessageParser.parse("error.share_code_invalid")
|
||||
#expect(result == "Invalid share code. Please check and try again.")
|
||||
}
|
||||
|
||||
@Test func propertiesLimitReachedCode() {
|
||||
let result = ErrorMessageParser.parse("error.properties_limit_reached")
|
||||
#expect(result == "You've reached your property limit. Upgrade to add more.")
|
||||
}
|
||||
|
||||
@Test func internalErrorCode() {
|
||||
let result = ErrorMessageParser.parse("error.internal")
|
||||
#expect(result == "Something went wrong. Please try again.")
|
||||
}
|
||||
|
||||
@Test func titleRequiredCode() {
|
||||
let result = ErrorMessageParser.parse("error.title_required")
|
||||
#expect(result == "Title is required.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unknown Error Code Tests
|
||||
|
||||
struct UnknownErrorCodeTests {
|
||||
|
||||
@Test func unknownErrorCodeGeneratesMessage() {
|
||||
let result = ErrorMessageParser.parse("error.some_new_error")
|
||||
#expect(result == "Some new error. Please try again.")
|
||||
}
|
||||
|
||||
@Test func unknownErrorCodeWithSingleWord() {
|
||||
let result = ErrorMessageParser.parse("error.forbidden")
|
||||
#expect(result == "Forbidden. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Network Error Pattern Tests
|
||||
|
||||
struct NetworkErrorPatternTests {
|
||||
|
||||
@Test func offlineErrorDetected() {
|
||||
let result = ErrorMessageParser.parse("The Internet connection appears to be offline")
|
||||
#expect(result == "No internet connection. Please check your network.")
|
||||
}
|
||||
|
||||
@Test func timeoutErrorDetected() {
|
||||
let result = ErrorMessageParser.parse("The request timed out")
|
||||
#expect(result == "Request timed out. Please try again.")
|
||||
}
|
||||
|
||||
@Test func connectionLostDetected() {
|
||||
let result = ErrorMessageParser.parse("The network connection was lost")
|
||||
#expect(result == "Connection lost. Please try again.")
|
||||
}
|
||||
|
||||
@Test func sslErrorDetected() {
|
||||
let result = ErrorMessageParser.parse("An SSL error has occurred")
|
||||
#expect(result == "Secure connection failed. Please try again.")
|
||||
}
|
||||
|
||||
@Test func nsUrlErrorCodeDetected() {
|
||||
let result = ErrorMessageParser.parse("Error Code=-1009 in domain NSURLErrorDomain")
|
||||
#expect(result.contains("internet") || result.contains("connect"))
|
||||
}
|
||||
|
||||
@Test func connectionRefusedDetected() {
|
||||
let result = ErrorMessageParser.parse("Connection refused")
|
||||
#expect(result == "Unable to connect. The server may be temporarily unavailable.")
|
||||
}
|
||||
|
||||
@Test func socketTimeoutDetected() {
|
||||
let result = ErrorMessageParser.parse("SocketTimeoutException: connect timed out")
|
||||
#expect(result == "Request timed out. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Technical Error Detection Tests
|
||||
|
||||
struct TechnicalErrorDetectionTests {
|
||||
|
||||
@Test func stackTraceDetected() {
|
||||
let result = ErrorMessageParser.parse("java.lang.NullPointerException at com.example.App.method(App.kt:42)")
|
||||
#expect(result == "Something went wrong. Please try again.")
|
||||
}
|
||||
|
||||
@Test func swiftErrorDetected() {
|
||||
let result = ErrorMessageParser.parse("Fatal error in module.swift:123")
|
||||
#expect(result == "Something went wrong. Please try again.")
|
||||
}
|
||||
|
||||
@Test func kotlinErrorDetected() {
|
||||
let result = ErrorMessageParser.parse("Exception at kotlin.coroutines.ContinuationKt.kt:55")
|
||||
#expect(result == "Something went wrong. Please try again.")
|
||||
}
|
||||
|
||||
@Test func nsErrorDomainDetected() {
|
||||
let result = ErrorMessageParser.parse("Error Domain=NSURLErrorDomain Code=-1001 UserInfo={NSLocalizedDescription=timed out}")
|
||||
#expect(result != "Error Domain=NSURLErrorDomain Code=-1001 UserInfo={NSLocalizedDescription=timed out}")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON Error Parsing Tests
|
||||
|
||||
struct JSONErrorParsingTests {
|
||||
|
||||
@Test func jsonWithErrorFieldParsed() {
|
||||
let json = #"{"error": "error.invalid_credentials"}"#
|
||||
let result = ErrorMessageParser.parse(json)
|
||||
#expect(result == "Invalid username or password. Please try again.")
|
||||
}
|
||||
|
||||
@Test func jsonWithMessageFieldParsed() {
|
||||
let json = #"{"message": "Something went wrong"}"#
|
||||
let result = ErrorMessageParser.parse(json)
|
||||
#expect(result == "Something went wrong")
|
||||
}
|
||||
|
||||
@Test func jsonWithDetailFieldParsed() {
|
||||
let json = #"{"detail": "Not authorized"}"#
|
||||
let result = ErrorMessageParser.parse(json)
|
||||
#expect(result == "Not authorized")
|
||||
}
|
||||
|
||||
@Test func jsonWithDataObjectReturnsGeneric() {
|
||||
let json = #"{"id": 1, "title": "Test"}"#
|
||||
let result = ErrorMessageParser.parse(json)
|
||||
#expect(result == "Request failed. Please check your input and try again.")
|
||||
}
|
||||
|
||||
@Test func invalidJsonReturnsGeneric() {
|
||||
let json = #"{malformed json"#
|
||||
let result = ErrorMessageParser.parse(json)
|
||||
#expect(result == "An error occurred. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User-Friendly Message Tests
|
||||
|
||||
struct UserFriendlyMessageTests {
|
||||
|
||||
@Test func shortReadableMessagePassedThrough() {
|
||||
let result = ErrorMessageParser.parse("Invalid email address")
|
||||
#expect(result == "Invalid email address")
|
||||
}
|
||||
|
||||
@Test func emptyStringReturnsSelf() {
|
||||
// Empty/whitespace strings pass through the parser's user-friendly check
|
||||
let result = ErrorMessageParser.parse("")
|
||||
#expect(result == "")
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyReturnsTrimmed() {
|
||||
let result = ErrorMessageParser.parse(" ")
|
||||
#expect(result == "")
|
||||
}
|
||||
}
|
||||
218
iosApp/HoneyDueTests/PasswordResetViewModelTests.swift
Normal file
218
iosApp/HoneyDueTests/PasswordResetViewModelTests.swift
Normal file
@@ -0,0 +1,218 @@
|
||||
//
|
||||
// PasswordResetViewModelTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for PasswordResetViewModel navigation, state management,
|
||||
// and client-side validation (no network calls).
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import honeyDue
|
||||
|
||||
// MARK: - PasswordResetStep Tests
|
||||
|
||||
struct PasswordResetStepTests {
|
||||
|
||||
@Test func allCasesHasFiveValues() {
|
||||
#expect(PasswordResetStep.allCases.count == 5)
|
||||
}
|
||||
|
||||
@Test func allCasesContainsExpectedSteps() {
|
||||
let cases = PasswordResetStep.allCases
|
||||
#expect(cases.contains(.requestCode))
|
||||
#expect(cases.contains(.verifyCode))
|
||||
#expect(cases.contains(.resetPassword))
|
||||
#expect(cases.contains(.loggingIn))
|
||||
#expect(cases.contains(.success))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization Tests
|
||||
|
||||
@MainActor
|
||||
struct PasswordResetViewModelInitTests {
|
||||
|
||||
@Test func defaultInitStartsAtRequestCode() {
|
||||
let vm = PasswordResetViewModel()
|
||||
#expect(vm.currentStep == .requestCode)
|
||||
#expect(vm.resetToken == nil)
|
||||
}
|
||||
|
||||
@Test func initWithTokenStartsAtResetPassword() {
|
||||
let vm = PasswordResetViewModel(resetToken: "abc123")
|
||||
#expect(vm.currentStep == .resetPassword)
|
||||
#expect(vm.resetToken == "abc123")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation Tests
|
||||
|
||||
@MainActor
|
||||
struct PasswordResetViewModelNavigationTests {
|
||||
|
||||
@Test func moveToNextStepFromRequestCode() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.currentStep = .requestCode
|
||||
vm.moveToNextStep()
|
||||
#expect(vm.currentStep == .verifyCode)
|
||||
}
|
||||
|
||||
@Test func moveToNextStepFromVerifyCode() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.currentStep = .verifyCode
|
||||
vm.moveToNextStep()
|
||||
#expect(vm.currentStep == .resetPassword)
|
||||
}
|
||||
|
||||
@Test func moveToNextStepFromResetPassword() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.currentStep = .resetPassword
|
||||
vm.moveToNextStep()
|
||||
#expect(vm.currentStep == .loggingIn)
|
||||
}
|
||||
|
||||
@Test func moveToNextStepFromLoggingIn() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.currentStep = .loggingIn
|
||||
vm.moveToNextStep()
|
||||
#expect(vm.currentStep == .success)
|
||||
}
|
||||
|
||||
@Test func moveToNextStepFromSuccessIsNoOp() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.currentStep = .success
|
||||
vm.moveToNextStep()
|
||||
#expect(vm.currentStep == .success)
|
||||
}
|
||||
|
||||
@Test func moveToPreviousStepFromVerifyCode() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.currentStep = .verifyCode
|
||||
vm.moveToPreviousStep()
|
||||
#expect(vm.currentStep == .requestCode)
|
||||
}
|
||||
|
||||
@Test func moveToPreviousStepFromResetPassword() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.currentStep = .resetPassword
|
||||
vm.moveToPreviousStep()
|
||||
#expect(vm.currentStep == .verifyCode)
|
||||
}
|
||||
|
||||
@Test func moveToPreviousStepFromRequestCodeIsNoOp() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.currentStep = .requestCode
|
||||
vm.moveToPreviousStep()
|
||||
#expect(vm.currentStep == .requestCode)
|
||||
}
|
||||
|
||||
@Test func moveToPreviousStepFromLoggingInIsNoOp() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.currentStep = .loggingIn
|
||||
vm.moveToPreviousStep()
|
||||
#expect(vm.currentStep == .loggingIn)
|
||||
}
|
||||
|
||||
@Test func moveToPreviousStepFromSuccessIsNoOp() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.currentStep = .success
|
||||
vm.moveToPreviousStep()
|
||||
#expect(vm.currentStep == .success)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reset and Clear Tests
|
||||
|
||||
@MainActor
|
||||
struct PasswordResetViewModelResetTests {
|
||||
|
||||
@Test func resetClearsAllState() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.email = "test@example.com"
|
||||
vm.code = "123456"
|
||||
vm.newPassword = "password123"
|
||||
vm.confirmPassword = "password123"
|
||||
vm.resetToken = "token"
|
||||
vm.errorMessage = "error"
|
||||
vm.successMessage = "success"
|
||||
vm.currentStep = .resetPassword
|
||||
vm.isLoading = true
|
||||
|
||||
vm.reset()
|
||||
|
||||
#expect(vm.email == "")
|
||||
#expect(vm.code == "")
|
||||
#expect(vm.newPassword == "")
|
||||
#expect(vm.confirmPassword == "")
|
||||
#expect(vm.resetToken == nil)
|
||||
#expect(vm.errorMessage == nil)
|
||||
#expect(vm.successMessage == nil)
|
||||
#expect(vm.currentStep == .requestCode)
|
||||
#expect(vm.isLoading == false)
|
||||
}
|
||||
|
||||
@Test func clearErrorNilsOutErrorMessage() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.errorMessage = "Something went wrong"
|
||||
vm.clearError()
|
||||
#expect(vm.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test func clearSuccessNilsOutSuccessMessage() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.successMessage = "Code sent!"
|
||||
vm.clearSuccess()
|
||||
#expect(vm.successMessage == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Client-Side Validation Tests (verifyResetCode / resetPassword gates)
|
||||
|
||||
@MainActor
|
||||
struct PasswordResetViewModelValidationTests {
|
||||
|
||||
@Test func verifyResetCodeWithEmptyCodeSetsError() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.code = ""
|
||||
vm.verifyResetCode()
|
||||
#expect(vm.errorMessage == "Code is required")
|
||||
}
|
||||
|
||||
@Test func verifyResetCodeWithNonNumericCodeSetsError() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.code = "abcdef"
|
||||
vm.verifyResetCode()
|
||||
#expect(vm.errorMessage == "Code must be 6 digits")
|
||||
}
|
||||
|
||||
@Test func resetPasswordWithEmptyPasswordSetsError() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.newPassword = ""
|
||||
vm.resetPassword()
|
||||
#expect(vm.errorMessage == "Password is required")
|
||||
}
|
||||
|
||||
@Test func resetPasswordWithWeakPasswordSetsError() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.newPassword = "abcdefgh" // no number
|
||||
vm.resetPassword()
|
||||
#expect(vm.errorMessage == "Password must contain at least one number")
|
||||
}
|
||||
|
||||
@Test func resetPasswordWithMismatchedPasswordsSetsError() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.newPassword = "abc12345"
|
||||
vm.confirmPassword = "xyz12345"
|
||||
vm.resetPassword()
|
||||
#expect(vm.errorMessage == "Passwords do not match")
|
||||
}
|
||||
|
||||
@Test func resetPasswordWithNilTokenSetsError() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.newPassword = "abc12345"
|
||||
vm.confirmPassword = "abc12345"
|
||||
vm.resetToken = nil
|
||||
vm.resetPassword()
|
||||
#expect(vm.errorMessage == "Invalid reset token. Please start over.")
|
||||
}
|
||||
}
|
||||
203
iosApp/HoneyDueTests/StringExtensionsTests.swift
Normal file
203
iosApp/HoneyDueTests/StringExtensionsTests.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
//
|
||||
// StringExtensionsTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for String and Optional<String> extensions.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import honeyDue
|
||||
|
||||
// MARK: - isBlank Tests
|
||||
|
||||
struct IsBlankTests {
|
||||
|
||||
@Test func emptyStringIsBlank() {
|
||||
#expect("".isBlank)
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyIsBlank() {
|
||||
#expect(" ".isBlank)
|
||||
}
|
||||
|
||||
@Test func nonEmptyIsNotBlank() {
|
||||
#expect(!"hello".isBlank)
|
||||
}
|
||||
|
||||
@Test func stringWithWhitespacePaddingIsNotBlank() {
|
||||
#expect(!" hello ".isBlank)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - nilIfBlank Tests
|
||||
|
||||
struct NilIfBlankTests {
|
||||
|
||||
@Test func emptyReturnsNil() {
|
||||
#expect("".nilIfBlank == nil)
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyReturnsNil() {
|
||||
#expect(" ".nilIfBlank == nil)
|
||||
}
|
||||
|
||||
@Test func nonEmptyReturnsTrimmed() {
|
||||
#expect(" hello ".nilIfBlank == "hello")
|
||||
}
|
||||
|
||||
@Test func plainStringReturnsSelf() {
|
||||
#expect("test".nilIfBlank == "test")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - capitalizedFirst Tests
|
||||
|
||||
struct CapitalizedFirstTests {
|
||||
|
||||
@Test func lowercaseFirstCapitalized() {
|
||||
#expect("hello".capitalizedFirst == "Hello")
|
||||
}
|
||||
|
||||
@Test func alreadyCapitalizedUnchanged() {
|
||||
#expect("Hello".capitalizedFirst == "Hello")
|
||||
}
|
||||
|
||||
@Test func allUppercaseOnlyFirstKept() {
|
||||
#expect("hELLO".capitalizedFirst == "HELLO")
|
||||
}
|
||||
|
||||
@Test func emptyStringReturnsEmpty() {
|
||||
#expect("".capitalizedFirst == "")
|
||||
}
|
||||
|
||||
@Test func singleCharCapitalized() {
|
||||
#expect("a".capitalizedFirst == "A")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - truncated Tests
|
||||
|
||||
struct TruncatedTests {
|
||||
|
||||
@Test func shortStringNotTruncated() {
|
||||
#expect("hi".truncated(to: 10) == "hi")
|
||||
}
|
||||
|
||||
@Test func longStringTruncatedWithEllipsis() {
|
||||
#expect("Hello World".truncated(to: 5) == "Hello...")
|
||||
}
|
||||
|
||||
@Test func longStringTruncatedWithoutEllipsis() {
|
||||
#expect("Hello World".truncated(to: 5, addEllipsis: false) == "Hello")
|
||||
}
|
||||
|
||||
@Test func exactLengthNotTruncated() {
|
||||
#expect("Hello".truncated(to: 5) == "Hello")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - isValidEmail Tests
|
||||
|
||||
struct IsValidEmailTests {
|
||||
|
||||
@Test func standardEmailValid() {
|
||||
#expect("user@example.com".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func emailWithSubdomainValid() {
|
||||
#expect("user@mail.example.com".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func emailWithPlusValid() {
|
||||
#expect("user+tag@example.com".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func emailWithDotsValid() {
|
||||
#expect("first.last@example.com".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func noAtSignInvalid() {
|
||||
#expect(!"userexample.com".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func noDomainInvalid() {
|
||||
#expect(!"user@".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func noTLDInvalid() {
|
||||
#expect(!"user@example".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func emptyStringInvalid() {
|
||||
#expect(!"".isValidEmail)
|
||||
}
|
||||
|
||||
@Test func numericLocalPartValid() {
|
||||
#expect("123@example.com".isValidEmail)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - isValidPhone Tests
|
||||
|
||||
struct IsValidPhoneTests {
|
||||
|
||||
@Test func tenDigitsValid() {
|
||||
#expect("5551234567".isValidPhone)
|
||||
}
|
||||
|
||||
@Test func formattedUSPhoneValid() {
|
||||
#expect("(555) 123-4567".isValidPhone)
|
||||
}
|
||||
|
||||
@Test func internationalFormatValid() {
|
||||
#expect("+1 555-123-4567".isValidPhone)
|
||||
}
|
||||
|
||||
@Test func tooShortInvalid() {
|
||||
#expect(!"12345".isValidPhone)
|
||||
}
|
||||
|
||||
@Test func lettersInvalid() {
|
||||
#expect(!"555-ABC-DEFG".isValidPhone)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Optional String Tests
|
||||
|
||||
struct OptionalStringTests {
|
||||
|
||||
@Test func nilIsNilOrBlank() {
|
||||
let s: String? = nil
|
||||
#expect(s.isNilOrBlank)
|
||||
}
|
||||
|
||||
@Test func emptyIsNilOrBlank() {
|
||||
let s: String? = ""
|
||||
#expect(s.isNilOrBlank)
|
||||
}
|
||||
|
||||
@Test func whitespaceIsNilOrBlank() {
|
||||
let s: String? = " "
|
||||
#expect(s.isNilOrBlank)
|
||||
}
|
||||
|
||||
@Test func valueIsNotNilOrBlank() {
|
||||
let s: String? = "hello"
|
||||
#expect(!s.isNilOrBlank)
|
||||
}
|
||||
|
||||
@Test func nilNilIfBlankReturnsNil() {
|
||||
let s: String? = nil
|
||||
#expect(s.nilIfBlank == nil)
|
||||
}
|
||||
|
||||
@Test func blankOptionalNilIfBlankReturnsNil() {
|
||||
let s: String? = " "
|
||||
#expect(s.nilIfBlank == nil)
|
||||
}
|
||||
|
||||
@Test func valueOptionalNilIfBlankReturnsTrimmed() {
|
||||
let s: String? = " hello "
|
||||
#expect(s.nilIfBlank == "hello")
|
||||
}
|
||||
}
|
||||
253
iosApp/HoneyDueTests/SubscriptionGatingTests.swift
Normal file
253
iosApp/HoneyDueTests/SubscriptionGatingTests.swift
Normal file
@@ -0,0 +1,253 @@
|
||||
//
|
||||
// SubscriptionGatingTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for SubscriptionCacheWrapper feature gating logic:
|
||||
// currentTier, shouldShowUpgradePrompt, canShareResidence, canShareContractor.
|
||||
//
|
||||
// Uses the shared singleton with serialized tests to avoid race conditions.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import honeyDue
|
||||
import ComposeApp
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Build a SubscriptionStatus with sensible defaults for testing.
|
||||
private func makeSubscription(
|
||||
expiresAt: String? = nil,
|
||||
limitationsEnabled: Bool = false,
|
||||
limits: [String: TierLimits] = [:]
|
||||
) -> SubscriptionStatus {
|
||||
SubscriptionStatus(
|
||||
subscribedAt: nil,
|
||||
expiresAt: expiresAt,
|
||||
autoRenew: true,
|
||||
usage: UsageStats(propertiesCount: 0, tasksCount: 0, contractorsCount: 0, documentsCount: 0),
|
||||
limits: limits,
|
||||
limitationsEnabled: limitationsEnabled
|
||||
)
|
||||
}
|
||||
|
||||
private let freeLimits = TierLimits(
|
||||
properties: KotlinInt(int: 1),
|
||||
tasks: KotlinInt(int: 10),
|
||||
contractors: KotlinInt(int: 5),
|
||||
documents: KotlinInt(int: 5)
|
||||
)
|
||||
|
||||
private let proLimits = TierLimits(
|
||||
properties: nil,
|
||||
tasks: nil,
|
||||
contractors: nil,
|
||||
documents: nil
|
||||
)
|
||||
|
||||
// MARK: - Serialized Suite (SubscriptionCacheWrapper is a @MainActor shared singleton)
|
||||
|
||||
@MainActor
|
||||
@Suite(.serialized)
|
||||
struct SubscriptionGatingTests {
|
||||
|
||||
private let cache = SubscriptionCacheWrapper.shared
|
||||
|
||||
// MARK: - currentTier Tests
|
||||
|
||||
@Test func noSubscriptionTierIsFree() {
|
||||
cache.currentSubscription = nil
|
||||
#expect(cache.currentTier == "free")
|
||||
}
|
||||
|
||||
@Test func subscriptionWithExpiresAtTierIsPro() {
|
||||
cache.currentSubscription = makeSubscription(expiresAt: "2030-01-01T00:00:00Z")
|
||||
#expect(cache.currentTier == "pro")
|
||||
}
|
||||
|
||||
@Test func subscriptionWithEmptyExpiresAtTierIsFree() {
|
||||
cache.currentSubscription = makeSubscription(expiresAt: "")
|
||||
#expect(cache.currentTier == "free")
|
||||
}
|
||||
|
||||
@Test func subscriptionWithNilExpiresAtTierIsFree() {
|
||||
cache.currentSubscription = makeSubscription(expiresAt: nil)
|
||||
#expect(cache.currentTier == "free")
|
||||
}
|
||||
|
||||
// MARK: - shouldShowUpgradePrompt Tests
|
||||
|
||||
@Test func nilSubscriptionNeverBlocks() {
|
||||
cache.currentSubscription = nil
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 100, limitKey: "properties") == false)
|
||||
}
|
||||
|
||||
@Test func limitationsDisabledNeverBlocks() {
|
||||
cache.currentSubscription = makeSubscription(
|
||||
limitationsEnabled: false,
|
||||
limits: ["free": freeLimits]
|
||||
)
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 100, limitKey: "properties") == false)
|
||||
}
|
||||
|
||||
@Test func proTierNeverBlocks() {
|
||||
cache.currentSubscription = makeSubscription(
|
||||
expiresAt: "2030-01-01T00:00:00Z",
|
||||
limitationsEnabled: true,
|
||||
limits: ["pro": proLimits]
|
||||
)
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 100, limitKey: "properties") == false)
|
||||
}
|
||||
|
||||
@Test func freeTierUnderLimitAllowed() {
|
||||
cache.currentSubscription = makeSubscription(
|
||||
limitationsEnabled: true,
|
||||
limits: ["free": freeLimits]
|
||||
)
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "properties") == false)
|
||||
}
|
||||
|
||||
@Test func freeTierAtLimitBlocked() {
|
||||
cache.currentSubscription = makeSubscription(
|
||||
limitationsEnabled: true,
|
||||
limits: ["free": freeLimits]
|
||||
)
|
||||
// freeLimits.properties = 1, so count of 1 should block
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 1, limitKey: "properties") == true)
|
||||
}
|
||||
|
||||
@Test func freeTierOverLimitBlocked() {
|
||||
cache.currentSubscription = makeSubscription(
|
||||
limitationsEnabled: true,
|
||||
limits: ["free": freeLimits]
|
||||
)
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 5, limitKey: "properties") == true)
|
||||
}
|
||||
|
||||
@Test func tasksLimitEnforced() {
|
||||
cache.currentSubscription = makeSubscription(
|
||||
limitationsEnabled: true,
|
||||
limits: ["free": freeLimits]
|
||||
)
|
||||
// freeLimits.tasks = 10
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 9, limitKey: "tasks") == false)
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 10, limitKey: "tasks") == true)
|
||||
}
|
||||
|
||||
@Test func contractorsLimitEnforced() {
|
||||
cache.currentSubscription = makeSubscription(
|
||||
limitationsEnabled: true,
|
||||
limits: ["free": freeLimits]
|
||||
)
|
||||
// freeLimits.contractors = 5
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 4, limitKey: "contractors") == false)
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 5, limitKey: "contractors") == true)
|
||||
}
|
||||
|
||||
@Test func documentsLimitEnforced() {
|
||||
cache.currentSubscription = makeSubscription(
|
||||
limitationsEnabled: true,
|
||||
limits: ["free": freeLimits]
|
||||
)
|
||||
// freeLimits.documents = 5
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 4, limitKey: "documents") == false)
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 5, limitKey: "documents") == true)
|
||||
}
|
||||
|
||||
@Test func nilLimitMeansUnlimited() {
|
||||
let unlimitedTasks = TierLimits(
|
||||
properties: KotlinInt(int: 1),
|
||||
tasks: nil,
|
||||
contractors: nil,
|
||||
documents: nil
|
||||
)
|
||||
cache.currentSubscription = makeSubscription(
|
||||
limitationsEnabled: true,
|
||||
limits: ["free": unlimitedTasks]
|
||||
)
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 10000, limitKey: "tasks") == false)
|
||||
}
|
||||
|
||||
@Test func unknownLimitKeyReturnsFalse() {
|
||||
cache.currentSubscription = makeSubscription(
|
||||
limitationsEnabled: true,
|
||||
limits: ["free": freeLimits]
|
||||
)
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 100, limitKey: "unknown") == false)
|
||||
}
|
||||
|
||||
@Test func noLimitsForTierReturnsFalse() {
|
||||
cache.currentSubscription = makeSubscription(
|
||||
limitationsEnabled: true,
|
||||
limits: [:] // no "free" key
|
||||
)
|
||||
#expect(cache.shouldShowUpgradePrompt(currentCount: 100, limitKey: "properties") == false)
|
||||
}
|
||||
|
||||
// MARK: - Deprecated shouldShowUpgradePrompt (computed property)
|
||||
|
||||
@Test func deprecatedPromptFreeWithLimitations() {
|
||||
cache.currentSubscription = makeSubscription(limitationsEnabled: true)
|
||||
#expect(cache.shouldShowUpgradePrompt == true)
|
||||
}
|
||||
|
||||
@Test func deprecatedPromptFreeWithoutLimitations() {
|
||||
cache.currentSubscription = makeSubscription(limitationsEnabled: false)
|
||||
#expect(cache.shouldShowUpgradePrompt == false)
|
||||
}
|
||||
|
||||
@Test func deprecatedPromptNilSubscription() {
|
||||
cache.currentSubscription = nil
|
||||
#expect(cache.shouldShowUpgradePrompt == false)
|
||||
}
|
||||
|
||||
// MARK: - canShareResidence Tests
|
||||
|
||||
@Test func canShareResidenceWhenNoSubscription() {
|
||||
cache.currentSubscription = nil
|
||||
#expect(cache.canShareResidence() == true)
|
||||
}
|
||||
|
||||
@Test func canShareResidenceWhenLimitationsDisabled() {
|
||||
cache.currentSubscription = makeSubscription(limitationsEnabled: false)
|
||||
#expect(cache.canShareResidence() == true)
|
||||
}
|
||||
|
||||
@Test func cannotShareResidenceWhenFreeWithLimitations() {
|
||||
cache.currentSubscription = makeSubscription(limitationsEnabled: true)
|
||||
#expect(cache.canShareResidence() == false)
|
||||
}
|
||||
|
||||
@Test func canShareResidenceWhenProWithLimitations() {
|
||||
cache.currentSubscription = makeSubscription(
|
||||
expiresAt: "2030-01-01T00:00:00Z",
|
||||
limitationsEnabled: true
|
||||
)
|
||||
#expect(cache.canShareResidence() == true)
|
||||
}
|
||||
|
||||
// MARK: - canShareContractor Tests
|
||||
|
||||
@Test func canShareContractorWhenNoSubscription() {
|
||||
cache.currentSubscription = nil
|
||||
#expect(cache.canShareContractor() == true)
|
||||
}
|
||||
|
||||
@Test func canShareContractorWhenLimitationsDisabled() {
|
||||
cache.currentSubscription = makeSubscription(limitationsEnabled: false)
|
||||
#expect(cache.canShareContractor() == true)
|
||||
}
|
||||
|
||||
@Test func cannotShareContractorWhenFreeWithLimitations() {
|
||||
cache.currentSubscription = makeSubscription(limitationsEnabled: true)
|
||||
#expect(cache.canShareContractor() == false)
|
||||
}
|
||||
|
||||
@Test func canShareContractorWhenProWithLimitations() {
|
||||
cache.currentSubscription = makeSubscription(
|
||||
expiresAt: "2030-01-01T00:00:00Z",
|
||||
limitationsEnabled: true
|
||||
)
|
||||
#expect(cache.canShareContractor() == true)
|
||||
}
|
||||
}
|
||||
395
iosApp/HoneyDueTests/TaskMetricsTests.swift
Normal file
395
iosApp/HoneyDueTests/TaskMetricsTests.swift
Normal file
@@ -0,0 +1,395 @@
|
||||
//
|
||||
// TaskMetricsTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for WidgetDataManager.TaskMetrics and task categorization logic.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import honeyDue
|
||||
|
||||
// MARK: - Column Name Constants Tests
|
||||
|
||||
struct ColumnNameConstantsTests {
|
||||
|
||||
@Test func overdueColumnName() {
|
||||
#expect(WidgetDataManager.overdueColumn == "overdue_tasks")
|
||||
}
|
||||
|
||||
@Test func dueWithin7DaysColumnName() {
|
||||
#expect(WidgetDataManager.dueWithin7DaysColumn == "due_soon_tasks")
|
||||
}
|
||||
|
||||
@Test func due8To30DaysColumnName() {
|
||||
#expect(WidgetDataManager.due8To30DaysColumn == "upcoming_tasks")
|
||||
}
|
||||
|
||||
@Test func completedColumnName() {
|
||||
#expect(WidgetDataManager.completedColumn == "completed_tasks")
|
||||
}
|
||||
|
||||
@Test func cancelledColumnName() {
|
||||
#expect(WidgetDataManager.cancelledColumn == "cancelled_tasks")
|
||||
}
|
||||
|
||||
@Test func allColumnNamesUnique() {
|
||||
let columns = [
|
||||
WidgetDataManager.overdueColumn,
|
||||
WidgetDataManager.dueWithin7DaysColumn,
|
||||
WidgetDataManager.due8To30DaysColumn,
|
||||
WidgetDataManager.completedColumn,
|
||||
WidgetDataManager.cancelledColumn
|
||||
]
|
||||
#expect(Set(columns).count == columns.count)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TaskMetrics Calculation Tests
|
||||
|
||||
struct TaskMetricsCalculationTests {
|
||||
|
||||
private func makeTask(
|
||||
id: Int,
|
||||
isOverdue: Bool = false,
|
||||
isDueWithin7Days: Bool = false,
|
||||
isDue8To30Days: Bool = false
|
||||
) -> WidgetDataManager.WidgetTask {
|
||||
WidgetDataManager.WidgetTask(
|
||||
id: id,
|
||||
title: "Task \(id)",
|
||||
description: nil,
|
||||
priority: "medium",
|
||||
inProgress: false,
|
||||
dueDate: nil,
|
||||
category: "maintenance",
|
||||
residenceName: "Home",
|
||||
isOverdue: isOverdue,
|
||||
isDueWithin7Days: isDueWithin7Days,
|
||||
isDue8To30Days: isDue8To30Days
|
||||
)
|
||||
}
|
||||
|
||||
@Test func emptyArrayReturnsZeroCounts() {
|
||||
let metrics = WidgetDataManager.calculateMetrics(from: [])
|
||||
#expect(metrics.totalCount == 0)
|
||||
#expect(metrics.overdueCount == 0)
|
||||
#expect(metrics.upcoming7Days == 0)
|
||||
#expect(metrics.upcoming30Days == 0)
|
||||
}
|
||||
|
||||
@Test func allOverdueTasks() {
|
||||
let tasks = [
|
||||
makeTask(id: 1, isOverdue: true),
|
||||
makeTask(id: 2, isOverdue: true),
|
||||
makeTask(id: 3, isOverdue: true)
|
||||
]
|
||||
let metrics = WidgetDataManager.calculateMetrics(from: tasks)
|
||||
#expect(metrics.totalCount == 3)
|
||||
#expect(metrics.overdueCount == 3)
|
||||
#expect(metrics.upcoming7Days == 0)
|
||||
#expect(metrics.upcoming30Days == 0)
|
||||
}
|
||||
|
||||
@Test func allDueWithin7DaysTasks() {
|
||||
let tasks = [
|
||||
makeTask(id: 1, isDueWithin7Days: true),
|
||||
makeTask(id: 2, isDueWithin7Days: true),
|
||||
makeTask(id: 3, isDueWithin7Days: true),
|
||||
makeTask(id: 4, isDueWithin7Days: true)
|
||||
]
|
||||
let metrics = WidgetDataManager.calculateMetrics(from: tasks)
|
||||
#expect(metrics.totalCount == 4)
|
||||
#expect(metrics.overdueCount == 0)
|
||||
#expect(metrics.upcoming7Days == 4)
|
||||
#expect(metrics.upcoming30Days == 0)
|
||||
}
|
||||
|
||||
@Test func allDue8To30DaysTasks() {
|
||||
let tasks = [
|
||||
makeTask(id: 1, isDue8To30Days: true),
|
||||
makeTask(id: 2, isDue8To30Days: true)
|
||||
]
|
||||
let metrics = WidgetDataManager.calculateMetrics(from: tasks)
|
||||
#expect(metrics.totalCount == 2)
|
||||
#expect(metrics.overdueCount == 0)
|
||||
#expect(metrics.upcoming7Days == 0)
|
||||
#expect(metrics.upcoming30Days == 2)
|
||||
}
|
||||
|
||||
@Test func mixedTaskCategories() {
|
||||
let tasks = [
|
||||
makeTask(id: 1, isOverdue: true),
|
||||
makeTask(id: 2, isOverdue: true),
|
||||
makeTask(id: 3, isDueWithin7Days: true),
|
||||
makeTask(id: 4, isDueWithin7Days: true),
|
||||
makeTask(id: 5, isDueWithin7Days: true),
|
||||
makeTask(id: 6, isDue8To30Days: true)
|
||||
]
|
||||
let metrics = WidgetDataManager.calculateMetrics(from: tasks)
|
||||
#expect(metrics.totalCount == 6)
|
||||
#expect(metrics.overdueCount == 2)
|
||||
#expect(metrics.upcoming7Days == 3)
|
||||
#expect(metrics.upcoming30Days == 1)
|
||||
}
|
||||
|
||||
@Test func tasksWithNoFlagsCountedInTotalOnly() {
|
||||
let tasks = [
|
||||
makeTask(id: 1),
|
||||
makeTask(id: 2),
|
||||
makeTask(id: 3, isOverdue: true)
|
||||
]
|
||||
let metrics = WidgetDataManager.calculateMetrics(from: tasks)
|
||||
#expect(metrics.totalCount == 3)
|
||||
#expect(metrics.overdueCount == 1)
|
||||
#expect(metrics.upcoming7Days == 0)
|
||||
#expect(metrics.upcoming30Days == 0)
|
||||
}
|
||||
|
||||
@Test func singleTask() {
|
||||
let tasks = [makeTask(id: 1, isOverdue: true)]
|
||||
let metrics = WidgetDataManager.calculateMetrics(from: tasks)
|
||||
#expect(metrics.totalCount == 1)
|
||||
#expect(metrics.overdueCount == 1)
|
||||
}
|
||||
|
||||
@Test func largeDataset() {
|
||||
var tasks: [WidgetDataManager.WidgetTask] = []
|
||||
for i in 1...50 { tasks.append(makeTask(id: i, isOverdue: true)) }
|
||||
for i in 51...150 { tasks.append(makeTask(id: i, isDueWithin7Days: true)) }
|
||||
for i in 151...225 { tasks.append(makeTask(id: i, isDue8To30Days: true)) }
|
||||
for i in 226...250 { tasks.append(makeTask(id: i)) }
|
||||
|
||||
let metrics = WidgetDataManager.calculateMetrics(from: tasks)
|
||||
#expect(metrics.totalCount == 250)
|
||||
#expect(metrics.overdueCount == 50)
|
||||
#expect(metrics.upcoming7Days == 100)
|
||||
#expect(metrics.upcoming30Days == 75)
|
||||
}
|
||||
|
||||
@Test func orderDoesNotAffectMetrics() {
|
||||
let tasks1 = [
|
||||
makeTask(id: 1, isOverdue: true),
|
||||
makeTask(id: 2, isDueWithin7Days: true),
|
||||
makeTask(id: 3, isDue8To30Days: true)
|
||||
]
|
||||
let tasks2 = [
|
||||
makeTask(id: 3, isDue8To30Days: true),
|
||||
makeTask(id: 1, isOverdue: true),
|
||||
makeTask(id: 2, isDueWithin7Days: true)
|
||||
]
|
||||
let m1 = WidgetDataManager.calculateMetrics(from: tasks1)
|
||||
let m2 = WidgetDataManager.calculateMetrics(from: tasks2)
|
||||
#expect(m1.totalCount == m2.totalCount)
|
||||
#expect(m1.overdueCount == m2.overdueCount)
|
||||
#expect(m1.upcoming7Days == m2.upcoming7Days)
|
||||
#expect(m1.upcoming30Days == m2.upcoming30Days)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TaskMetrics Struct Tests
|
||||
|
||||
struct TaskMetricsStructTests {
|
||||
|
||||
@Test func initializesWithCorrectValues() {
|
||||
let metrics = WidgetDataManager.TaskMetrics(
|
||||
totalCount: 10,
|
||||
overdueCount: 2,
|
||||
upcoming7Days: 3,
|
||||
upcoming30Days: 5
|
||||
)
|
||||
#expect(metrics.totalCount == 10)
|
||||
#expect(metrics.overdueCount == 2)
|
||||
#expect(metrics.upcoming7Days == 3)
|
||||
#expect(metrics.upcoming30Days == 5)
|
||||
}
|
||||
|
||||
@Test func allowsZeroValues() {
|
||||
let metrics = WidgetDataManager.TaskMetrics(
|
||||
totalCount: 0,
|
||||
overdueCount: 0,
|
||||
upcoming7Days: 0,
|
||||
upcoming30Days: 0
|
||||
)
|
||||
#expect(metrics.totalCount == 0)
|
||||
#expect(metrics.overdueCount == 0)
|
||||
#expect(metrics.upcoming7Days == 0)
|
||||
#expect(metrics.upcoming30Days == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WidgetTask Codable Tests
|
||||
|
||||
struct WidgetTaskCodableTests {
|
||||
|
||||
@Test func decodesFromJSON() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 123,
|
||||
"title": "Test Task",
|
||||
"description": "Desc",
|
||||
"priority": "high",
|
||||
"in_progress": true,
|
||||
"due_date": "2024-12-25",
|
||||
"category": "plumbing",
|
||||
"residence_name": "Home",
|
||||
"is_overdue": false,
|
||||
"is_due_within_7_days": true,
|
||||
"is_due_8_to_30_days": false
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let task = try JSONDecoder().decode(WidgetDataManager.WidgetTask.self, from: json)
|
||||
#expect(task.id == 123)
|
||||
#expect(task.title == "Test Task")
|
||||
#expect(task.description == "Desc")
|
||||
#expect(task.priority == "high")
|
||||
#expect(task.inProgress == true)
|
||||
#expect(task.dueDate == "2024-12-25")
|
||||
#expect(task.category == "plumbing")
|
||||
#expect(task.residenceName == "Home")
|
||||
#expect(task.isOverdue == false)
|
||||
#expect(task.isDueWithin7Days == true)
|
||||
#expect(task.isDue8To30Days == false)
|
||||
}
|
||||
|
||||
@Test func encodesToJSON() throws {
|
||||
let task = WidgetDataManager.WidgetTask(
|
||||
id: 456,
|
||||
title: "Encode Test",
|
||||
description: nil,
|
||||
priority: "medium",
|
||||
inProgress: false,
|
||||
dueDate: "2024-06-01",
|
||||
category: "hvac",
|
||||
residenceName: nil,
|
||||
isOverdue: true,
|
||||
isDueWithin7Days: false,
|
||||
isDue8To30Days: false
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(task)
|
||||
let json = String(data: data, encoding: .utf8)!
|
||||
#expect(json.contains("\"id\":456"))
|
||||
#expect(json.contains("\"is_overdue\":true"))
|
||||
#expect(json.contains("\"in_progress\":false"))
|
||||
}
|
||||
|
||||
@Test func roundTripsJSON() throws {
|
||||
let original = WidgetDataManager.WidgetTask(
|
||||
id: 789,
|
||||
title: "Round Trip",
|
||||
description: "Testing",
|
||||
priority: "low",
|
||||
inProgress: true,
|
||||
dueDate: "2024-03-15",
|
||||
category: "landscaping",
|
||||
residenceName: "Cabin",
|
||||
isOverdue: false,
|
||||
isDueWithin7Days: true,
|
||||
isDue8To30Days: false
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(WidgetDataManager.WidgetTask.self, from: data)
|
||||
|
||||
#expect(decoded.id == original.id)
|
||||
#expect(decoded.title == original.title)
|
||||
#expect(decoded.description == original.description)
|
||||
#expect(decoded.inProgress == original.inProgress)
|
||||
#expect(decoded.isOverdue == original.isOverdue)
|
||||
#expect(decoded.isDueWithin7Days == original.isDueWithin7Days)
|
||||
#expect(decoded.isDue8To30Days == original.isDue8To30Days)
|
||||
}
|
||||
|
||||
@Test func handlesNilOptionalFields() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Minimal",
|
||||
"description": null,
|
||||
"priority": null,
|
||||
"in_progress": false,
|
||||
"due_date": null,
|
||||
"category": null,
|
||||
"residence_name": null,
|
||||
"is_overdue": false,
|
||||
"is_due_within_7_days": false,
|
||||
"is_due_8_to_30_days": false
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let task = try JSONDecoder().decode(WidgetDataManager.WidgetTask.self, from: json)
|
||||
#expect(task.id == 1)
|
||||
#expect(task.description == nil)
|
||||
#expect(task.priority == nil)
|
||||
#expect(task.dueDate == nil)
|
||||
#expect(task.category == nil)
|
||||
#expect(task.residenceName == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Realistic Scenario Tests
|
||||
|
||||
struct RealisticScenarioTests {
|
||||
|
||||
private func makeTask(
|
||||
id: Int,
|
||||
title: String,
|
||||
isOverdue: Bool = false,
|
||||
isDueWithin7Days: Bool = false,
|
||||
isDue8To30Days: Bool = false
|
||||
) -> WidgetDataManager.WidgetTask {
|
||||
WidgetDataManager.WidgetTask(
|
||||
id: id,
|
||||
title: title,
|
||||
description: nil,
|
||||
priority: "medium",
|
||||
inProgress: false,
|
||||
dueDate: nil,
|
||||
category: "maintenance",
|
||||
residenceName: "Home",
|
||||
isOverdue: isOverdue,
|
||||
isDueWithin7Days: isDueWithin7Days,
|
||||
isDue8To30Days: isDue8To30Days
|
||||
)
|
||||
}
|
||||
|
||||
@Test func newUserWithNoTasks() {
|
||||
let metrics = WidgetDataManager.calculateMetrics(from: [])
|
||||
#expect(metrics.totalCount == 0)
|
||||
#expect(metrics.overdueCount == 0)
|
||||
}
|
||||
|
||||
@Test func userWithOverdueTasks() {
|
||||
let tasks = [
|
||||
makeTask(id: 1, title: "HVAC maintenance", isOverdue: true),
|
||||
makeTask(id: 2, title: "Roof inspection", isOverdue: true),
|
||||
makeTask(id: 3, title: "Weekly lawn care", isDueWithin7Days: true),
|
||||
makeTask(id: 4, title: "Monthly pest control", isDue8To30Days: true)
|
||||
]
|
||||
let metrics = WidgetDataManager.calculateMetrics(from: tasks)
|
||||
#expect(metrics.totalCount == 4)
|
||||
#expect(metrics.overdueCount == 2)
|
||||
#expect(metrics.upcoming7Days == 1)
|
||||
#expect(metrics.upcoming30Days == 1)
|
||||
}
|
||||
|
||||
@Test func typicalHomeowner() {
|
||||
let tasks = [
|
||||
makeTask(id: 1, title: "Replace water heater anode", isOverdue: true),
|
||||
makeTask(id: 2, title: "Mow lawn", isDueWithin7Days: true),
|
||||
makeTask(id: 3, title: "Take out trash", isDueWithin7Days: true),
|
||||
makeTask(id: 4, title: "Water plants", isDueWithin7Days: true),
|
||||
makeTask(id: 5, title: "Change air filter", isDue8To30Days: true),
|
||||
makeTask(id: 6, title: "Clean dryer vent", isDue8To30Days: true),
|
||||
makeTask(id: 7, title: "Organize garage"),
|
||||
makeTask(id: 8, title: "Paint fence")
|
||||
]
|
||||
let metrics = WidgetDataManager.calculateMetrics(from: tasks)
|
||||
#expect(metrics.totalCount == 8)
|
||||
#expect(metrics.overdueCount == 1)
|
||||
#expect(metrics.upcoming7Days == 3)
|
||||
#expect(metrics.upcoming30Days == 2)
|
||||
}
|
||||
}
|
||||
50
iosApp/HoneyDueTests/ThemeIDTests.swift
Normal file
50
iosApp/HoneyDueTests/ThemeIDTests.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// ThemeIDTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for ThemeID enum properties and round-tripping.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import honeyDue
|
||||
|
||||
// MARK: - ThemeID Enum Tests
|
||||
|
||||
struct ThemeIDTests {
|
||||
|
||||
@Test func allCasesCountIsEleven() {
|
||||
#expect(ThemeID.allCases.count == 11)
|
||||
}
|
||||
|
||||
@Test func allDisplayNamesAreNonEmpty() {
|
||||
for theme in ThemeID.allCases {
|
||||
#expect(!theme.displayName.isEmpty, "ThemeID.\(theme) has empty displayName")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func allDescriptionsAreNonEmpty() {
|
||||
for theme in ThemeID.allCases {
|
||||
#expect(!theme.description.isEmpty, "ThemeID.\(theme) has empty description")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rawValueRoundTripsForAllCases() {
|
||||
for theme in ThemeID.allCases {
|
||||
let roundTripped = ThemeID(rawValue: theme.rawValue)
|
||||
#expect(roundTripped == theme, "ThemeID.\(theme) failed rawValue round-trip")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func allRawValuesAreUnique() {
|
||||
let rawValues = ThemeID.allCases.map(\.rawValue)
|
||||
#expect(Set(rawValues).count == rawValues.count)
|
||||
}
|
||||
|
||||
@Test func brightDisplayNameIsDefault() {
|
||||
#expect(ThemeID.bright.displayName == "Default")
|
||||
}
|
||||
|
||||
@Test func oceanRawValueIsOcean() {
|
||||
#expect(ThemeID.ocean.rawValue == "Ocean")
|
||||
}
|
||||
}
|
||||
445
iosApp/HoneyDueTests/ValidationHelpersTests.swift
Normal file
445
iosApp/HoneyDueTests/ValidationHelpersTests.swift
Normal file
@@ -0,0 +1,445 @@
|
||||
//
|
||||
// ValidationHelpersTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for ValidationHelpers, FormValidator, and related types.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import honeyDue
|
||||
|
||||
// MARK: - ValidationResult Tests
|
||||
|
||||
struct ValidationResultTests {
|
||||
|
||||
@Test func validResultIsValid() {
|
||||
let result = ValidationResult.valid
|
||||
#expect(result.isValid)
|
||||
#expect(result.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test func invalidResultIsNotValid() {
|
||||
let result = ValidationResult.invalid("Error")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Error")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Email Validation Tests
|
||||
|
||||
struct EmailValidationTests {
|
||||
|
||||
@Test func validEmailPasses() {
|
||||
let result = ValidationHelpers.validateEmail("user@example.com")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyEmailFails() {
|
||||
let result = ValidationHelpers.validateEmail("")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Email is required")
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyEmailFails() {
|
||||
let result = ValidationHelpers.validateEmail(" ")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Email is required")
|
||||
}
|
||||
|
||||
@Test func emailWithoutAtSignFails() {
|
||||
let result = ValidationHelpers.validateEmail("userexample.com")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Please enter a valid email address")
|
||||
}
|
||||
|
||||
@Test func emailWithoutDomainFails() {
|
||||
let result = ValidationHelpers.validateEmail("user@")
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
|
||||
@Test func emailWithSubdomainPasses() {
|
||||
let result = ValidationHelpers.validateEmail("user@mail.example.com")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emailWithPlusAddressingPasses() {
|
||||
let result = ValidationHelpers.validateEmail("user+tag@example.com")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emailWithDotsInLocalPartPasses() {
|
||||
let result = ValidationHelpers.validateEmail("first.last@example.com")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Validation Tests
|
||||
|
||||
struct PasswordValidationTests {
|
||||
|
||||
@Test func validPasswordPasses() {
|
||||
let result = ValidationHelpers.validatePassword("StrongPass1")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyPasswordFails() {
|
||||
let result = ValidationHelpers.validatePassword("")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Password is required")
|
||||
}
|
||||
|
||||
@Test func shortPasswordFails() {
|
||||
let result = ValidationHelpers.validatePassword("short")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Password must be at least 8 characters")
|
||||
}
|
||||
|
||||
@Test func exactMinLengthPasswordPasses() {
|
||||
let result = ValidationHelpers.validatePassword("12345678")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func customMinLengthEnforced() {
|
||||
let result = ValidationHelpers.validatePassword("1234", minLength: 6)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Password must be at least 6 characters")
|
||||
}
|
||||
|
||||
@Test func customMinLengthPassesWhenMet() {
|
||||
let result = ValidationHelpers.validatePassword("123456", minLength: 6)
|
||||
#expect(result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Confirmation Tests
|
||||
|
||||
struct PasswordConfirmationTests {
|
||||
|
||||
@Test func matchingPasswordsPasses() {
|
||||
let result = ValidationHelpers.validatePasswordConfirmation("password123", confirmation: "password123")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func mismatchedPasswordsFails() {
|
||||
let result = ValidationHelpers.validatePasswordConfirmation("password123", confirmation: "password456")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Passwords do not match")
|
||||
}
|
||||
|
||||
@Test func emptyConfirmationFails() {
|
||||
let result = ValidationHelpers.validatePasswordConfirmation("password123", confirmation: "")
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
|
||||
@Test func caseSensitiveMismatchFails() {
|
||||
let result = ValidationHelpers.validatePasswordConfirmation("Password", confirmation: "password")
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Name Validation Tests
|
||||
|
||||
struct NameValidationTests {
|
||||
|
||||
@Test func validNamePasses() {
|
||||
let result = ValidationHelpers.validateName("John")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyNameFails() {
|
||||
let result = ValidationHelpers.validateName("")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Name is required")
|
||||
}
|
||||
|
||||
@Test func singleCharNameFails() {
|
||||
let result = ValidationHelpers.validateName("J")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Name must be at least 2 characters")
|
||||
}
|
||||
|
||||
@Test func twoCharNamePasses() {
|
||||
let result = ValidationHelpers.validateName("Jo")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func customFieldNameInError() {
|
||||
let result = ValidationHelpers.validateName("", fieldName: "Username")
|
||||
#expect(result.errorMessage == "Username is required")
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyNameFails() {
|
||||
let result = ValidationHelpers.validateName(" ")
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phone Validation Tests
|
||||
|
||||
struct PhoneValidationTests {
|
||||
|
||||
@Test func validUSPhonePasses() {
|
||||
let result = ValidationHelpers.validatePhone("(555) 123-4567")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyPhoneFails() {
|
||||
let result = ValidationHelpers.validatePhone("")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Phone number is required")
|
||||
}
|
||||
|
||||
@Test func shortPhoneFails() {
|
||||
let result = ValidationHelpers.validatePhone("12345")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Please enter a valid phone number")
|
||||
}
|
||||
|
||||
@Test func phoneWithCountryCodePasses() {
|
||||
let result = ValidationHelpers.validatePhone("+1 555-123-4567")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func digitsOnlyPhonePasses() {
|
||||
let result = ValidationHelpers.validatePhone("5551234567")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Required Field Validation Tests
|
||||
|
||||
struct RequiredFieldValidationTests {
|
||||
|
||||
@Test func nonEmptyPasses() {
|
||||
let result = ValidationHelpers.validateRequired("value", fieldName: "Field")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyFails() {
|
||||
let result = ValidationHelpers.validateRequired("", fieldName: "Title")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Title is required")
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyFails() {
|
||||
let result = ValidationHelpers.validateRequired(" ", fieldName: "Description")
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Number Validation Tests
|
||||
|
||||
struct NumberValidationTests {
|
||||
|
||||
@Test func validNumberPasses() {
|
||||
let result = ValidationHelpers.validateNumber("42.5", fieldName: "Cost")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyNumberFails() {
|
||||
let result = ValidationHelpers.validateNumber("", fieldName: "Cost")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Cost is required")
|
||||
}
|
||||
|
||||
@Test func nonNumericFails() {
|
||||
let result = ValidationHelpers.validateNumber("abc", fieldName: "Cost")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Cost must be a valid number")
|
||||
}
|
||||
|
||||
@Test func belowMinFails() {
|
||||
let result = ValidationHelpers.validateNumber("5", fieldName: "Cost", min: 10)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Cost must be at least 10.0")
|
||||
}
|
||||
|
||||
@Test func aboveMaxFails() {
|
||||
let result = ValidationHelpers.validateNumber("100", fieldName: "Cost", max: 50)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Cost must be at most 50.0")
|
||||
}
|
||||
|
||||
@Test func withinRangePasses() {
|
||||
let result = ValidationHelpers.validateNumber("25", fieldName: "Cost", min: 10, max: 50)
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func negativeNumberValidated() {
|
||||
let result = ValidationHelpers.validateNumber("-5", fieldName: "Value", min: 0)
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Integer Validation Tests
|
||||
|
||||
struct IntegerValidationTests {
|
||||
|
||||
@Test func validIntegerPasses() {
|
||||
let result = ValidationHelpers.validateInteger("42", fieldName: "Bedrooms")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func decimalFails() {
|
||||
let result = ValidationHelpers.validateInteger("3.5", fieldName: "Bedrooms")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Bedrooms must be a whole number")
|
||||
}
|
||||
|
||||
@Test func belowMinFails() {
|
||||
let result = ValidationHelpers.validateInteger("0", fieldName: "Bedrooms", min: 1)
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
|
||||
@Test func aboveMaxFails() {
|
||||
let result = ValidationHelpers.validateInteger("100", fieldName: "Bedrooms", max: 50)
|
||||
#expect(!result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Length Validation Tests
|
||||
|
||||
struct LengthValidationTests {
|
||||
|
||||
@Test func withinLengthPasses() {
|
||||
let result = ValidationHelpers.validateLength("hello", fieldName: "Title", min: 2, max: 100)
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func tooShortFails() {
|
||||
let result = ValidationHelpers.validateLength("a", fieldName: "Title", min: 3)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Title must be at least 3 characters")
|
||||
}
|
||||
|
||||
@Test func tooLongFails() {
|
||||
let result = ValidationHelpers.validateLength("abcdef", fieldName: "Code", max: 5)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Code must be at most 5 characters")
|
||||
}
|
||||
|
||||
@Test func emptyWithNoMinPasses() {
|
||||
let result = ValidationHelpers.validateLength("", fieldName: "Notes")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL Validation Tests
|
||||
|
||||
struct URLValidationTests {
|
||||
|
||||
@Test func validURLPasses() {
|
||||
let result = ValidationHelpers.validateURL("https://example.com")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func emptyURLFails() {
|
||||
let result = ValidationHelpers.validateURL("")
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "URL is required")
|
||||
}
|
||||
|
||||
@Test func httpURLPasses() {
|
||||
let result = ValidationHelpers.validateURL("http://example.com/path")
|
||||
#expect(result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom Validation Tests
|
||||
|
||||
struct CustomValidationTests {
|
||||
|
||||
@Test func passingCustomValidatorPasses() {
|
||||
let result = ValidationHelpers.validateCustom(
|
||||
"abc123",
|
||||
fieldName: "Code",
|
||||
validator: { $0.count == 6 },
|
||||
errorMessage: "Code must be exactly 6 characters"
|
||||
)
|
||||
#expect(result.isValid)
|
||||
}
|
||||
|
||||
@Test func failingCustomValidatorFails() {
|
||||
let result = ValidationHelpers.validateCustom(
|
||||
"abc",
|
||||
fieldName: "Code",
|
||||
validator: { $0.count == 6 },
|
||||
errorMessage: "Code must be exactly 6 characters"
|
||||
)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Code must be exactly 6 characters")
|
||||
}
|
||||
|
||||
@Test func emptyValueInCustomValidatorFails() {
|
||||
let result = ValidationHelpers.validateCustom(
|
||||
"",
|
||||
fieldName: "Code",
|
||||
validator: { _ in true },
|
||||
errorMessage: "Unused"
|
||||
)
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errorMessage == "Code is required")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FormValidator Tests
|
||||
|
||||
struct FormValidatorTests {
|
||||
|
||||
@Test func allValidFieldsPass() {
|
||||
let validator = FormValidator()
|
||||
validator.add(fieldName: "email") { ValidationHelpers.validateEmail("user@example.com") }
|
||||
validator.add(fieldName: "name") { ValidationHelpers.validateName("John") }
|
||||
|
||||
let result = validator.validate()
|
||||
#expect(result.isValid)
|
||||
#expect(result.errors.isEmpty)
|
||||
}
|
||||
|
||||
@Test func singleInvalidFieldFails() {
|
||||
let validator = FormValidator()
|
||||
validator.add(fieldName: "email") { ValidationHelpers.validateEmail("") }
|
||||
validator.add(fieldName: "name") { ValidationHelpers.validateName("John") }
|
||||
|
||||
let result = validator.validate()
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errors.count == 1)
|
||||
#expect(result.errors["email"] != nil)
|
||||
}
|
||||
|
||||
@Test func multipleInvalidFieldsFail() {
|
||||
let validator = FormValidator()
|
||||
validator.add(fieldName: "email") { ValidationHelpers.validateEmail("") }
|
||||
validator.add(fieldName: "password") { ValidationHelpers.validatePassword("") }
|
||||
|
||||
let result = validator.validate()
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errors.count == 2)
|
||||
}
|
||||
|
||||
@Test func clearRemovesValidations() {
|
||||
let validator = FormValidator()
|
||||
validator.add(fieldName: "email") { ValidationHelpers.validateEmail("") }
|
||||
validator.clear()
|
||||
|
||||
let result = validator.validate()
|
||||
#expect(result.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FormValidationResult Tests
|
||||
|
||||
struct FormValidationResultTests {
|
||||
|
||||
@Test func validResultHasNoErrors() {
|
||||
let result = FormValidationResult.valid
|
||||
#expect(result.isValid)
|
||||
#expect(result.errors.isEmpty)
|
||||
}
|
||||
|
||||
@Test func invalidResultHasErrors() {
|
||||
let result = FormValidationResult.invalid(["email": "Email is required"])
|
||||
#expect(!result.isValid)
|
||||
#expect(result.errors["email"] == "Email is required")
|
||||
}
|
||||
}
|
||||
266
iosApp/HoneyDueTests/ValidationRulesTests.swift
Normal file
266
iosApp/HoneyDueTests/ValidationRulesTests.swift
Normal file
@@ -0,0 +1,266 @@
|
||||
//
|
||||
// ValidationRulesTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for ValidationError and ValidationRules (distinct from ValidationHelpers).
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import honeyDue
|
||||
|
||||
// MARK: - ValidationError errorDescription Tests
|
||||
|
||||
struct ValidationErrorTests {
|
||||
|
||||
@Test func requiredFieldErrorDescription() {
|
||||
let error = ValidationError.required(field: "Email")
|
||||
#expect(error.errorDescription == "Email is required")
|
||||
}
|
||||
|
||||
@Test func invalidEmailErrorDescription() {
|
||||
let error = ValidationError.invalidEmail
|
||||
#expect(error.errorDescription == "Please enter a valid email address")
|
||||
}
|
||||
|
||||
@Test func passwordTooShortErrorDescription() {
|
||||
let error = ValidationError.passwordTooShort(minLength: 8)
|
||||
#expect(error.errorDescription == "Password must be at least 8 characters")
|
||||
}
|
||||
|
||||
@Test func passwordMismatchErrorDescription() {
|
||||
let error = ValidationError.passwordMismatch
|
||||
#expect(error.errorDescription == "Passwords do not match")
|
||||
}
|
||||
|
||||
@Test func passwordMissingLetterErrorDescription() {
|
||||
let error = ValidationError.passwordMissingLetter
|
||||
#expect(error.errorDescription == "Password must contain at least one letter")
|
||||
}
|
||||
|
||||
@Test func passwordMissingNumberErrorDescription() {
|
||||
let error = ValidationError.passwordMissingNumber
|
||||
#expect(error.errorDescription == "Password must contain at least one number")
|
||||
}
|
||||
|
||||
@Test func invalidCodeErrorDescription() {
|
||||
let error = ValidationError.invalidCode(expectedLength: 6)
|
||||
#expect(error.errorDescription == "Code must be 6 digits")
|
||||
}
|
||||
|
||||
@Test func invalidUsernameErrorDescription() {
|
||||
let error = ValidationError.invalidUsername
|
||||
#expect(error.errorDescription == "Username can only contain letters, numbers, and underscores")
|
||||
}
|
||||
|
||||
@Test func customMessageErrorDescription() {
|
||||
let error = ValidationError.custom(message: "Something went wrong")
|
||||
#expect(error.errorDescription == "Something went wrong")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Email Validation Tests
|
||||
|
||||
struct ValidationRulesEmailTests {
|
||||
|
||||
@Test func emptyEmailReturnsRequired() {
|
||||
let error = ValidationRules.validateEmail("")
|
||||
#expect(error?.errorDescription == "Email is required")
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyEmailReturnsRequired() {
|
||||
let error = ValidationRules.validateEmail(" ")
|
||||
#expect(error?.errorDescription == "Email is required")
|
||||
}
|
||||
|
||||
@Test func invalidEmailReturnsInvalidEmail() {
|
||||
let error = ValidationRules.validateEmail("notanemail")
|
||||
#expect(error?.errorDescription == "Please enter a valid email address")
|
||||
}
|
||||
|
||||
@Test func validEmailReturnsNil() {
|
||||
let error = ValidationRules.validateEmail("user@example.com")
|
||||
#expect(error == nil)
|
||||
}
|
||||
|
||||
@Test func isValidEmailReturnsTrueForValid() {
|
||||
#expect(ValidationRules.isValidEmail("user@example.com"))
|
||||
}
|
||||
|
||||
@Test func isValidEmailReturnsFalseForInvalid() {
|
||||
#expect(!ValidationRules.isValidEmail("notanemail"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Validation Tests
|
||||
|
||||
struct ValidationRulesPasswordTests {
|
||||
|
||||
@Test func emptyPasswordReturnsRequired() {
|
||||
let error = ValidationRules.validatePassword("")
|
||||
#expect(error?.errorDescription == "Password is required")
|
||||
}
|
||||
|
||||
@Test func shortPasswordReturnsTooShort() {
|
||||
let error = ValidationRules.validatePassword("abc")
|
||||
#expect(error?.errorDescription == "Password must be at least 8 characters")
|
||||
}
|
||||
|
||||
@Test func validPasswordReturnsNil() {
|
||||
let error = ValidationRules.validatePassword("longpassword")
|
||||
#expect(error == nil)
|
||||
}
|
||||
|
||||
@Test func exactMinLengthPasswordReturnsNil() {
|
||||
let error = ValidationRules.validatePassword("12345678")
|
||||
#expect(error == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Strength Tests
|
||||
|
||||
struct ValidationRulesPasswordStrengthTests {
|
||||
|
||||
@Test func emptyPasswordReturnsRequired() {
|
||||
let error = ValidationRules.validatePasswordStrength("")
|
||||
#expect(error?.errorDescription == "Password is required")
|
||||
}
|
||||
|
||||
@Test func noLetterReturnsMissingLetter() {
|
||||
let error = ValidationRules.validatePasswordStrength("123456")
|
||||
#expect(error?.errorDescription == "Password must contain at least one letter")
|
||||
}
|
||||
|
||||
@Test func noNumberReturnsMissingNumber() {
|
||||
let error = ValidationRules.validatePasswordStrength("abcdef")
|
||||
#expect(error?.errorDescription == "Password must contain at least one number")
|
||||
}
|
||||
|
||||
@Test func letterAndNumberReturnsNil() {
|
||||
let error = ValidationRules.validatePasswordStrength("abc123")
|
||||
#expect(error == nil)
|
||||
}
|
||||
|
||||
@Test func isValidPasswordReturnsTrueForStrong() {
|
||||
#expect(ValidationRules.isValidPassword("abc123"))
|
||||
}
|
||||
|
||||
@Test func isValidPasswordReturnsFalseForLettersOnly() {
|
||||
#expect(!ValidationRules.isValidPassword("abcdef"))
|
||||
}
|
||||
|
||||
@Test func isValidPasswordReturnsFalseForNumbersOnly() {
|
||||
#expect(!ValidationRules.isValidPassword("123456"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Match Tests
|
||||
|
||||
struct ValidationRulesPasswordMatchTests {
|
||||
|
||||
@Test func matchingPasswordsReturnsNil() {
|
||||
let error = ValidationRules.validatePasswordMatch("abc123", "abc123")
|
||||
#expect(error == nil)
|
||||
}
|
||||
|
||||
@Test func mismatchedPasswordsReturnsMismatch() {
|
||||
let error = ValidationRules.validatePasswordMatch("abc123", "xyz789")
|
||||
#expect(error?.errorDescription == "Passwords do not match")
|
||||
}
|
||||
|
||||
@Test func caseSensitiveMismatch() {
|
||||
let error = ValidationRules.validatePasswordMatch("Password", "password")
|
||||
#expect(error?.errorDescription == "Passwords do not match")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Code Validation Tests
|
||||
|
||||
struct ValidationRulesCodeTests {
|
||||
|
||||
@Test func emptyCodeReturnsRequired() {
|
||||
let error = ValidationRules.validateCode("")
|
||||
#expect(error?.errorDescription == "Code is required")
|
||||
}
|
||||
|
||||
@Test func wrongLengthReturnsInvalidCode() {
|
||||
let error = ValidationRules.validateCode("123")
|
||||
#expect(error?.errorDescription == "Code must be 6 digits")
|
||||
}
|
||||
|
||||
@Test func nonNumericCodeReturnsInvalidCode() {
|
||||
let error = ValidationRules.validateCode("abcdef")
|
||||
#expect(error?.errorDescription == "Code must be 6 digits")
|
||||
}
|
||||
|
||||
@Test func validSixDigitCodeReturnsNil() {
|
||||
let error = ValidationRules.validateCode("123456")
|
||||
#expect(error == nil)
|
||||
}
|
||||
|
||||
@Test func customLengthFourDigitCode() {
|
||||
let error = ValidationRules.validateCode("1234", expectedLength: 4)
|
||||
#expect(error == nil)
|
||||
}
|
||||
|
||||
@Test func customLengthWrongDigitCount() {
|
||||
let error = ValidationRules.validateCode("123", expectedLength: 4)
|
||||
#expect(error?.errorDescription == "Code must be 4 digits")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Username Validation Tests
|
||||
|
||||
struct ValidationRulesUsernameTests {
|
||||
|
||||
@Test func emptyUsernameReturnsRequired() {
|
||||
let error = ValidationRules.validateUsername("")
|
||||
#expect(error?.errorDescription == "Username is required")
|
||||
}
|
||||
|
||||
@Test func validAlphanumericUsernameReturnsNil() {
|
||||
let error = ValidationRules.validateUsername("john_doe123")
|
||||
#expect(error == nil)
|
||||
}
|
||||
|
||||
@Test func usernameWithSpacesReturnsInvalid() {
|
||||
let error = ValidationRules.validateUsername("john doe")
|
||||
#expect(error?.errorDescription == "Username can only contain letters, numbers, and underscores")
|
||||
}
|
||||
|
||||
@Test func usernameWithSpecialCharsReturnsInvalid() {
|
||||
let error = ValidationRules.validateUsername("user@name!")
|
||||
#expect(error?.errorDescription == "Username can only contain letters, numbers, and underscores")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Required Field Validation Tests
|
||||
|
||||
struct ValidationRulesRequiredTests {
|
||||
|
||||
@Test func emptyValueReturnsRequired() {
|
||||
let error = ValidationRules.validateRequired("", fieldName: "Name")
|
||||
#expect(error?.errorDescription == "Name is required")
|
||||
}
|
||||
|
||||
@Test func whitespaceOnlyReturnsRequired() {
|
||||
let error = ValidationRules.validateRequired(" ", fieldName: "Name")
|
||||
#expect(error?.errorDescription == "Name is required")
|
||||
}
|
||||
|
||||
@Test func nonEmptyReturnsNil() {
|
||||
let error = ValidationRules.validateRequired("hello", fieldName: "Name")
|
||||
#expect(error == nil)
|
||||
}
|
||||
|
||||
@Test func isNotEmptyReturnsTrueForNonEmpty() {
|
||||
#expect(ValidationRules.isNotEmpty("hello"))
|
||||
}
|
||||
|
||||
@Test func isNotEmptyReturnsFalseForEmpty() {
|
||||
#expect(!ValidationRules.isNotEmpty(""))
|
||||
}
|
||||
|
||||
@Test func isNotEmptyReturnsFalseForWhitespace() {
|
||||
#expect(!ValidationRules.isNotEmpty(" "))
|
||||
}
|
||||
}
|
||||
102
iosApp/HoneyDueTests/WidgetActionTests.swift
Normal file
102
iosApp/HoneyDueTests/WidgetActionTests.swift
Normal file
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// WidgetActionTests.swift
|
||||
// honeyDueTests
|
||||
//
|
||||
// Unit tests for WidgetDataManager.WidgetAction (Codable, Equatable, accessors)
|
||||
// and WidgetDataManager.parseDate static helper.
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import honeyDue
|
||||
|
||||
// MARK: - WidgetAction Codable Tests
|
||||
|
||||
struct WidgetActionCodableTests {
|
||||
|
||||
@Test func encodeDecodeRoundTrip() throws {
|
||||
let original = WidgetDataManager.WidgetAction.completeTask(taskId: 42, taskTitle: "Fix leak")
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(WidgetDataManager.WidgetAction.self, from: data)
|
||||
#expect(decoded == original)
|
||||
}
|
||||
|
||||
@Test func decodedValuesMatch() throws {
|
||||
let original = WidgetDataManager.WidgetAction.completeTask(taskId: 99, taskTitle: "Paint walls")
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(WidgetDataManager.WidgetAction.self, from: data)
|
||||
#expect(decoded.taskId == 99)
|
||||
#expect(decoded.taskTitle == "Paint walls")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WidgetAction Equatable Tests
|
||||
|
||||
struct WidgetActionEquatableTests {
|
||||
|
||||
@Test func sameValuesAreEqual() {
|
||||
let a = WidgetDataManager.WidgetAction.completeTask(taskId: 1, taskTitle: "Test")
|
||||
let b = WidgetDataManager.WidgetAction.completeTask(taskId: 1, taskTitle: "Test")
|
||||
#expect(a == b)
|
||||
}
|
||||
|
||||
@Test func differentTaskIdNotEqual() {
|
||||
let a = WidgetDataManager.WidgetAction.completeTask(taskId: 1, taskTitle: "Test")
|
||||
let b = WidgetDataManager.WidgetAction.completeTask(taskId: 2, taskTitle: "Test")
|
||||
#expect(a != b)
|
||||
}
|
||||
|
||||
@Test func differentTaskTitleNotEqual() {
|
||||
let a = WidgetDataManager.WidgetAction.completeTask(taskId: 1, taskTitle: "Alpha")
|
||||
let b = WidgetDataManager.WidgetAction.completeTask(taskId: 1, taskTitle: "Beta")
|
||||
#expect(a != b)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WidgetAction Accessor Tests
|
||||
|
||||
struct WidgetActionAccessorTests {
|
||||
|
||||
@Test func taskIdReturnsCorrectValue() {
|
||||
let action = WidgetDataManager.WidgetAction.completeTask(taskId: 55, taskTitle: "Mow lawn")
|
||||
#expect(action.taskId == 55)
|
||||
}
|
||||
|
||||
@Test func taskTitleReturnsCorrectValue() {
|
||||
let action = WidgetDataManager.WidgetAction.completeTask(taskId: 55, taskTitle: "Mow lawn")
|
||||
#expect(action.taskTitle == "Mow lawn")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - parseDate Tests
|
||||
|
||||
struct ParseDateTests {
|
||||
|
||||
@Test func validDateStringReturnsDate() {
|
||||
let date = WidgetDataManager.parseDate("2024-06-15")
|
||||
#expect(date != nil)
|
||||
}
|
||||
|
||||
@Test func nilInputReturnsNil() {
|
||||
let date = WidgetDataManager.parseDate(nil)
|
||||
#expect(date == nil)
|
||||
}
|
||||
|
||||
@Test func emptyStringReturnsNil() {
|
||||
let date = WidgetDataManager.parseDate("")
|
||||
#expect(date == nil)
|
||||
}
|
||||
|
||||
@Test func isoDateTimeExtractsDatePart() {
|
||||
let date = WidgetDataManager.parseDate("2025-12-26T00:00:00Z")
|
||||
#expect(date != nil)
|
||||
// Should parse the same as just the date part
|
||||
let dateDirect = WidgetDataManager.parseDate("2025-12-26")
|
||||
#expect(date == dateDirect)
|
||||
}
|
||||
|
||||
@Test func invalidStringReturnsNil() {
|
||||
let date = WidgetDataManager.parseDate("not-a-date")
|
||||
#expect(date == nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user