payfrit-works-ios/test-walkthrough.html
2026-02-01 23:38:34 -08:00

1611 lines
61 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payfrit Works iOS — Test Walkthrough</title>
<style>
:root {
--bg: #1a1a2e;
--surface: #16213e;
--card: #0f3460;
--accent: #22b14c;
--accent-dim: #1a8a3a;
--text: #e0e0e0;
--text-dim: #8899aa;
--pass: #22b14c;
--fail: #e74c3c;
--skip: #f39c12;
--border: #2a3a5e;
--input-bg: #0d1b3e;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 0 0 80px 0;
}
header {
background: linear-gradient(135deg, var(--surface), var(--card));
padding: 24px 32px;
border-bottom: 2px solid var(--accent);
position: sticky;
top: 0;
z-index: 100;
}
header h1 { font-size: 1.4rem; color: var(--accent); margin-bottom: 4px; }
header .meta { font-size: 0.8rem; color: var(--text-dim); }
.progress-bar-container {
margin-top: 12px;
background: var(--input-bg);
border-radius: 8px;
height: 24px;
overflow: hidden;
position: relative;
}
.progress-bar-fill {
height: 100%;
border-radius: 8px;
transition: width 0.4s ease;
background: linear-gradient(90deg, var(--pass), var(--accent));
}
.progress-bar-text {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 600; color: #fff;
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
}
.stats {
display: flex; gap: 16px; margin-top: 8px; font-size: 0.8rem;
}
.stats span { padding: 2px 8px; border-radius: 4px; font-weight: 600; }
.stat-pass { background: rgba(34,177,76,0.2); color: var(--pass); }
.stat-fail { background: rgba(231,76,60,0.2); color: var(--fail); }
.stat-skip { background: rgba(243,156,18,0.2); color: var(--skip); }
.stat-pending { background: rgba(136,153,170,0.15); color: var(--text-dim); }
.controls {
display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap;
}
.controls button {
padding: 6px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: background 0.2s;
}
.controls button:hover { background: var(--card); }
.controls button.danger { border-color: var(--fail); color: var(--fail); }
.controls button.danger:hover { background: rgba(231,76,60,0.15); }
main { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
.section-group {
margin-bottom: 32px;
}
.section-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
}
.section-title .badge {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 10px;
background: var(--card);
color: var(--text-dim);
font-weight: 500;
}
.step {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
margin-bottom: 10px;
overflow: hidden;
transition: border-color 0.3s;
}
.step.status-pass { border-left: 4px solid var(--pass); }
.step.status-fail { border-left: 4px solid var(--fail); }
.step.status-skip { border-left: 4px solid var(--skip); }
.step-header {
display: flex;
align-items: center;
padding: 12px 16px;
cursor: pointer;
user-select: none;
gap: 12px;
}
.step-header:hover { background: rgba(255,255,255,0.03); }
.step-id {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.75rem;
font-weight: 700;
color: var(--accent);
background: rgba(34,177,76,0.12);
padding: 2px 8px;
border-radius: 4px;
min-width: 40px;
text-align: center;
}
.step-title { flex: 1; font-weight: 600; font-size: 0.9rem; }
.step-status-badge {
font-size: 0.7rem;
padding: 2px 10px;
border-radius: 10px;
font-weight: 600;
text-transform: uppercase;
}
.badge-pass { background: rgba(34,177,76,0.2); color: var(--pass); }
.badge-fail { background: rgba(231,76,60,0.2); color: var(--fail); }
.badge-skip { background: rgba(243,156,18,0.2); color: var(--skip); }
.badge-pending { background: rgba(136,153,170,0.1); color: var(--text-dim); }
.step-expand { color: var(--text-dim); transition: transform 0.2s; }
.step.open .step-expand { transform: rotate(180deg); }
.step-body {
display: none;
padding: 0 16px 16px 16px;
}
.step.open .step-body { display: block; }
.step-field { margin-bottom: 10px; }
.step-field-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
font-weight: 700;
margin-bottom: 3px;
}
.step-field-value {
font-size: 0.85rem;
color: var(--text);
padding: 8px 12px;
background: var(--input-bg);
border-radius: 6px;
border: 1px solid var(--border);
}
.step-actions {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
transition: filter 0.2s, transform 0.1s;
}
.btn:active { transform: scale(0.97); }
.btn-pass { background: var(--pass); color: #fff; }
.btn-pass:hover { filter: brightness(1.15); }
.btn-fail { background: var(--fail); color: #fff; }
.btn-fail:hover { filter: brightness(1.15); }
.btn-skip { background: var(--skip); color: #fff; }
.btn-skip:hover { filter: brightness(1.15); }
.notes-field {
width: 100%;
min-height: 60px;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 8px 12px;
font-family: inherit;
font-size: 0.8rem;
resize: vertical;
margin-top: 8px;
}
.notes-field:focus { outline: none; border-color: var(--accent); }
.notes-field::placeholder { color: var(--text-dim); }
.chevron { font-size: 0.7rem; }
@media (max-width: 600px) {
header { padding: 16px; }
main { padding: 12px 8px; }
.step-header { padding: 10px 12px; }
}
</style>
</head>
<body>
<header>
<h1>Payfrit Works iOS — Test Walkthrough</h1>
<div class="meta">
App: <strong>com.payfrit.works</strong> &nbsp;|&nbsp;
Version: <strong>1.0 (1)</strong> &nbsp;|&nbsp;
Environment: <strong>Development (dev.payfrit.com)</strong> &nbsp;|&nbsp;
Generated: <strong id="genDate"></strong>
</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="progressFill" style="width:0%"></div>
<div class="progress-bar-text" id="progressText">0 / 0</div>
</div>
<div class="stats" id="stats"></div>
<div class="controls">
<button onclick="expandAll()">Expand All</button>
<button onclick="collapseAll()">Collapse All</button>
<button onclick="jumpToNext()">Jump to Next ↓</button>
<button onclick="exportResults()">Export Results (.txt)</button>
<button class="danger" onclick="resetRun()">Reset Run</button>
</div>
</header>
<main id="main"></main>
<script>
const APP_NAME = 'Payfrit Works iOS';
const APP_VERSION = '1.0 (1)';
const APP_ENV = 'Development (dev.payfrit.com)';
const STORAGE_KEY = 'payfrit_works_ios_test_walkthrough';
document.getElementById('genDate').textContent = new Date().toISOString().split('T')[0];
const sections = [
{
title: '🚀 Install & First Launch',
steps: [
{
id: 'I01',
title: 'Install app on device/simulator',
preconditions: 'Xcode project builds successfully. Simulator or device available.',
action: 'Build and run PayfritWorks scheme on iPhone 16 Pro simulator (or real device).',
expected: 'App installs. App icon appears on home screen with Payfrit branding.',
onFail: 'Screenshot build error or crash log from Xcode console.'
},
{
id: 'I02',
title: 'First launch — splash screen',
preconditions: 'Fresh install, no saved auth credentials.',
action: 'Tap app icon to launch.',
expected: 'Splash screen shows Payfrit logo (PayfritLogoLight) centered on white background with a green progress spinner. Transitions to Login screen within 1-3 seconds.',
onFail: 'Screenshot of splash screen or crash. Check RootView.swift loadingView.'
},
{
id: 'I03',
title: 'DEV badge visible',
preconditions: 'App is configured for .development environment.',
action: 'Observe bottom-left corner of the screen after launch.',
expected: 'Orange "DEV" badge is visible, rotated 45°, in bottom-left corner. This confirms environment is development.',
onFail: 'Screenshot. Check RootView.swift isDev and APIService.swift environment property.'
},
{
id: 'I04',
title: 'Environment confirmation (API base URL)',
preconditions: 'App launched.',
action: 'Login and open Debug API from the menu. Verify the API base URL shown.',
expected: 'Debug panel shows base URL: https://dev.payfrit.com/api',
onFail: 'Screenshot of debug panel. Verify APIService.swift environment = .development.'
}
]
},
{
title: '🔐 Login',
steps: [
{
id: 'L01',
title: 'Login screen layout',
preconditions: 'Not authenticated (fresh install or logged out).',
action: 'Observe the login screen.',
expected: 'Shows: Payfrit logo, "Payfrit Works" title, "Sign in to view and claim tasks" subtitle, red "DEV MODE — password: 123456" hint, email/phone field, password field with eye toggle, green "Sign In" button.',
onFail: 'Screenshot. Compare with LoginScreen.swift body.'
},
{
id: 'L02',
title: 'Login — empty fields validation',
preconditions: 'Login screen visible, both fields empty.',
action: 'Tap "Sign In" with both fields empty.',
expected: 'Red error banner appears: "Please enter username and password". Login does NOT call API.',
onFail: 'Screenshot of error or lack thereof.'
},
{
id: 'L03',
title: 'Login — invalid credentials',
preconditions: 'Login screen visible.',
action: 'Enter a valid email/phone but wrong password. Tap "Sign In".',
expected: 'Loading spinner shows on button. After API response, red error banner: "Invalid email/phone or password".',
onFail: 'Screenshot. Check network tab or console for API response.'
},
{
id: 'L04',
title: 'Login — valid credentials',
preconditions: 'Login screen visible. Test account: use DEV password 123456.',
action: 'Enter valid email/phone + password 123456. Tap "Sign In".',
expected: 'Loading spinner, then transition to Business Selection screen. No error shown.',
onFail: 'Screenshot. Check console for PAYFRIT logs. Verify API returns OK: true with Token.'
},
{
id: 'L05',
title: 'Login — password visibility toggle',
preconditions: 'Login screen visible, password typed.',
action: 'Type a password, tap the eye icon to toggle visibility.',
expected: 'Password toggles between SecureField (dots) and plain TextField. Eye icon changes between eye.fill and eye.slash.fill.',
onFail: 'Screenshot of both states.'
},
{
id: 'L06',
title: 'Login — keyboard submit',
preconditions: 'Both fields filled with valid credentials.',
action: 'Press Return/Go on the keyboard from the password field.',
expected: 'Login is triggered (same as tapping Sign In). Uses .onSubmit handler.',
onFail: 'Note whether keyboard submit triggers login or does nothing.'
},
{
id: 'L07',
title: 'Auth token stored in Keychain',
preconditions: 'Successful login.',
action: 'After login, force-quit the app and relaunch.',
expected: 'App does NOT show login screen — goes directly to biometric check or Business Selection (simulator skips biometrics).',
onFail: 'Screenshot. Check AuthStorage.swift saveAuth and loadAuth methods.'
}
]
},
{
title: '🔒 Biometrics',
steps: [
{
id: 'B01',
title: 'Biometric prompt on relaunch (real device)',
preconditions: 'Logged in previously. Face ID / Touch ID enrolled. Real device only.',
action: 'Force-quit and relaunch the app.',
expected: 'Face ID / Touch ID prompt appears: "Sign in to Payfrit Works". Cancel button says "Use Password".',
onFail: 'Screenshot. Check RootView.swift checkAuthWithBiometrics.'
},
{
id: 'B02',
title: 'Biometric success',
preconditions: 'Biometric prompt shown.',
action: 'Authenticate with Face ID / Touch ID.',
expected: 'Auth loaded from Keychain, transitions to Business Selection.',
onFail: 'Note if stuck on splash or redirects to login.'
},
{
id: 'B03',
title: 'Biometric cancel — "Use Password"',
preconditions: 'Biometric prompt shown.',
action: 'Tap "Use Password" (cancel) on the biometric dialog.',
expected: 'Login screen appears. User must enter credentials again.',
onFail: 'Screenshot. Note if app crashes or shows blank screen.'
},
{
id: 'B04',
title: 'Simulator skips biometrics',
preconditions: 'Running on iOS Simulator.',
action: 'Force-quit and relaunch on simulator.',
expected: 'Biometric prompt is NOT shown (skipped via #if targetEnvironment(simulator)). Goes straight to Business Selection if previously logged in.',
onFail: 'Note if biometric prompt appears on simulator.'
},
{
id: 'B05',
title: 'No biometrics enrolled fallback',
preconditions: 'Real device with no Face ID / Touch ID enrolled.',
action: 'Relaunch app after previous login.',
expected: 'App skips biometric prompt entirely (canEvaluatePolicy returns false). Goes straight to Business Selection using saved auth.',
onFail: 'Note behavior — should NOT crash or show login.'
}
]
},
{
title: '📋 Permissions',
steps: [
{
id: 'P01',
title: 'Location permission prompt (happy path)',
preconditions: 'Beacon scanning triggered (task detail with beacon UUID, Complete button visible).',
action: 'Accept a task with a beacon UUID. Observe for location permission dialog.',
expected: 'iOS shows "Allow While Using App" / "Don\'t Allow" prompt. Message: "Payfrit Works uses your location to detect nearby beacons for automatic task completion."',
onFail: 'Screenshot. Check Info.plist NSLocationWhenInUseUsageDescription.'
},
{
id: 'P02',
title: 'Location permission denied',
preconditions: 'Location permission set to "Don\'t Allow" in Settings.',
action: 'Open a task detail that has a beacon UUID and Complete button.',
expected: 'Error message appears: "Bluetooth permission is required for auto-complete. Please enable it in Settings." Beacon scanning does not start.',
onFail: 'Screenshot. Check BeaconScanner.swift onPermissionDenied callback.'
},
{
id: 'P03',
title: 'Bluetooth permission prompt',
preconditions: 'First time Bluetooth is accessed.',
action: 'Trigger beacon scanning.',
expected: 'iOS shows Bluetooth permission dialog: "Payfrit Works uses Bluetooth to scan for nearby beacons."',
onFail: 'Screenshot. Check Info.plist NSBluetoothAlwaysUsageDescription.'
},
{
id: 'P04',
title: 'Bluetooth off indicator',
preconditions: 'Bluetooth turned off in Control Center.',
action: 'Open a task detail with beacon + Complete button.',
expected: 'Beacon indicator shows slashed antenna icon (antenna.radiowaves.left.and.right.slash) in green.',
onFail: 'Screenshot. Check BeaconScanner onBluetoothOff callback.'
},
{
id: 'P05',
title: 'Face ID permission prompt',
preconditions: 'First launch on device with Face ID.',
action: 'Login and relaunch app.',
expected: 'iOS shows Face ID permission dialog: "Payfrit Works uses Face ID for quick sign-in."',
onFail: 'Screenshot. Check Info.plist NSFaceIDUsageDescription.'
}
]
},
{
title: '🏢 Business Selection',
steps: [
{
id: 'BS01',
title: 'Business list loads',
preconditions: 'Logged in, user has businesses.',
action: 'After login, observe Business Selection screen.',
expected: 'Nav title "Select Business". List of businesses with: initial letter in colored square, business name, address/city, status badge (Pending/Active/Inactive), task count or green checkmark. Toolbar: refresh button and ellipsis menu.',
onFail: 'Screenshot. Check API response from /workers/myBusinesses.cfm.'
},
{
id: 'BS02',
title: 'Business list — empty state',
preconditions: 'User has no businesses assigned.',
action: 'Login with a user who has no employments.',
expected: 'Briefcase icon, "No businesses found", "You are not currently employed at any business".',
onFail: 'Screenshot.'
},
{
id: 'BS03',
title: 'Business card — pending tasks badge',
preconditions: 'A business has pending tasks.',
action: 'Observe the business card.',
expected: 'Green capsule badge showing task count (e.g. "3") with "tasks" label below.',
onFail: 'Screenshot. Verify PendingTaskCount from API.'
},
{
id: 'BS04',
title: 'Business card — no pending tasks',
preconditions: 'A business has 0 pending tasks.',
action: 'Observe the business card.',
expected: 'Green checkmark icon with "clear" label. No count badge.',
onFail: 'Screenshot.'
},
{
id: 'BS05',
title: 'Tap business → Task List',
preconditions: 'Business list showing businesses.',
action: 'Tap any business card.',
expected: 'Navigates to Task List screen. Nav title shows business name. Business ID is set in APIService.',
onFail: 'Screenshot. Check that TaskListScreen receives businessName prop.'
},
{
id: 'BS06',
title: 'Pull to refresh',
preconditions: 'Business list visible.',
action: 'Pull down on the list.',
expected: 'Refresh indicator appears. List reloads from API. Changes in task counts are reflected.',
onFail: 'Note if refresh fails silently.'
},
{
id: 'BS07',
title: 'Auto-refresh every 2 seconds',
preconditions: 'Business list visible.',
action: 'Watch the list for 10 seconds while another worker accepts tasks in another session.',
expected: 'PendingTaskCount updates automatically without user action (silent polling every 2s).',
onFail: 'Note if counts go stale. Check refreshTimer in BusinessSelectionScreen.'
},
{
id: 'BS08',
title: 'Toolbar menu — My Tasks',
preconditions: 'Business Selection screen.',
action: 'Tap ellipsis menu → "My Tasks".',
expected: 'Navigates to My Tasks screen.',
onFail: 'Screenshot.'
},
{
id: 'BS09',
title: 'Toolbar menu — Account',
preconditions: 'Business Selection screen.',
action: 'Tap ellipsis menu → "Account".',
expected: 'Navigates to Account screen.',
onFail: 'Screenshot.'
},
{
id: 'BS10',
title: 'Toolbar menu — Debug API',
preconditions: 'Business Selection screen.',
action: 'Tap ellipsis menu → "Debug API".',
expected: 'Modal sheet appears showing raw JSON from /workers/myBusinesses.cfm and /tasks/listPending.cfm. Monospaced text, "Done" button to dismiss.',
onFail: 'Screenshot.'
},
{
id: 'BS11',
title: 'Toolbar menu — Logout',
preconditions: 'Business Selection screen.',
action: 'Tap ellipsis menu → Logout (red text).',
expected: 'Auth cleared (Keychain + UserDefaults). Returns to Login screen. Relaunching app also shows Login.',
onFail: 'Screenshot. Verify AuthStorage.clearAuth is called.'
},
{
id: 'BS12',
title: 'FAB — My Tasks floating button',
preconditions: 'Business Selection screen.',
action: 'Tap the green checkmark FAB button in bottom-right corner.',
expected: 'Navigates to My Tasks screen.',
onFail: 'Screenshot. Check that FAB is not covered by list items.'
},
{
id: 'BS13',
title: 'Manual refresh button',
preconditions: 'Business Selection screen.',
action: 'Tap the arrow.clockwise button in the toolbar.',
expected: 'Business list reloads. Loading spinner appears briefly.',
onFail: 'Screenshot.'
}
]
},
{
title: '📝 Task List (Pending Tasks)',
steps: [
{
id: 'TL01',
title: 'Task list loads',
preconditions: 'Selected a business with pending tasks.',
action: 'Observe the task list screen.',
expected: 'Nav title = business name. List of task cards with: color bar, category icon (chat = bubble icon), title, location (mappin or bicycle icon), category badge, chat badge if applicable, time ago label.',
onFail: 'Screenshot. Check /tasks/listPending.cfm response.'
},
{
id: 'TL02',
title: 'Task list — empty state',
preconditions: 'Business has no pending tasks.',
action: 'Open task list for a business with 0 tasks.',
expected: 'Checkmark circle icon, "No pending tasks", "Check back soon!".',
onFail: 'Screenshot.'
},
{
id: 'TL03',
title: 'Task card — regular task',
preconditions: 'Task list has a non-chat task.',
action: 'Observe a regular task card.',
expected: 'Left color bar matches category color. Document icon. Title, location, category badge, time ago.',
onFail: 'Screenshot.'
},
{
id: 'TL04',
title: 'Task card — chat task',
preconditions: 'Task list has a chat task (TaskTypeID = 2).',
action: 'Observe the chat task card.',
expected: 'Green bubble icon instead of document. Extra "Chat" badge in green. Arrow icon instead of chevron on right.',
onFail: 'Screenshot.'
},
{
id: 'TL05',
title: 'Tap task → Task Detail',
preconditions: 'Task list with tasks.',
action: 'Tap any task card.',
expected: 'Navigates to Task Detail screen with Accept button visible (showAcceptButton: true).',
onFail: 'Screenshot.'
},
{
id: 'TL06',
title: 'Pull to refresh',
preconditions: 'Task list visible.',
action: 'Pull down to refresh.',
expected: 'List reloads. New tasks appear, completed/accepted tasks disappear.',
onFail: 'Note behavior.'
},
{
id: 'TL07',
title: 'Auto-refresh every 2 seconds',
preconditions: 'Task list visible.',
action: 'Wait 10 seconds. Add a task from another session.',
expected: 'New task appears automatically without user pull-to-refresh.',
onFail: 'Note if list is stale.'
},
{
id: 'TL08',
title: 'Toolbar menu — My Tasks + Logout',
preconditions: 'Task list screen.',
action: 'Tap ellipsis menu. Verify "My Tasks" and "Logout" options appear.',
expected: 'Menu shows worker name at top, My Tasks, Logout (red). Tapping each works correctly.',
onFail: 'Screenshot.'
},
{
id: 'TL09',
title: 'FAB — My Tasks button',
preconditions: 'Task list screen.',
action: 'Tap green checkmark FAB.',
expected: 'Navigates to My Tasks.',
onFail: 'Screenshot.'
},
{
id: 'TL10',
title: 'Back navigation to Business Selection',
preconditions: 'Task list screen.',
action: 'Tap back button or swipe back.',
expected: 'Returns to Business Selection screen.',
onFail: 'Note behavior.'
}
]
},
{
title: '📋 Task Detail',
steps: [
{
id: 'TD01',
title: 'Task detail loads',
preconditions: 'Tapped a task from task list.',
action: 'Observe the task detail screen.',
expected: 'Loading spinner, then content: customer section (avatar/name/phone), location section (table or delivery), nav bar colored with category color. "Accept Task" button at bottom.',
onFail: 'Screenshot. Check /tasks/getDetails.cfm response.'
},
{
id: 'TD02',
title: 'Customer section — with photo',
preconditions: 'Task has a customer with a photo URL.',
action: 'Observe customer section.',
expected: 'AsyncImage loads customer photo in a 64pt circle. Name and phone displayed. Phone call button visible.',
onFail: 'Screenshot.'
},
{
id: 'TD03',
title: 'Customer section — without photo (initials)',
preconditions: 'Task has a customer without a photo URL.',
action: 'Observe customer section.',
expected: 'Circle shows initials (first letter of first + last name) in category color. "?" if name is empty.',
onFail: 'Screenshot.'
},
{
id: 'TD04',
title: 'Customer phone call button',
preconditions: 'Task has a customer with phone number.',
action: 'Tap the green phone icon.',
expected: 'Opens tel: URL. On real device, initiates phone call. On simulator, may show error (expected).',
onFail: 'Note behavior.'
},
{
id: 'TD05',
title: 'Location section — table service',
preconditions: 'Task has a ServicePointName (not delivery).',
action: 'Observe location section.',
expected: 'Table icon (table.furniture), "Table Service" label, service point name. Green background square.',
onFail: 'Screenshot.'
},
{
id: 'TD06',
title: 'Location section — delivery',
preconditions: 'Task has a DeliveryAddress.',
action: 'Observe location section.',
expected: 'Bicycle icon, "Delivery" label, delivery address. Maps button (arrow.triangle) visible if lat/lng provided.',
onFail: 'Screenshot.'
},
{
id: 'TD07',
title: 'Maps navigation button (delivery)',
preconditions: 'Delivery task with lat/lng.',
action: 'Tap the maps navigation icon.',
expected: 'Opens Google Maps with destination set to delivery coordinates.',
onFail: 'Note behavior.'
},
{
id: 'TD08',
title: 'Chat button (chat task)',
preconditions: 'Task is a chat task (TaskTypeID = 2).',
action: 'Observe the chat button section.',
expected: '"Chat with Customer" button with bubble icon, customer name subtitle, chevron. Tapping navigates to Chat screen.',
onFail: 'Screenshot.'
},
{
id: 'TD09',
title: 'Table members section',
preconditions: 'Task detail has tableMembers array.',
action: 'Observe table members section.',
expected: 'Person.3 icon, "Table Members (N)" header. Flow layout of member chips with initials circle and name. Host member highlighted in yellow.',
onFail: 'Screenshot.'
},
{
id: 'TD10',
title: 'Order items section',
preconditions: 'Task detail has lineItems.',
action: 'Observe order items section.',
expected: 'List icon, "Order Items (N)". Each item: placeholder image, quantity badge (colored), item name, modifiers as sub-items, remark in green italic.',
onFail: 'Screenshot.'
},
{
id: 'TD11',
title: 'Order remarks section',
preconditions: 'Task detail has orderRemarks.',
action: 'Observe remarks section.',
expected: 'Yellow note icon, "Order Notes" label, remark text. Yellow-tinted background.',
onFail: 'Screenshot.'
},
{
id: 'TD12',
title: 'Accept Task button',
preconditions: 'Task viewed from pending list (showAcceptButton = true).',
action: 'Tap "Accept Task" button.',
expected: 'Alert: "Accept Task? Claim this task and add it to your tasks?" with Cancel and Accept buttons.',
onFail: 'Screenshot.'
},
{
id: 'TD13',
title: 'Accept Task — confirm',
preconditions: 'Accept alert shown.',
action: 'Tap "Accept".',
expected: 'API call to /tasks/accept.cfm. On success, screen dismisses back to task list. Task removed from pending list.',
onFail: 'Screenshot. Check API response.'
},
{
id: 'TD14',
title: 'Accept Chat Task — navigates to chat',
preconditions: 'Accepting a chat task.',
action: 'Accept a chat task (TaskTypeID = 2).',
expected: 'After accept, navigates to Chat screen instead of dismissing. (showingChat = true)',
onFail: 'Screenshot. Verify acceptTask() in TaskDetailScreen sets showingChat for chat tasks.'
},
{
id: 'TD15',
title: 'Accept Task — already accepted error',
preconditions: 'Another worker already accepted the task.',
action: 'Try to accept a task that was just accepted by someone else.',
expected: 'Error: "This task has already been claimed by someone else."',
onFail: 'Screenshot.'
},
{
id: 'TD16',
title: 'Complete Task button',
preconditions: 'Task viewed from My Tasks (showCompleteButton = true).',
action: 'Tap "Complete Task".',
expected: 'Alert: "Complete Task? Mark this task as completed?" with Cancel and Complete.',
onFail: 'Screenshot.'
},
{
id: 'TD17',
title: 'Complete Task — confirm',
preconditions: 'Complete alert shown.',
action: 'Tap "Complete".',
expected: 'API call to /tasks/complete.cfm. On success, dismisses back to My Tasks. Task moves to Completed filter.',
onFail: 'Screenshot.'
},
{
id: 'TD18',
title: 'Beacon auto-complete — detection + countdown',
preconditions: 'Task with beacon UUID, Complete button visible, near physical beacon.',
action: 'Open task detail near a matching beacon.',
expected: 'Beacon indicator turns green (detected). After 5 RSSI samples, auto-complete countdown sheet appears: timer icon, "Auto-Complete", countdown from 3.',
onFail: 'Screenshot. Check BeaconScanner RSSI threshold (-75) and sample count (5).'
},
{
id: 'TD19',
title: 'Beacon auto-complete — cancel countdown',
preconditions: 'Auto-complete countdown sheet shown.',
action: 'Tap "Cancel".',
expected: 'Countdown stops. Sheet dismisses. Beacon scanning resumes. Samples reset.',
onFail: 'Screenshot.'
},
{
id: 'TD20',
title: 'Beacon auto-complete — countdown completes',
preconditions: 'Auto-complete countdown running.',
action: 'Let countdown reach 0.',
expected: 'Checkmark icon, "Task Completed!", then auto-dismisses after 1 second. Task is completed via API.',
onFail: 'Screenshot.'
},
{
id: 'TD21',
title: 'Task detail — error and retry',
preconditions: 'Network error or server error loading details.',
action: 'Load task detail while offline or with bad TaskID.',
expected: 'Red exclamation icon, error message, "Retry" button. Tapping Retry reloads.',
onFail: 'Screenshot.'
},
{
id: 'TD22',
title: 'FAB — My Tasks button on detail',
preconditions: 'Task detail screen.',
action: 'Tap green checkmark FAB.',
expected: 'Navigates to My Tasks.',
onFail: 'Screenshot.'
}
]
},
{
title: '✅ My Tasks',
steps: [
{
id: 'MT01',
title: 'My Tasks screen layout',
preconditions: 'Navigated to My Tasks from any entry point.',
action: 'Observe the screen.',
expected: 'Green nav bar with "My Tasks" title. 4 filter tabs: Active (play), Today (calendar), This Week (calendar.badge.clock), Done (checkmark). White sliding indicator under active tab. Swipeable tab content.',
onFail: 'Screenshot.'
},
{
id: 'MT02',
title: 'Active filter — shows accepted tasks',
preconditions: 'Worker has accepted tasks.',
action: 'Select Active tab.',
expected: 'Shows tasks with status Accepted/In Progress. Each card shows: color bar, title, location, category badge, time ago, status badge.',
onFail: 'Screenshot. Verify /tasks/listMine.cfm?FilterType=active response.'
},
{
id: 'MT03',
title: 'Today filter',
preconditions: 'Worker has tasks from today.',
action: 'Tap "Today" tab.',
expected: 'Shows tasks accepted/completed today.',
onFail: 'Screenshot.'
},
{
id: 'MT04',
title: 'This Week filter',
preconditions: 'Worker has tasks from this week.',
action: 'Tap "This Week" tab.',
expected: 'Shows tasks accepted/completed this week.',
onFail: 'Screenshot.'
},
{
id: 'MT05',
title: 'Done filter — completed tasks',
preconditions: 'Worker has completed tasks.',
action: 'Tap "Done" tab.',
expected: 'Shows completed tasks. Cards have green checkmark, strikethrough title, green color bar. No status badge.',
onFail: 'Screenshot.'
},
{
id: 'MT06',
title: 'Empty state per filter',
preconditions: 'Filter has no tasks.',
action: 'Switch to a filter with no tasks.',
expected: 'Appropriate empty icon and messages. E.g. Active: "No active tasks" / "Claim some tasks to get started!"',
onFail: 'Screenshot. Verify empty messages match emptyMessage/emptySubMessage functions.'
},
{
id: 'MT07',
title: 'Tap task → Task Detail with Complete',
preconditions: 'Active tab with tasks.',
action: 'Tap an active task.',
expected: 'Opens Task Detail with "Complete Task" button (showCompleteButton = true). No Accept button.',
onFail: 'Screenshot.'
},
{
id: 'MT08',
title: 'Tap completed task → Task Detail (no buttons)',
preconditions: 'Done tab with completed tasks.',
action: 'Tap a completed task.',
expected: 'Opens Task Detail with NO action buttons (showCompleteButton = false for completed).',
onFail: 'Screenshot.'
},
{
id: 'MT09',
title: 'Swipe between tabs',
preconditions: 'My Tasks screen.',
action: 'Swipe left/right between tabs.',
expected: 'Tab content swipes. White indicator moves. Selected tab updates. Content loads for new tab.',
onFail: 'Note if swipe doesn\'t work or indicator doesn\'t move.'
},
{
id: 'MT10',
title: 'Pull to refresh per tab',
preconditions: 'Any tab with data.',
action: 'Pull down to refresh.',
expected: 'Current tab reloads. Data refreshes.',
onFail: 'Note behavior.'
},
{
id: 'MT11',
title: 'Auto-refresh on active tab',
preconditions: 'My Tasks screen on any tab.',
action: 'Wait and observe for auto-refresh.',
expected: 'Current tab auto-refreshes every 2 seconds (silent).',
onFail: 'Check refreshTimer in MyTasksScreen.'
},
{
id: 'MT12',
title: 'Chat icon on chat tasks',
preconditions: 'My Tasks has a chat task.',
action: 'Observe chat task card.',
expected: 'Green speech bubble icon on right side of card.',
onFail: 'Screenshot.'
}
]
},
{
title: '💬 Chat',
steps: [
{
id: 'CH01',
title: 'Chat screen — initial load',
preconditions: 'Navigated to Chat from task detail.',
action: 'Observe chat screen.',
expected: 'Nav title "Chat with Customer" (for worker). Loading spinner, then messages or empty state. Toolbar: WiFi indicator (green = WebSocket connected, orange slash = disconnected). Close chat X button.',
onFail: 'Screenshot.'
},
{
id: 'CH02',
title: 'Chat — empty state',
preconditions: 'Chat with no messages.',
action: 'Open fresh chat.',
expected: 'Bubble icon, "No messages yet", "Start the conversation!".',
onFail: 'Screenshot.'
},
{
id: 'CH03',
title: 'Send a message',
preconditions: 'Chat screen open, text field visible.',
action: 'Type a message and tap send (paperplane icon).',
expected: 'Message appears in blue bubble on right. User initials (from AppState.userName) shown in small circle. Timestamp shown.',
onFail: 'Screenshot. Verify initials come from appState not hardcoded "Me".'
},
{
id: 'CH04',
title: 'Receive a message',
preconditions: 'Chat open, another party sends a message.',
action: 'Send a message from customer side (or wait for incoming).',
expected: 'New message appears in gray bubble on left. Sender avatar shows first letter of name. Sender name shown above bubble. Auto-scrolls to bottom.',
onFail: 'Screenshot.'
},
{
id: 'CH05',
title: 'WebSocket connection indicator',
preconditions: 'Chat screen.',
action: 'Observe WiFi icon in toolbar.',
expected: 'Green WiFi = WebSocket connected (real-time). Orange slashed WiFi = disconnected (falls back to 3s polling).',
onFail: 'Screenshot.'
},
{
id: 'CH06',
title: 'Typing indicator',
preconditions: 'Chat open with WebSocket.',
action: 'Other party starts typing.',
expected: '"[Name] is typing..." appears below message list in italic.',
onFail: 'Screenshot.'
},
{
id: 'CH07',
title: 'Close chat (worker)',
preconditions: 'Chat screen as worker, chat active.',
action: 'Tap X button in toolbar.',
expected: 'Alert: "Close Chat — Are you sure you want to close this chat?" with Cancel and Close (red). Tapping Close: API call, WebSocket close event, "This chat has ended" banner, screen dismisses.',
onFail: 'Screenshot.'
},
{
id: 'CH08',
title: 'Chat ended banner',
preconditions: 'Chat that has been closed.',
action: 'Open a closed chat.',
expected: '"This chat has ended" banner at top (green tint). Input area hidden. Cannot send messages.',
onFail: 'Screenshot.'
},
{
id: 'CH09',
title: 'Error banner on send failure',
preconditions: 'Chat screen, network offline.',
action: 'Try to send a message while offline.',
expected: 'Red error banner appears at top of chat with error message. Tap to dismiss.',
onFail: 'Screenshot. Verify error banner exists in ChatScreen (added in recent fix).'
},
{
id: 'CH10',
title: 'HTTP fallback when WebSocket disconnected',
preconditions: 'WebSocket not connected.',
action: 'Send a message.',
expected: 'Message sent via HTTP POST to /chat/sendMessage.cfm. Messages poll via 3-second timer.',
onFail: 'Note if message fails or gets lost.'
},
{
id: 'CH11',
title: 'FAB on chat screen',
preconditions: 'Chat screen.',
action: 'Tap green checkmark FAB.',
expected: 'Navigates to My Tasks.',
onFail: 'Screenshot.'
}
]
},
{
title: '💰 Account & Payout',
steps: [
{
id: 'AC01',
title: 'Account screen loads',
preconditions: 'Navigated to Account from menu.',
action: 'Observe Account screen.',
expected: 'Nav title "Account". Loading spinner, then: Payout Status card, Activation card, Earnings card, Logout button. FAB in bottom-right.',
onFail: 'Screenshot. Check /workers/tierStatus.cfm response.'
},
{
id: 'AC02',
title: 'Payout Status — Tier 1 unlocked',
preconditions: 'User has tier >= 1.',
action: 'Observe Payout Status card.',
expected: 'Green shield icon, "Payout Status", green checkmark + "Tier 1 unlocked — Payouts enabled".',
onFail: 'Screenshot.'
},
{
id: 'AC03',
title: 'Payout Status — no Stripe account',
preconditions: 'User has tier 0, no Stripe account.',
action: 'Observe Payout Status card.',
expected: 'Gray shield icon, "Tier 1 is locked", green "Complete payout setup" button.',
onFail: 'Screenshot.'
},
{
id: 'AC04',
title: 'Start Stripe onboarding',
preconditions: 'No Stripe account.',
action: 'Tap "Complete payout setup".',
expected: 'API calls createStripeAccount + getOnboardingLink. Opens Stripe URL in Safari. After return, reloads data after 2 seconds.',
onFail: 'Screenshot. Note if URL opens or error appears.'
},
{
id: 'AC05',
title: 'Continue Stripe onboarding (incomplete)',
preconditions: 'Stripe account exists but setup incomplete.',
action: 'Tap "Continue payout setup".',
expected: 'Opens Stripe onboarding URL. After return, reloads data.',
onFail: 'Screenshot.'
},
{
id: 'AC06',
title: 'Activation card — in progress',
preconditions: 'Activation not yet complete.',
action: 'Observe Activation card.',
expected: 'Star icon, "Activation", progress bar (green tint), "$X.XX of $Y.YY completed", "Complete activation now (optional)" button, "What is activation?" link.',
onFail: 'Screenshot.'
},
{
id: 'AC07',
title: '"What is activation?" info button',
preconditions: 'Activation card visible, not complete.',
action: 'Tap "What is activation?".',
expected: 'Alert dialog: "What is Activation?" with explanation text about early earnings and activation cap. "Got it" dismiss button.',
onFail: 'Screenshot. Verify alert wired up (recently fixed from empty button).'
},
{
id: 'AC08',
title: 'Early unlock button',
preconditions: 'Activation not complete.',
action: 'Tap "Complete activation now (optional)".',
expected: 'API call to /workers/earlyUnlock.cfm. Opens URL in Safari. Reloads after return.',
onFail: 'Screenshot.'
},
{
id: 'AC09',
title: 'Activation card — complete',
preconditions: 'Activation is complete.',
action: 'Observe Activation card.',
expected: 'Green checkmark icon, "Activation complete" in green. No progress bar or buttons.',
onFail: 'Screenshot.'
},
{
id: 'AC10',
title: 'Earnings card — with entries',
preconditions: 'User has ledger entries.',
action: 'Observe Earnings card.',
expected: 'Dollar icon, "Earnings", total earned amount in green. List of up to 20 entries: Task #ID, date, net amount, status badge (Pending/Charged/Transferred/Reversed).',
onFail: 'Screenshot.'
},
{
id: 'AC11',
title: 'Earnings card — empty',
preconditions: 'User has no ledger entries.',
action: 'Observe Earnings card.',
expected: '"No earnings yet. Complete tasks to start earning!"',
onFail: 'Screenshot.'
},
{
id: 'AC12',
title: 'Logout button',
preconditions: 'Account screen.',
action: 'Tap red "Logout" button.',
expected: 'Auth cleared. Returns to Login screen.',
onFail: 'Screenshot.'
},
{
id: 'AC13',
title: 'Pull to refresh',
preconditions: 'Account screen.',
action: 'Pull down to refresh.',
expected: 'Tier status and ledger reload from API.',
onFail: 'Note behavior.'
},
{
id: 'AC14',
title: 'Account — error and retry',
preconditions: 'Network error loading tier status.',
action: 'Open Account while offline.',
expected: 'Error view with exclamation icon, error message, "Retry" button.',
onFail: 'Screenshot.'
}
]
},
{
title: '🌐 Network Errors & Recovery',
steps: [
{
id: 'NE01',
title: 'Offline — Business Selection',
preconditions: 'Device offline (airplane mode).',
action: 'Open app while offline.',
expected: 'Error view with message and Retry button. After going back online, Retry loads data.',
onFail: 'Screenshot.'
},
{
id: 'NE02',
title: 'Offline — Task List',
preconditions: 'On task list, go offline.',
action: 'Wait for next auto-refresh or pull to refresh.',
expected: 'Silent refresh failure (no UI change for silent). Manual refresh shows error.',
onFail: 'Note behavior.'
},
{
id: 'NE03',
title: 'Offline — Task Detail',
preconditions: 'Offline.',
action: 'Try to load task detail.',
expected: 'Error view: exclamation icon + error message + "Retry" button.',
onFail: 'Screenshot.'
},
{
id: 'NE04',
title: 'Offline — Accept Task',
preconditions: 'Offline, on task detail with Accept button.',
action: 'Tap Accept → Accept.',
expected: 'Error message shown on task detail screen (self.error = error.localizedDescription).',
onFail: 'Screenshot.'
},
{
id: 'NE05',
title: 'Offline — Complete Task',
preconditions: 'Offline, on task detail with Complete button.',
action: 'Tap Complete → Complete.',
expected: 'Error message shown.',
onFail: 'Screenshot.'
},
{
id: 'NE06',
title: 'Offline — Chat send',
preconditions: 'Chat screen, offline.',
action: 'Try to send a message.',
expected: 'Red error banner at top of chat. Message not sent. Tap banner to dismiss.',
onFail: 'Screenshot.'
},
{
id: 'NE07',
title: 'Server 500 error',
preconditions: 'Server returning 500.',
action: 'Trigger any API call.',
expected: 'Error: "HTTP 500" or similar server error message shown in appropriate error UI.',
onFail: 'Screenshot.'
},
{
id: 'NE08',
title: 'Auth expired (401)',
preconditions: 'Token expired or invalidated server-side.',
action: 'Trigger any API call with expired token.',
expected: 'APIError.unauthorized thrown. Error: "Unauthorized" shown. (Note: app does NOT auto-redirect to login — this is a known limitation).',
onFail: 'Screenshot. Note actual behavior.'
},
{
id: 'NE09',
title: 'Slow network — loading states',
preconditions: 'Throttled network (use Network Link Conditioner).',
action: 'Navigate between screens.',
expected: 'All screens show ProgressView() loading spinners while waiting. No blank screens.',
onFail: 'Screenshot of any blank or broken states.'
},
{
id: 'NE10',
title: 'Non-JSON response handling',
preconditions: 'Server returns HTML error page instead of JSON.',
action: 'Trigger API call that returns non-JSON.',
expected: 'App tries to extract JSON from mixed response (tryDecodeJSON fallback). If fails, shows "Decoding error: Non-JSON response".',
onFail: 'Note behavior.'
}
]
},
{
title: '🔀 Edge Cases',
steps: [
{
id: 'EC01',
title: 'Empty business name',
preconditions: 'Business with empty Name field in DB.',
action: 'Observe business card.',
expected: 'Initial shows "?". Business name shows as empty string. No crash.',
onFail: 'Screenshot.'
},
{
id: 'EC02',
title: 'Task with no location',
preconditions: 'Task with no ServicePointName and no DeliveryAddress.',
action: 'Observe task detail.',
expected: 'Location section shows "No location specified".',
onFail: 'Screenshot.'
},
{
id: 'EC03',
title: 'Customer with no name',
preconditions: 'Task detail where customer has no first/last name.',
action: 'Observe customer section.',
expected: 'Name shows "Guest". Initials show "?". No crash.',
onFail: 'Screenshot.'
},
{
id: 'EC04',
title: 'Order with no items',
preconditions: 'Task with empty LineItems array.',
action: 'Observe task detail.',
expected: 'Order Items section is hidden entirely (conditional on !details.mainItems.isEmpty).',
onFail: 'Screenshot.'
},
{
id: 'EC05',
title: 'Order with modifiers and remarks',
preconditions: 'Task with line items that have modifiers (child items) and remarks.',
action: 'Observe order items.',
expected: 'Modifiers shown as "- ModName: ChildName" in gray. Remarks shown in green italic quotes.',
onFail: 'Screenshot.'
},
{
id: 'EC06',
title: 'Rapid screen navigation',
preconditions: 'App is running.',
action: 'Rapidly tap between businesses, tasks, back, My Tasks, Account.',
expected: 'No crashes. Navigation stack doesn\'t break. No zombie screens.',
onFail: 'Note crash or broken navigation state.'
},
{
id: 'EC07',
title: 'Double-tap Accept',
preconditions: 'Task detail with Accept button.',
action: 'Tap Accept, then quickly tap Accept again on the alert.',
expected: 'Only one API call made. No double-accept error.',
onFail: 'Note behavior.'
},
{
id: 'EC08',
title: 'Beacon UUID format variants',
preconditions: 'Task with beacon UUID in various formats (with/without dashes).',
action: 'Open task detail.',
expected: 'BeaconScanner.formatUUID normalizes the UUID. Scanning starts correctly regardless of format.',
onFail: 'Check console for UUID parsing errors.'
},
{
id: 'EC09',
title: 'CFML date format parsing',
preconditions: 'Server returns dates in various CFML formats.',
action: 'Load tasks and task details.',
expected: 'Dates parse correctly. timeAgo shows correct relative time. No "just now" for old tasks.',
onFail: 'Note incorrect dates. Check APIService.parseDate formats.'
},
{
id: 'EC10',
title: 'Mixed-case API keys',
preconditions: 'Server returns ALL_CAPS, PascalCase, or camelCase keys.',
action: 'Load any data.',
expected: 'All data populates correctly. normalizeKeys + multiple key fallbacks in models handle any casing.',
onFail: 'Note which fields show as empty or default.'
},
{
id: 'EC11',
title: 'Screen rotation (if supported)',
preconditions: 'Device with rotation enabled.',
action: 'Rotate device on each screen.',
expected: 'Layout adapts. No overlapping elements. ScrollView content remains accessible.',
onFail: 'Screenshot of broken layout.'
},
{
id: 'EC12',
title: 'Memory: background/foreground cycle',
preconditions: 'App running.',
action: 'Send app to background, wait 30 seconds, return.',
expected: 'App resumes normally. Timers restart. No stale data or crashes.',
onFail: 'Note behavior.'
},
{
id: 'EC13',
title: 'Idle timer disabled during beacon scan',
preconditions: 'Beacon scanning active (task detail with Complete + beacon UUID).',
action: 'Leave the screen on during scanning.',
expected: 'Screen does NOT dim or lock (isIdleTimerDisabled = true). Restores when scanning stops or screen dismissed.',
onFail: 'Note if screen dims.'
}
]
},
{
title: '🧪 Test Accounts & Environment',
steps: [
{
id: 'TA01',
title: 'Confirm DEV environment',
preconditions: 'App launched.',
action: 'Check for DEV badge and Debug API output.',
expected: 'DEV badge visible. API base URL is https://dev.payfrit.com/api. Photo URLs resolve to dev.payfrit.com.',
onFail: 'Screenshot.'
},
{
id: 'TA02',
title: 'Test login — UserID 1',
preconditions: 'Login screen.',
action: 'Login with UserID 1 credentials + password 123456.',
expected: 'Businesses shown: Vegainz LA (44), Apple Review (45), Century Bar & Grill (47). Worker name appears in menus.',
onFail: 'Note which businesses appear and any missing.'
},
{
id: 'TA03',
title: 'Test login — UserID 2',
preconditions: 'Login screen.',
action: 'Login with UserID 2 credentials.',
expected: 'Different set of businesses. Verify at least one business appears.',
onFail: 'Note behavior.'
},
{
id: 'TA04',
title: 'Seeded data verification — Vegainz LA',
preconditions: 'Logged in as UserID 1, selected Vegainz LA.',
action: 'Check pending tasks and task details.',
expected: 'Tasks appear (if seeded). Task details load with customer info, line items, etc.',
onFail: 'Note API response from Debug API.'
},
{
id: 'TA05',
title: 'Photo URL resolution',
preconditions: 'Task detail with customer photo.',
action: 'Observe customer photo loads.',
expected: 'Relative photo URLs (starting with /) are resolved to https://dev.payfrit.com/... Full URLs used as-is.',
onFail: 'Note if photos fail to load. Check APIService.resolvePhotoUrl.'
}
]
}
];
// ---- State ----
let state = {};
try {
state = JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
} catch(e) { state = {}; }
function saveState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function getStepState(id) {
return state[id] || { status: 'pending', notes: '' };
}
function setStepStatus(id, status) {
if (!state[id]) state[id] = { status: 'pending', notes: '' };
state[id].status = status;
saveState();
renderStep(id);
updateProgress();
// Auto-scroll to next pending
if (status !== 'pending') {
setTimeout(() => jumpToNext(), 300);
}
}
function setStepNotes(id, notes) {
if (!state[id]) state[id] = { status: 'pending', notes: '' };
state[id].notes = notes;
saveState();
}
// ---- Render ----
const allStepIds = [];
function render() {
const main = document.getElementById('main');
main.innerHTML = '';
sections.forEach(section => {
const group = document.createElement('div');
group.className = 'section-group';
const sectionSteps = section.steps;
const done = sectionSteps.filter(s => getStepState(s.id).status !== 'pending').length;
group.innerHTML = `<div class="section-title">
${section.title}
<span class="badge">${done}/${sectionSteps.length}</span>
</div>`;
sectionSteps.forEach(step => {
allStepIds.push(step.id);
const el = createStepElement(step);
group.appendChild(el);
});
main.appendChild(group);
});
updateProgress();
}
function createStepElement(step) {
const s = getStepState(step.id);
const el = document.createElement('div');
el.className = `step status-${s.status}`;
el.id = `step-${step.id}`;
el.setAttribute('data-id', step.id);
el.innerHTML = `
<div class="step-header" onclick="toggleStep('${step.id}')">
<span class="step-id">${step.id}</span>
<span class="step-title">${step.title}</span>
<span class="step-status-badge badge-${s.status}">${s.status}</span>
<span class="step-expand chevron">▼</span>
</div>
<div class="step-body">
<div class="step-field">
<div class="step-field-label">Preconditions</div>
<div class="step-field-value">${step.preconditions}</div>
</div>
<div class="step-field">
<div class="step-field-label">Action</div>
<div class="step-field-value">${step.action}</div>
</div>
<div class="step-field">
<div class="step-field-label">Expected Result</div>
<div class="step-field-value">${step.expected}</div>
</div>
<div class="step-field">
<div class="step-field-label">What to Capture on Fail</div>
<div class="step-field-value">${step.onFail}</div>
</div>
<div class="step-actions">
<button class="btn btn-pass" onclick="setStepStatus('${step.id}','pass')">✓ Pass</button>
<button class="btn btn-fail" onclick="setStepStatus('${step.id}','fail')">✗ Fail</button>
<button class="btn btn-skip" onclick="setStepStatus('${step.id}','skip')">⊘ Skip</button>
</div>
<textarea class="notes-field" placeholder="Notes for ${step.id}..."
oninput="setStepNotes('${step.id}', this.value)">${s.notes || ''}</textarea>
</div>
`;
return el;
}
function renderStep(id) {
const el = document.getElementById(`step-${id}`);
if (!el) return;
const s = getStepState(id);
el.className = `step status-${s.status}` + (el.classList.contains('open') ? ' open' : '');
el.querySelector('.step-status-badge').className = `step-status-badge badge-${s.status}`;
el.querySelector('.step-status-badge').textContent = s.status;
// Update section badge
sections.forEach(section => {
if (section.steps.some(st => st.id === id)) {
const group = el.closest('.section-group');
if (group) {
const done = section.steps.filter(st => getStepState(st.id).status !== 'pending').length;
group.querySelector('.badge').textContent = `${done}/${section.steps.length}`;
}
}
});
}
function toggleStep(id) {
const el = document.getElementById(`step-${id}`);
if (el) el.classList.toggle('open');
}
function updateProgress() {
const total = allStepIds.length;
const passed = allStepIds.filter(id => getStepState(id).status === 'pass').length;
const failed = allStepIds.filter(id => getStepState(id).status === 'fail').length;
const skipped = allStepIds.filter(id => getStepState(id).status === 'skip').length;
const pending = total - passed - failed - skipped;
const done = passed + failed + skipped;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('progressText').textContent = `${done} / ${total} (${pct}%)`;
document.getElementById('stats').innerHTML = `
<span class="stat-pass">✓ ${passed} Pass</span>
<span class="stat-fail">✗ ${failed} Fail</span>
<span class="stat-skip">⊘ ${skipped} Skip</span>
<span class="stat-pending">○ ${pending} Pending</span>
`;
}
// ---- Controls ----
function expandAll() {
document.querySelectorAll('.step').forEach(el => el.classList.add('open'));
}
function collapseAll() {
document.querySelectorAll('.step').forEach(el => el.classList.remove('open'));
}
function jumpToNext() {
// Find first step that has no allStepIds status set
for (const id of allStepIds) {
if (getStepState(id).status === 'pending') {
const el = document.getElementById(`step-${id}`);
if (el) {
el.classList.add('open');
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return;
}
}
// All done
alert('All steps have been evaluated!');
}
function exportResults() {
const now = new Date().toISOString();
let txt = `=== PAYFRIT WORKS iOS TEST RESULTS ===\n`;
txt += `Exported: ${now}\n`;
txt += `App: ${APP_NAME}\n`;
txt += `Version: ${APP_VERSION}\n`;
txt += `Environment: ${APP_ENV}\n`;
txt += `\n`;
const total = allStepIds.length;
const passed = allStepIds.filter(id => getStepState(id).status === 'pass').length;
const failed = allStepIds.filter(id => getStepState(id).status === 'fail').length;
const skipped = allStepIds.filter(id => getStepState(id).status === 'skip').length;
const pending = total - passed - failed - skipped;
txt += `SUMMARY: ${passed} Pass | ${failed} Fail | ${skipped} Skip | ${pending} Pending (${total} total)\n`;
txt += `${'='.repeat(60)}\n\n`;
sections.forEach(section => {
txt += `--- ${section.title} ---\n`;
section.steps.forEach(step => {
const s = getStepState(step.id);
const statusIcon = s.status === 'pass' ? '✓' : s.status === 'fail' ? '✗' : s.status === 'skip' ? '⊘' : '○';
txt += ` [${statusIcon}] ${step.id}: ${step.title}${s.status.toUpperCase()}\n`;
if (s.notes) {
txt += ` Notes: ${s.notes}\n`;
}
});
txt += `\n`;
});
// Download
const blob = new Blob([txt], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `payfrit-works-ios-test-results-${now.split('T')[0]}.txt`;
a.click();
URL.revokeObjectURL(url);
}
function resetRun() {
if (!confirm('Reset all test results? This will clear all Pass/Fail/Skip statuses and notes for this walkthrough.')) return;
localStorage.removeItem(STORAGE_KEY);
state = {};
// Re-render
allStepIds.length = 0;
render();
}
// ---- Init ----
render();
</script>
</body>
</html>