Add 4 new unit test suites for greenfield test plan coverage

Add 87 new tests (384 total) covering ValidationRules/ValidationError,
PasswordResetViewModel navigation and client-side validation, WidgetAction
Codable/Equatable/accessors, parseDate, and ThemeID enum properties.
Updates greenfield CSV for AUTH-012/017/018, NOTIF-006/007, WID-003, THEME-002.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
treyt
2026-02-24 19:29:12 -06:00
parent 4679764fdf
commit 0c803af9bc
5 changed files with 643 additions and 7 deletions

View File

@@ -10,13 +10,13 @@
"AUTH-009","Authentication","Registration","Create account success then verify-email step","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Unique email/username","Submit valid registration","Session established and verify-email screen appears","Existing email conflict","Register API auto-authenticates","Automate","🟢 testR103_successfulRegistrationTransitionsToVerificationGate"
"AUTH-010","Authentication","Registration validation","Invalid email/password formats rejected","Manual + UI","P1","iOS, Android, Web, Desktop","No session","Try invalid email, short password, weak password","User-friendly validation errors","Unicode emails, long usernames","Validation rules are enforced consistently","Automate","🟢 test03_registrationWithEmptyFields | test04_registrationWithInvalidEmail | test06_registrationWithWeakPassword"
"AUTH-011","Authentication","Email verification","Valid 6-digit code verifies account","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Logged-in unverified user","Enter 6-digit numeric code","Account marked verified and app routes forward","Code already used","Code must be exactly 6 digits","Automate","🟢 testR105_validVerificationCodeTransitionsToMainApp | test07_successfulRegistrationAndVerification"
"AUTH-012","Authentication","Email verification","Non-numeric/short/long code blocked","Manual + UI","P1","iOS, Android, Web, Desktop","On verify-email screen","Enter invalid codes","Verify action disabled or error shown","Pasted with spaces","Code input sanitizes to digits","Automate","🟢 testR107_invalidVerificationCodeShowsErrorAndStaysBlocked | testR109_verifyButtonDisabledForIncompleteCode | test10_verificationCodeFieldValidation"
"AUTH-012","Authentication","Email verification","Non-numeric/short/long code blocked","Manual + UI","P1","iOS, Android, Web, Desktop","On verify-email screen","Enter invalid codes","Verify action disabled or error shown","Pasted with spaces","Code input sanitizes to digits","Automate","🟢 testR107_invalidVerificationCodeShowsErrorAndStaysBlocked | testR109_verifyButtonDisabledForIncompleteCode | test10_verificationCodeFieldValidation | emptyCodeReturnsRequired | wrongLengthReturnsInvalidCode | nonNumericCodeReturnsInvalidCode | validSixDigitCodeReturnsNil | customLengthFourDigitCode | customLengthWrongDigitCount (ValidationRulesTests)"
"AUTH-013","Authentication","Logout","Logout clears token, user data, lookups, and returns to login","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Logged in","Tap logout","Login shown; protected API calls fail without token","Logout API fails network-side","Client clears state even if API call fails","Automate","🟢 test06_logout | testR205_logoutFromMainAppReturnsToLoginRoot"
"AUTH-014","Authentication","Forgot password","Request reset by email success path","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Known account email","Submit forgot-password email","Success message and next step available","Unknown email behavior privacy-safe","Backend may still return generic success","Automate","🟢 test05_forgotPasswordNavigation | testF206_ForgotPasswordButtonIsAccessible | testF209_ForgotPasswordNavigatesToResetFlow"
"AUTH-015","Authentication","Reset code verification","Verify reset code success path","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Reset email submitted","Enter correct code","Reset-token available for password reset","Expired code","Code verification endpoint returns token","Automate","🟢 test03_verifyResetCodeSuccess"
"AUTH-016","Authentication","Password reset","Reset password success with matching confirmation","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Valid reset token","Enter new password + confirm","Password changed; user can log in (or auto-login flow)","Token expires mid-flow","Reset endpoint ignores confirm field","Automate","🟢 test04_resetPasswordSuccessAndLogin"
"AUTH-017","Authentication","Password reset","Mismatched password confirmation blocked","Manual + UI","P1","iOS, Android, Web, Desktop","Reset password screen","Enter mismatch","Inline error; no reset request","Trailing spaces","Client validates before submit","Automate","🟢 mismatchedPasswordsFails | caseSensitiveMismatchFails (ValidationHelpersTests)"
"AUTH-018","Authentication","Deep link reset","Password-reset deep link opens flow with token prefilled","Manual + E2E UI","P0","iOS, Android","Installed app with deep-link support","Open reset deep link","Forgot/reset flow opens and token is consumed","Malformed token in URL","Deep link token can be cleared on back","Automate",""
"AUTH-017","Authentication","Password reset","Mismatched password confirmation blocked","Manual + UI","P1","iOS, Android, Web, Desktop","Reset password screen","Enter mismatch","Inline error; no reset request","Trailing spaces","Client validates before submit","Automate","🟢 mismatchedPasswordsFails | caseSensitiveMismatchFails (ValidationHelpersTests) | matchingPasswordsReturnsNil | mismatchedPasswordsReturnsMismatch | caseSensitiveMismatch (ValidationRulesTests) | requiredFieldErrorDescription | invalidEmailErrorDescription | passwordTooShortErrorDescription | passwordMismatchErrorDescription | passwordMissingLetterErrorDescription | passwordMissingNumberErrorDescription | invalidCodeErrorDescription | invalidUsernameErrorDescription | customMessageErrorDescription (ValidationRulesTests)"
"AUTH-018","Authentication","Deep link reset","Password-reset deep link opens flow with token prefilled","Manual + E2E UI","P0","iOS, Android","Installed app with deep-link support","Open reset deep link","Forgot/reset flow opens and token is consumed","Malformed token in URL","Deep link token can be cleared on back","Automate","🟢 defaultInitStartsAtRequestCode | initWithTokenStartsAtResetPassword | moveToNextStepFromRequestCode | moveToNextStepFromVerifyCode | moveToNextStepFromResetPassword | moveToNextStepFromLoggingIn | moveToNextStepFromSuccessIsNoOp | moveToPreviousStepFromVerifyCode | moveToPreviousStepFromResetPassword | moveToPreviousStepFromRequestCodeIsNoOp | moveToPreviousStepFromLoggingInIsNoOp | moveToPreviousStepFromSuccessIsNoOp | resetClearsAllState | clearErrorNilsOutErrorMessage | clearSuccessNilsOutSuccessMessage | verifyResetCodeWithEmptyCodeSetsError | verifyResetCodeWithNonNumericCodeSetsError | resetPasswordWithEmptyPasswordSetsError | resetPasswordWithWeakPasswordSetsError | resetPasswordWithMismatchedPasswordsSetsError | resetPasswordWithNilTokenSetsError | allCasesHasFiveValues | allCasesContainsExpectedSteps (PasswordResetViewModelTests)"
"AUTH-019","Authentication","SSO Apple","Apple Sign-In success creates or signs in account","Manual + Integration","P1","iOS","Apple-capable test device/simulator","Complete Apple sign-in","Session established; verification state handled","User hides email, first-login only email scope","Apple credential mapped to backend request","Automate partially (mock)",""
"AUTH-020","Authentication","SSO Google","Google Sign-In success path","Manual + Integration","P1","Android, Web, KMP UI","Google sign-in configured","Complete Google sign-in","Session established with backend token","Revoked Google token","Backend validates ID token","Automate partially",""
"ONB-001","Onboarding","Intent split","Start Fresh path goes through value props and name residence","Manual + E2E UI","P0","iOS, Android (KMP)","Fresh install","Choose Start Fresh and continue","Step order matches intended flow","Back navigation at each step","Flow differs by intent","Automate","🟢 testF101_StartFreshFlowReachesCreateAccount | testR002_startFreshFlowReachesCreateAccount"
@@ -89,8 +89,8 @@
"NOTIF-003","Notifications","Token refresh","New push token triggers backend re-registration","Manual + Integration","P1","iOS, Android","Previously registered token","Simulate token rotation","Backend receives updated registration id","No token change should skip call","Last registered token cache used","Automate",""
"NOTIF-004","Notifications","Foreground notification","Foreground notifications display banner/sound and update read state","Manual","P1","iOS, Android","Permission granted, send test push","Receive push while app active","Banner shown; notification handled","Malformed payload missing type","Foreground presentation explicitly enabled","Manual",""
"NOTIF-005","Notifications","Notification tap navigation","Tap task push opens app and routes to tasks context","Manual + E2E","P0","iOS, Android","Task-linked push sent","Tap push from background/terminated","App opens on tasks flow for target task","Task no longer exists","Task id may be string or int","Automate partially",""
"NOTIF-006","Notifications","Action buttons premium","Premium users see and can execute task action buttons","Manual + Integration","P0","iOS, Android","Premium subscription active","Receive actionable task notification, tap action","Action API executes and UI refreshes","Action timeout/network loss","Actions gated by premium/limitationsEnabled","Manual",""
"NOTIF-007","Notifications","Action buttons free-tier gating","Free users with limitations enabled are routed home/not allowed actions","Manual","P0","iOS, Android","Free user limitationsEnabled=true","Tap actionable notification","No privileged task action executed","Subscription cache stale nil","Nil subscription defaults allow on iOS currently","Manual",""
"NOTIF-006","Notifications","Action buttons premium","Premium users see and can execute task action buttons","Manual + Integration","P0","iOS, Android","Premium subscription active","Receive actionable task notification, tap action","Action API executes and UI refreshes","Action timeout/network loss","Actions gated by premium/limitationsEnabled","Manual","🟢 encodeDecodeRoundTrip | decodedValuesMatch | sameValuesAreEqual | differentTaskIdNotEqual | differentTaskTitleNotEqual | taskIdReturnsCorrectValue | taskTitleReturnsCorrectValue (WidgetActionTests)"
"NOTIF-007","Notifications","Action buttons free-tier gating","Free users with limitations enabled are routed home/not allowed actions","Manual","P0","iOS, Android","Free user limitationsEnabled=true","Tap actionable notification","No privileged task action executed","Subscription cache stale nil","Nil subscription defaults allow on iOS currently","Manual","🟢 encodeDecodeRoundTrip | sameValuesAreEqual | differentTaskIdNotEqual | differentTaskTitleNotEqual (WidgetActionTests)"
"NOTIF-008","Notifications","Preferences","Load and update notification preference toggles","Manual + Integration","P1","iOS, Android, Web, Desktop","Logged in","Open notification preferences, toggle settings, save","Preferences persist and affect server payloads","Partial update failures","Preferences API supports patch/update","Automate",""
"NOTIF-009","Notifications","History/read state","Notification history list and mark-read operations","Manual + Integration","P2","iOS, Android, Web, Desktop","Notifications exist","Open history, mark one and mark all","Unread counts decrement correctly","Race with incoming push","Unread count endpoint consistent","Automate",""
"SUB-001","Subscription","Status load","Subscription status loads at app launch/foreground","Manual + Integration","P0","iOS, Android, Web, Desktop","Logged in","Launch then background/foreground app","Status cache updates and UI gating accurate","Backend temporarily unavailable","Status refresh should be non-fatal","Automate",""
@@ -103,7 +103,7 @@
"SUB-008","Subscription","Feature gating","Pro-only features hidden/disabled for limited users","Manual + E2E","P0","iOS, Android, Web, Desktop","Test free and pro accounts","Traverse gated features (actions, limits, upgrades)","Gating consistent across surfaces","Cache stale after plan change","Gating uses subscription status + limitationsEnabled","Automate",""
"WID-001","Widgets","Small widget rendering","Small widget shows counts and opens app","Manual","P1","Android","Widget added, logged in","Place small widget and tap","Correct counts; tap opens app","No data cached yet","Widget reads from shared preferences state","Manual",""
"WID-002","Widgets","Medium widget list","Medium widget shows top tasks and overdue badge","Manual","P1","Android","Tasks exist","Place medium widget","Task rows and overdue badge correct","Malformed tasks_json","JSON parse fallback to empty list","Manual",""
"WID-003","Widgets","Large widget interactions","Large widget actions execute for pro users","Manual + Integration","P0","Android","Pro account, widget configured","Tap row and action controls","Opens task or executes action as expected","Free user should not see/execute pro actions","Widget passes task_id in intent","Manual",""
"WID-003","Widgets","Large widget interactions","Large widget actions execute for pro users","Manual + Integration","P0","Android","Pro account, widget configured","Tap row and action controls","Opens task or executes action as expected","Free user should not see/execute pro actions","Widget passes task_id in intent","Manual","🟢 encodeDecodeRoundTrip | decodedValuesMatch | taskIdReturnsCorrectValue | taskTitleReturnsCorrectValue (WidgetActionTests)"
"WID-004","Widgets","Widget refresh","Widgets refresh after task state changes","Manual","P1","Android","Widget present","Complete/cancel task in app","Widget counts/list update within expected interval","Background restrictions","Widget update manager triggers refresh","Manual",""
"SHR-001","Sharing/Import","File association",".casera files open app import flow","Manual","P1","Android","Have .casera file","Open file from files app/share sheet","Import confirmation dialog shown","Multiple apps can open same MIME","Intent filter handles application/json with extension","Manual",""
"SHR-002","Sharing/Import","Security","Import rejects when unauthenticated","Manual","P0","Android","Logged out, .casera file ready","Attempt import","Error shown, no data mutation","Stale token in storage","Auth check occurs before API call","Manual",""
@@ -134,7 +134,7 @@
"I18N-001","Localization","String coverage","No missing keys/placeholders across supported locales","Manual + Static check","P1","iOS, Android, Web, Desktop","Run app in each locale","Traverse major screens","No raw keys, no placeholder mismatches","Pluralization and gender strings","Locales include es/fr/de/it/ja/ko/nl/pt/zh etc.","Automate (lint + screenshot)",""
"I18N-002","Localization","Layout expansion","Long translations do not break onboarding/forms/buttons","Manual","P1","iOS, Android, Web, Desktop","Switch to longest-string locale","Review high-density screens","No clipping/overlap","RTL future locale support","Current locales may be LTR only","Manual",""
"THEME-001","Theming","Theme persistence","Theme choice persists across app restarts","Manual + Integration","P1","iOS, Android, Web, Desktop","Logged in or logged out","Change theme then relaunch","Selected theme reapplied","Theme ID missing/corrupt in storage","Theme stored separately from auth data","Automate","🟢 defaultThemeIdIsDefault | setThemeIdUpdatesValue | clearDoesNotResetTheme | themeIdIsPreservedAsOceanAfterClear (ThemePersistenceTests) | test09_themePersistsAcrossRestart"
"THEME-002","Theming","Theme switch live update","Changing theme updates active screen without broken states","Manual + UI","P2","iOS, Android, Web, Desktop","Open app on any tab","Change theme in profile/settings","Immediate UI recolor with legible components","Transition while modal open","Theme manager publishes updates","Manual",""
"THEME-002","Theming","Theme switch live update","Changing theme updates active screen without broken states","Manual + UI","P2","iOS, Android, Web, Desktop","Open app on any tab","Change theme in profile/settings","Immediate UI recolor with legible components","Transition while modal open","Theme manager publishes updates","Manual","🟢 allCasesCountIsEleven | allDisplayNamesAreNonEmpty | allDescriptionsAreNonEmpty | rawValueRoundTripsForAllCases | allRawValuesAreUnique | brightDisplayNameIsDefault | oceanRawValueIsOcean (ThemeIDTests)"
"NAV-001","Navigation","Bottom tabs","Residences/Tasks/Contractors/Documents tabs navigate and preserve expected state","Manual + E2E UI","P0","iOS, Android","Logged in","Switch tabs repeatedly","Correct screens shown; no stuck navigation","Push navigation then tab switch back","Nested nav stacks differ per platform","Automate","🟢 test03_navigationBetweenTabs | test10_navigateBetweenTabs | testR309_navigationAcrossPrimaryTabsAndBackToResidences"
"NAV-002","Navigation","Back stack safety","Back from details/forms returns to correct parent","Manual + E2E UI","P0","iOS, Android, Web, Desktop","Navigate into detail/edit screens","Use back gestures/buttons","Parent state intact and refreshed as expected","Direct deep-link entry without parent","Saved-state refresh flags used in KMP nav","Automate","🟢 test02_cancelRegistration | test04_cancelResidenceCreation | test02_cancelTaskCreation | test19_CancelDocumentCreation"
"NAV-003","Navigation","Duplicate routes","Avoid duplicate screen instances from repeated tap/navigation actions","Manual","P2","iOS, Android, Web, Desktop","Rapidly tap nav actions","Observe back stack and UI","No duplicate stacking or loops","Double tap race conditions","NavHost popUpTo rules applied","Manual","🟢 testP003_RapidDoubleTapOnValuePropsContinueLandsOnNameResidence | testP004_StartFreshThenBackToWelcomeThenJoinExistingDoesNotCorruptState | testP005_RepeatedLoginNavigationRemainsStable"
1 Test_ID Domain Feature Scenario Test_Method Priority Platforms Preconditions Steps Expected_Result Edge_Cases Assumptions Automation_Recommendation automated
10 AUTH-009 Authentication Registration Create account success then verify-email step Manual + E2E UI P0 iOS, Android, Web, Desktop Unique email/username Submit valid registration Session established and verify-email screen appears Existing email conflict Register API auto-authenticates Automate 🟢 testR103_successfulRegistrationTransitionsToVerificationGate
11 AUTH-010 Authentication Registration validation Invalid email/password formats rejected Manual + UI P1 iOS, Android, Web, Desktop No session Try invalid email, short password, weak password User-friendly validation errors Unicode emails, long usernames Validation rules are enforced consistently Automate 🟢 test03_registrationWithEmptyFields | test04_registrationWithInvalidEmail | test06_registrationWithWeakPassword
12 AUTH-011 Authentication Email verification Valid 6-digit code verifies account Manual + E2E UI P0 iOS, Android, Web, Desktop Logged-in unverified user Enter 6-digit numeric code Account marked verified and app routes forward Code already used Code must be exactly 6 digits Automate 🟢 testR105_validVerificationCodeTransitionsToMainApp | test07_successfulRegistrationAndVerification
13 AUTH-012 Authentication Email verification Non-numeric/short/long code blocked Manual + UI P1 iOS, Android, Web, Desktop On verify-email screen Enter invalid codes Verify action disabled or error shown Pasted with spaces Code input sanitizes to digits Automate 🟢 testR107_invalidVerificationCodeShowsErrorAndStaysBlocked | testR109_verifyButtonDisabledForIncompleteCode | test10_verificationCodeFieldValidation 🟢 testR107_invalidVerificationCodeShowsErrorAndStaysBlocked | testR109_verifyButtonDisabledForIncompleteCode | test10_verificationCodeFieldValidation | emptyCodeReturnsRequired | wrongLengthReturnsInvalidCode | nonNumericCodeReturnsInvalidCode | validSixDigitCodeReturnsNil | customLengthFourDigitCode | customLengthWrongDigitCount (ValidationRulesTests)
14 AUTH-013 Authentication Logout Logout clears token, user data, lookups, and returns to login Manual + E2E UI P0 iOS, Android, Web, Desktop Logged in Tap logout Login shown; protected API calls fail without token Logout API fails network-side Client clears state even if API call fails Automate 🟢 test06_logout | testR205_logoutFromMainAppReturnsToLoginRoot
15 AUTH-014 Authentication Forgot password Request reset by email success path Manual + E2E UI P0 iOS, Android, Web, Desktop Known account email Submit forgot-password email Success message and next step available Unknown email behavior privacy-safe Backend may still return generic success Automate 🟢 test05_forgotPasswordNavigation | testF206_ForgotPasswordButtonIsAccessible | testF209_ForgotPasswordNavigatesToResetFlow
16 AUTH-015 Authentication Reset code verification Verify reset code success path Manual + E2E UI P0 iOS, Android, Web, Desktop Reset email submitted Enter correct code Reset-token available for password reset Expired code Code verification endpoint returns token Automate 🟢 test03_verifyResetCodeSuccess
17 AUTH-016 Authentication Password reset Reset password success with matching confirmation Manual + E2E UI P0 iOS, Android, Web, Desktop Valid reset token Enter new password + confirm Password changed; user can log in (or auto-login flow) Token expires mid-flow Reset endpoint ignores confirm field Automate 🟢 test04_resetPasswordSuccessAndLogin
18 AUTH-017 Authentication Password reset Mismatched password confirmation blocked Manual + UI P1 iOS, Android, Web, Desktop Reset password screen Enter mismatch Inline error; no reset request Trailing spaces Client validates before submit Automate 🟢 mismatchedPasswordsFails | caseSensitiveMismatchFails (ValidationHelpersTests) 🟢 mismatchedPasswordsFails | caseSensitiveMismatchFails (ValidationHelpersTests) | matchingPasswordsReturnsNil | mismatchedPasswordsReturnsMismatch | caseSensitiveMismatch (ValidationRulesTests) | requiredFieldErrorDescription | invalidEmailErrorDescription | passwordTooShortErrorDescription | passwordMismatchErrorDescription | passwordMissingLetterErrorDescription | passwordMissingNumberErrorDescription | invalidCodeErrorDescription | invalidUsernameErrorDescription | customMessageErrorDescription (ValidationRulesTests)
19 AUTH-018 Authentication Deep link reset Password-reset deep link opens flow with token prefilled Manual + E2E UI P0 iOS, Android Installed app with deep-link support Open reset deep link Forgot/reset flow opens and token is consumed Malformed token in URL Deep link token can be cleared on back Automate 🟢 defaultInitStartsAtRequestCode | initWithTokenStartsAtResetPassword | moveToNextStepFromRequestCode | moveToNextStepFromVerifyCode | moveToNextStepFromResetPassword | moveToNextStepFromLoggingIn | moveToNextStepFromSuccessIsNoOp | moveToPreviousStepFromVerifyCode | moveToPreviousStepFromResetPassword | moveToPreviousStepFromRequestCodeIsNoOp | moveToPreviousStepFromLoggingInIsNoOp | moveToPreviousStepFromSuccessIsNoOp | resetClearsAllState | clearErrorNilsOutErrorMessage | clearSuccessNilsOutSuccessMessage | verifyResetCodeWithEmptyCodeSetsError | verifyResetCodeWithNonNumericCodeSetsError | resetPasswordWithEmptyPasswordSetsError | resetPasswordWithWeakPasswordSetsError | resetPasswordWithMismatchedPasswordsSetsError | resetPasswordWithNilTokenSetsError | allCasesHasFiveValues | allCasesContainsExpectedSteps (PasswordResetViewModelTests)
20 AUTH-019 Authentication SSO Apple Apple Sign-In success creates or signs in account Manual + Integration P1 iOS Apple-capable test device/simulator Complete Apple sign-in Session established; verification state handled User hides email, first-login only email scope Apple credential mapped to backend request Automate partially (mock)
21 AUTH-020 Authentication SSO Google Google Sign-In success path Manual + Integration P1 Android, Web, KMP UI Google sign-in configured Complete Google sign-in Session established with backend token Revoked Google token Backend validates ID token Automate partially
22 ONB-001 Onboarding Intent split Start Fresh path goes through value props and name residence Manual + E2E UI P0 iOS, Android (KMP) Fresh install Choose Start Fresh and continue Step order matches intended flow Back navigation at each step Flow differs by intent Automate 🟢 testF101_StartFreshFlowReachesCreateAccount | testR002_startFreshFlowReachesCreateAccount
89 NOTIF-003 Notifications Token refresh New push token triggers backend re-registration Manual + Integration P1 iOS, Android Previously registered token Simulate token rotation Backend receives updated registration id No token change should skip call Last registered token cache used Automate
90 NOTIF-004 Notifications Foreground notification Foreground notifications display banner/sound and update read state Manual P1 iOS, Android Permission granted, send test push Receive push while app active Banner shown; notification handled Malformed payload missing type Foreground presentation explicitly enabled Manual
91 NOTIF-005 Notifications Notification tap navigation Tap task push opens app and routes to tasks context Manual + E2E P0 iOS, Android Task-linked push sent Tap push from background/terminated App opens on tasks flow for target task Task no longer exists Task id may be string or int Automate partially
92 NOTIF-006 Notifications Action buttons premium Premium users see and can execute task action buttons Manual + Integration P0 iOS, Android Premium subscription active Receive actionable task notification, tap action Action API executes and UI refreshes Action timeout/network loss Actions gated by premium/limitationsEnabled Manual 🟢 encodeDecodeRoundTrip | decodedValuesMatch | sameValuesAreEqual | differentTaskIdNotEqual | differentTaskTitleNotEqual | taskIdReturnsCorrectValue | taskTitleReturnsCorrectValue (WidgetActionTests)
93 NOTIF-007 Notifications Action buttons free-tier gating Free users with limitations enabled are routed home/not allowed actions Manual P0 iOS, Android Free user limitationsEnabled=true Tap actionable notification No privileged task action executed Subscription cache stale nil Nil subscription defaults allow on iOS currently Manual 🟢 encodeDecodeRoundTrip | sameValuesAreEqual | differentTaskIdNotEqual | differentTaskTitleNotEqual (WidgetActionTests)
94 NOTIF-008 Notifications Preferences Load and update notification preference toggles Manual + Integration P1 iOS, Android, Web, Desktop Logged in Open notification preferences, toggle settings, save Preferences persist and affect server payloads Partial update failures Preferences API supports patch/update Automate
95 NOTIF-009 Notifications History/read state Notification history list and mark-read operations Manual + Integration P2 iOS, Android, Web, Desktop Notifications exist Open history, mark one and mark all Unread counts decrement correctly Race with incoming push Unread count endpoint consistent Automate
96 SUB-001 Subscription Status load Subscription status loads at app launch/foreground Manual + Integration P0 iOS, Android, Web, Desktop Logged in Launch then background/foreground app Status cache updates and UI gating accurate Backend temporarily unavailable Status refresh should be non-fatal Automate
103 SUB-008 Subscription Feature gating Pro-only features hidden/disabled for limited users Manual + E2E P0 iOS, Android, Web, Desktop Test free and pro accounts Traverse gated features (actions, limits, upgrades) Gating consistent across surfaces Cache stale after plan change Gating uses subscription status + limitationsEnabled Automate
104 WID-001 Widgets Small widget rendering Small widget shows counts and opens app Manual P1 Android Widget added, logged in Place small widget and tap Correct counts; tap opens app No data cached yet Widget reads from shared preferences state Manual
105 WID-002 Widgets Medium widget list Medium widget shows top tasks and overdue badge Manual P1 Android Tasks exist Place medium widget Task rows and overdue badge correct Malformed tasks_json JSON parse fallback to empty list Manual
106 WID-003 Widgets Large widget interactions Large widget actions execute for pro users Manual + Integration P0 Android Pro account, widget configured Tap row and action controls Opens task or executes action as expected Free user should not see/execute pro actions Widget passes task_id in intent Manual 🟢 encodeDecodeRoundTrip | decodedValuesMatch | taskIdReturnsCorrectValue | taskTitleReturnsCorrectValue (WidgetActionTests)
107 WID-004 Widgets Widget refresh Widgets refresh after task state changes Manual P1 Android Widget present Complete/cancel task in app Widget counts/list update within expected interval Background restrictions Widget update manager triggers refresh Manual
108 SHR-001 Sharing/Import File association .casera files open app import flow Manual P1 Android Have .casera file Open file from files app/share sheet Import confirmation dialog shown Multiple apps can open same MIME Intent filter handles application/json with extension Manual
109 SHR-002 Sharing/Import Security Import rejects when unauthenticated Manual P0 Android Logged out, .casera file ready Attempt import Error shown, no data mutation Stale token in storage Auth check occurs before API call Manual
134 I18N-001 Localization String coverage No missing keys/placeholders across supported locales Manual + Static check P1 iOS, Android, Web, Desktop Run app in each locale Traverse major screens No raw keys, no placeholder mismatches Pluralization and gender strings Locales include es/fr/de/it/ja/ko/nl/pt/zh etc. Automate (lint + screenshot)
135 I18N-002 Localization Layout expansion Long translations do not break onboarding/forms/buttons Manual P1 iOS, Android, Web, Desktop Switch to longest-string locale Review high-density screens No clipping/overlap RTL future locale support Current locales may be LTR only Manual
136 THEME-001 Theming Theme persistence Theme choice persists across app restarts Manual + Integration P1 iOS, Android, Web, Desktop Logged in or logged out Change theme then relaunch Selected theme reapplied Theme ID missing/corrupt in storage Theme stored separately from auth data Automate 🟢 defaultThemeIdIsDefault | setThemeIdUpdatesValue | clearDoesNotResetTheme | themeIdIsPreservedAsOceanAfterClear (ThemePersistenceTests) | test09_themePersistsAcrossRestart
137 THEME-002 Theming Theme switch live update Changing theme updates active screen without broken states Manual + UI P2 iOS, Android, Web, Desktop Open app on any tab Change theme in profile/settings Immediate UI recolor with legible components Transition while modal open Theme manager publishes updates Manual 🟢 allCasesCountIsEleven | allDisplayNamesAreNonEmpty | allDescriptionsAreNonEmpty | rawValueRoundTripsForAllCases | allRawValuesAreUnique | brightDisplayNameIsDefault | oceanRawValueIsOcean (ThemeIDTests)
138 NAV-001 Navigation Bottom tabs Residences/Tasks/Contractors/Documents tabs navigate and preserve expected state Manual + E2E UI P0 iOS, Android Logged in Switch tabs repeatedly Correct screens shown; no stuck navigation Push navigation then tab switch back Nested nav stacks differ per platform Automate 🟢 test03_navigationBetweenTabs | test10_navigateBetweenTabs | testR309_navigationAcrossPrimaryTabsAndBackToResidences
139 NAV-002 Navigation Back stack safety Back from details/forms returns to correct parent Manual + E2E UI P0 iOS, Android, Web, Desktop Navigate into detail/edit screens Use back gestures/buttons Parent state intact and refreshed as expected Direct deep-link entry without parent Saved-state refresh flags used in KMP nav Automate 🟢 test02_cancelRegistration | test04_cancelResidenceCreation | test02_cancelTaskCreation | test19_CancelDocumentCreation
140 NAV-003 Navigation Duplicate routes Avoid duplicate screen instances from repeated tap/navigation actions Manual P2 iOS, Android, Web, Desktop Rapidly tap nav actions Observe back stack and UI No duplicate stacking or loops Double tap race conditions NavHost popUpTo rules applied Manual 🟢 testP003_RapidDoubleTapOnValuePropsContinueLandsOnNameResidence | testP004_StartFreshThenBackToWelcomeThenJoinExistingDoesNotCorruptState | testP005_RepeatedLoginNavigationRemainsStable

View File

@@ -0,0 +1,218 @@
//
// PasswordResetViewModelTests.swift
// CaseraTests
//
// Unit tests for PasswordResetViewModel navigation, state management,
// and client-side validation (no network calls).
//
import Testing
@testable import Casera
// 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.")
}
}

View File

@@ -0,0 +1,50 @@
//
// ThemeIDTests.swift
// CaseraTests
//
// Unit tests for ThemeID enum properties and round-tripping.
//
import Testing
@testable import Casera
// 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")
}
}

View File

@@ -0,0 +1,266 @@
//
// ValidationRulesTests.swift
// CaseraTests
//
// Unit tests for ValidationError and ValidationRules (distinct from ValidationHelpers).
//
import Testing
@testable import Casera
// 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(" "))
}
}

View File

@@ -0,0 +1,102 @@
//
// WidgetActionTests.swift
// CaseraTests
//
// Unit tests for WidgetDataManager.WidgetAction (Codable, Equatable, accessors)
// and WidgetDataManager.parseDate static helper.
//
import Testing
import Foundation
@testable import Casera
// 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)
}
}