Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -14,5 +14,8 @@ xcuserdata/
|
|||||||
.swiftpm/
|
.swiftpm/
|
||||||
Package.resolved
|
Package.resolved
|
||||||
|
|
||||||
|
# Node tooling
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# App artifacts
|
# App artifacts
|
||||||
compose-screen.png
|
compose-screen.png
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/bin/zsh
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
script_dir="$(cd -- "$(dirname "$0")" && pwd)"
|
|
||||||
device="${1:-booted}"
|
|
||||||
output_dir="${2:-/tmp/socialio-controlled-shots}"
|
|
||||||
control_file="${SOCIALIO_CONTROL_FILE:-/tmp/socialio-control.txt}"
|
|
||||||
routes_file="${SOCIALIO_SCREENSHOT_ROUTES:-$script_dir/ui-screenshot-routes.txt}"
|
|
||||||
bundle_id="io.social.app"
|
|
||||||
|
|
||||||
mkdir -p "$output_dir"
|
|
||||||
|
|
||||||
printf '%s\n' 'socialio://mailbox/inbox' > "$control_file"
|
|
||||||
|
|
||||||
SIMCTL_CHILD_SOCIALIO_CONTROL_FILE="$control_file" \
|
|
||||||
SIMCTL_CHILD_SOCIALIO_CONTROL_POLL_MS="250" \
|
|
||||||
xcrun simctl launch --terminate-running-process "$device" "$bundle_id" >/dev/null
|
|
||||||
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
while IFS='|' read -r name route; do
|
|
||||||
if [[ -z "${name}" || "${name}" == \#* ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s\n' "$route" > "$control_file"
|
|
||||||
sleep 1.2
|
|
||||||
xcrun simctl io "$device" screenshot "$output_dir/$name.png" >/dev/null
|
|
||||||
done < "$routes_file"
|
|
||||||
|
|
||||||
echo "Saved screenshots to $output_dir"
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/bin/zsh
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
script_dir="$(cd -- "$(dirname "$0")" && pwd)"
|
|
||||||
device="${1:-8EBCDD58-34AB-457A-A878-8004A6108CA9}"
|
|
||||||
output_dir="${2:-/tmp/socialio-ui-review}"
|
|
||||||
derived_data="${3:-/tmp/socialio-ui-review-derived}"
|
|
||||||
project="/Users/philkunz/gitea/social.io-swiftapp/SocialIO/SocialIO.xcodeproj"
|
|
||||||
app_path="$derived_data/Build/Products/Debug-iphonesimulator/SocialIO.app"
|
|
||||||
|
|
||||||
xcodebuild \
|
|
||||||
-project "$project" \
|
|
||||||
-scheme SocialIO \
|
|
||||||
-configuration Debug \
|
|
||||||
-destination "id=$device" \
|
|
||||||
-derivedDataPath "$derived_data" \
|
|
||||||
build
|
|
||||||
|
|
||||||
xcrun simctl install "$device" "$app_path"
|
|
||||||
"$script_dir/capture-controlled-screenshots.sh" "$device" "$output_dir"
|
|
||||||
|
|
||||||
echo "Built app at $app_path"
|
|
||||||
echo "Saved screenshots to $output_dir"
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"@git.zone/tsswift": {
|
||||||
|
"defaultScheme": "SocialIO",
|
||||||
|
"defaultConfiguration": "Debug",
|
||||||
|
"derivedDataPath": ".build/xcode-derived-data",
|
||||||
|
"parallelBuilds": true,
|
||||||
|
"parallelTests": false,
|
||||||
|
"buildPlatforms": ["macos", "ios", "ipad"],
|
||||||
|
"testPlatforms": ["macos"],
|
||||||
|
"defaultPlatform": "macos",
|
||||||
|
"app": {
|
||||||
|
"bundleId": "io.social.app"
|
||||||
|
},
|
||||||
|
"control": {
|
||||||
|
"filePath": "/tmp/socialio-control.txt",
|
||||||
|
"fileEnvKey": "SOCIALIO_CONTROL_FILE",
|
||||||
|
"pollMs": 250,
|
||||||
|
"pollMsEnvKey": "SOCIALIO_CONTROL_POLL_MS",
|
||||||
|
"initialCommand": "socialio://mailbox/inbox"
|
||||||
|
},
|
||||||
|
"screenshots": {
|
||||||
|
"scenariosFile": "Automation/ui-screenshot-routes.txt",
|
||||||
|
"outputDir": "/tmp/socialio-ui-review",
|
||||||
|
"reviewPlatforms": ["ios", "ipad", "macos"],
|
||||||
|
"launchDelayMs": 2000,
|
||||||
|
"stepDelayMs": 1200,
|
||||||
|
"initialCommand": "socialio://mailbox/inbox",
|
||||||
|
"macosWindow": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"x": 80,
|
||||||
|
"y": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"ios": {
|
||||||
|
"simulatorName": "iPhone Air",
|
||||||
|
"runtime": "latest"
|
||||||
|
},
|
||||||
|
"ipad": {
|
||||||
|
"simulatorName": "iPad mini (A17 Pro)",
|
||||||
|
"runtime": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-11
@@ -20,6 +20,37 @@ Multiplatform SwiftUI mail client scaffold for macOS, iPadOS, and iOS.
|
|||||||
- an iPad simulator
|
- an iPad simulator
|
||||||
- an iPhone simulator
|
- an iPhone simulator
|
||||||
|
|
||||||
|
## tsswift workflow
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm install
|
||||||
|
corepack pnpm swift:doctor
|
||||||
|
corepack pnpm swift:emulators
|
||||||
|
corepack pnpm swift:build
|
||||||
|
corepack pnpm swift:test
|
||||||
|
corepack pnpm swift:run
|
||||||
|
corepack pnpm swift:launch
|
||||||
|
corepack pnpm swift:review
|
||||||
|
```
|
||||||
|
|
||||||
|
This repo now uses `@git.zone/tsswift` with project config in `SocialIO/.smartconfig.json`.
|
||||||
|
|
||||||
|
- `build` targets macOS, iPhone Simulator, and iPad Simulator in parallel
|
||||||
|
- `test` targets macOS, which matches the current test bundle setup
|
||||||
|
- `run` defaults to macOS unless you pass `--platform ios` or `--platform ipad`
|
||||||
|
- `launch` starts the app with the configured control-file transport enabled
|
||||||
|
- `review` replaces the old shell-script review loop and captures screenshot sets for iPhone, iPad, and macOS
|
||||||
|
|
||||||
|
Useful direct commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm exec tsswift prefer-emulator --path SocialIO/SocialIO.xcodeproj --platform ios --udid <iphone-simulator-udid>
|
||||||
|
corepack pnpm exec tsswift prefer-emulator --path SocialIO/SocialIO.xcodeproj --platform ipad --udid <ipad-simulator-udid>
|
||||||
|
corepack pnpm exec tsswift command --path SocialIO/SocialIO.xcodeproj --route 'socialio://open?thread=launch-copy&message=launch-copy-2'
|
||||||
|
```
|
||||||
|
|
||||||
## App control contract
|
## App control contract
|
||||||
|
|
||||||
The app can be driven in three ways:
|
The app can be driven in three ways:
|
||||||
@@ -66,30 +97,34 @@ That gives us a mocked backend transport now, and we can swap the same command m
|
|||||||
|
|
||||||
## Screenshot automation
|
## Screenshot automation
|
||||||
|
|
||||||
After the app is built and installed in Simulator, run:
|
Single-platform capture examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/Users/philkunz/gitea/social.io-swiftapp/Scripts/capture-controlled-screenshots.sh booted /tmp/socialio-shots
|
corepack pnpm swift:screenshots:ios
|
||||||
|
corepack pnpm swift:screenshots:ipad
|
||||||
|
corepack pnpm swift:screenshots:macos
|
||||||
```
|
```
|
||||||
|
|
||||||
The script launches the app with `SOCIALIO_CONTROL_FILE`, rewrites that file with a series of routes, and saves screenshots for each destination.
|
The route list now lives in `SocialIO/Automation/ui-screenshot-routes.txt`.
|
||||||
|
|
||||||
## Standard UI review loop
|
## Standard UI review loop
|
||||||
|
|
||||||
For UI-affecting changes, use the one-shot verification script:
|
For UI-affecting changes, use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/Users/philkunz/gitea/social.io-swiftapp/Scripts/verify-ios-ui.sh
|
corepack pnpm swift:review
|
||||||
```
|
```
|
||||||
|
|
||||||
That standard flow:
|
That flow now:
|
||||||
|
|
||||||
- builds the iPhone simulator app
|
- builds and captures an iPhone review pass
|
||||||
- installs it into Simulator
|
- builds and captures an iPad review pass
|
||||||
- runs the backend-control screenshot pass
|
- builds and captures a macOS review pass
|
||||||
- saves the review set to `/tmp/socialio-ui-review`
|
- saves the review set to `/tmp/socialio-ui-review/ios`, `/tmp/socialio-ui-review/ipad`, and `/tmp/socialio-ui-review/macos`
|
||||||
|
|
||||||
The screenshot list lives in `/Users/philkunz/gitea/social.io-swiftapp/Scripts/ui-screenshot-routes.txt`, so we can keep expanding the review set as the app grows.
|
Simulator selection is now handled through `tsswift emulators` and `tsswift prefer-emulator`, and the chosen devices are written into `SocialIO/.smartconfig.json`.
|
||||||
|
|
||||||
|
The macOS capture path still needs Accessibility and Screen Recording permission for the terminal app that runs the command, because `tsswift` positions the app window before each screenshot.
|
||||||
|
|
||||||
## UI test hooks
|
## UI test hooks
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,20 @@
|
|||||||
A10000000000000000000005 /* MailRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* MailRootView.swift */; };
|
A10000000000000000000005 /* MailRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* MailRootView.swift */; };
|
||||||
A10000000000000000000006 /* AppNavigationCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000007 /* AppNavigationCommand.swift */; };
|
A10000000000000000000006 /* AppNavigationCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000007 /* AppNavigationCommand.swift */; };
|
||||||
A10000000000000000000007 /* AppControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* AppControlService.swift */; };
|
A10000000000000000000007 /* AppControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* AppControlService.swift */; };
|
||||||
|
A10000000000000000000008 /* AppNavigationCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* AppNavigationCommandTests.swift */; };
|
||||||
|
A10000000000000000000009 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000A /* AppViewModelTests.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
A90000000000000000000001 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = A60000000000000000000001 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = A50000000000000000000001;
|
||||||
|
remoteInfo = SocialIO;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
A20000000000000000000001 /* SocialIOApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialIOApp.swift; sourceTree = "<group>"; };
|
A20000000000000000000001 /* SocialIOApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialIOApp.swift; sourceTree = "<group>"; };
|
||||||
A20000000000000000000002 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = "<group>"; };
|
A20000000000000000000002 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = "<group>"; };
|
||||||
@@ -25,6 +37,9 @@
|
|||||||
A20000000000000000000006 /* SocialIO.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SocialIO.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
A20000000000000000000006 /* SocialIO.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SocialIO.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A20000000000000000000007 /* AppNavigationCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommand.swift; sourceTree = "<group>"; };
|
A20000000000000000000007 /* AppNavigationCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommand.swift; sourceTree = "<group>"; };
|
||||||
A20000000000000000000008 /* AppControlService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppControlService.swift; sourceTree = "<group>"; };
|
A20000000000000000000008 /* AppControlService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppControlService.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000009 /* AppNavigationCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommandTests.swift; sourceTree = "<group>"; };
|
||||||
|
A2000000000000000000000A /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
|
A2000000000000000000000B /* SocialIOTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SocialIOTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -35,6 +50,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
A30000000000000000000004 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -50,6 +72,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A40000000000000000000003 /* Sources */,
|
A40000000000000000000003 /* Sources */,
|
||||||
|
A4000000000000000000000B /* Tests */,
|
||||||
);
|
);
|
||||||
name = SocialIO;
|
name = SocialIO;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -112,6 +135,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A20000000000000000000006 /* SocialIO.app */,
|
A20000000000000000000006 /* SocialIO.app */,
|
||||||
|
A2000000000000000000000B /* SocialIOTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -124,6 +148,15 @@
|
|||||||
path = Mail;
|
path = Mail;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A4000000000000000000000B /* Tests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000009 /* AppNavigationCommandTests.swift */,
|
||||||
|
A2000000000000000000000A /* AppViewModelTests.swift */,
|
||||||
|
);
|
||||||
|
path = Tests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -144,6 +177,24 @@
|
|||||||
productReference = A20000000000000000000006 /* SocialIO.app */;
|
productReference = A20000000000000000000006 /* SocialIO.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
A50000000000000000000002 /* SocialIOTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = A70000000000000000000003 /* Build configuration list for PBXNativeTarget "SocialIOTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
A30000000000000000000006 /* Sources */,
|
||||||
|
A30000000000000000000004 /* Frameworks */,
|
||||||
|
A30000000000000000000005 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
A90000000000000000000002 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = SocialIOTests;
|
||||||
|
productName = SocialIOTests;
|
||||||
|
productReference = A2000000000000000000000B /* SocialIOTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -157,6 +208,10 @@
|
|||||||
A50000000000000000000001 = {
|
A50000000000000000000001 = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
};
|
};
|
||||||
|
A50000000000000000000002 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
TestTargetID = A50000000000000000000001;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = A70000000000000000000001 /* Build configuration list for PBXProject "SocialIO" */;
|
buildConfigurationList = A70000000000000000000001 /* Build configuration list for PBXProject "SocialIO" */;
|
||||||
@@ -173,6 +228,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
A50000000000000000000001 /* SocialIO */,
|
A50000000000000000000001 /* SocialIO */,
|
||||||
|
A50000000000000000000002 /* SocialIOTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -185,6 +241,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
A30000000000000000000005 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -202,8 +265,25 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
A30000000000000000000006 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
A10000000000000000000008 /* AppNavigationCommandTests.swift in Sources */,
|
||||||
|
A10000000000000000000009 /* AppViewModelTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
A90000000000000000000002 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = A50000000000000000000001 /* SocialIO */;
|
||||||
|
targetProxy = A90000000000000000000001 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
A80000000000000000000001 /* Debug */ = {
|
A80000000000000000000001 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@@ -267,9 +347,11 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = SocialIO;
|
INFOPLIST_KEY_CFBundleDisplayName = "social.io";
|
||||||
|
INFOPLIST_KEY_CFBundleName = "social.io";
|
||||||
INFOPLIST_KEY_CFBundleURLTypes = (
|
INFOPLIST_KEY_CFBundleURLTypes = (
|
||||||
{
|
{
|
||||||
CFBundleTypeRole = Editor;
|
CFBundleTypeRole = Editor;
|
||||||
@@ -307,7 +389,8 @@
|
|||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = SocialIO;
|
INFOPLIST_KEY_CFBundleDisplayName = "social.io";
|
||||||
|
INFOPLIST_KEY_CFBundleName = "social.io";
|
||||||
INFOPLIST_KEY_CFBundleURLTypes = (
|
INFOPLIST_KEY_CFBundleURLTypes = (
|
||||||
{
|
{
|
||||||
CFBundleTypeRole = Editor;
|
CFBundleTypeRole = Editor;
|
||||||
@@ -336,6 +419,51 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
A80000000000000000000005 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
"@loader_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.tests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SocialIO.app/Contents/MacOS/SocialIO";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
A80000000000000000000006 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
"@loader_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = io.social.app.tests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = auto;
|
||||||
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SocialIO.app/Contents/MacOS/SocialIO";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -357,6 +485,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
A70000000000000000000003 /* Build configuration list for PBXNativeTarget "SocialIOTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
A80000000000000000000005 /* Debug */,
|
||||||
|
A80000000000000000000006 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = A60000000000000000000001 /* Project object */;
|
rootObject = A60000000000000000000001 /* Project object */;
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2600"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A50000000000000000000001"
|
||||||
|
BuildableName = "SocialIO.app"
|
||||||
|
BlueprintName = "SocialIO"
|
||||||
|
ReferencedContainer = "container:SocialIO.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "NO"
|
||||||
|
buildForProfiling = "NO"
|
||||||
|
buildForArchiving = "NO"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A50000000000000000000002"
|
||||||
|
BuildableName = "SocialIOTests.xctest"
|
||||||
|
BlueprintName = "SocialIOTests"
|
||||||
|
ReferencedContainer = "container:SocialIO.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A50000000000000000000001"
|
||||||
|
BuildableName = "SocialIO.app"
|
||||||
|
BlueprintName = "SocialIO"
|
||||||
|
ReferencedContainer = "container:SocialIO.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A50000000000000000000002"
|
||||||
|
BuildableName = "SocialIOTests.xctest"
|
||||||
|
BlueprintName = "SocialIOTests"
|
||||||
|
ReferencedContainer = "container:SocialIO.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A50000000000000000000001"
|
||||||
|
BuildableName = "SocialIO.app"
|
||||||
|
BlueprintName = "SocialIO"
|
||||||
|
ReferencedContainer = "container:SocialIO.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A50000000000000000000001"
|
||||||
|
BuildableName = "SocialIO.app"
|
||||||
|
BlueprintName = "SocialIO"
|
||||||
|
ReferencedContainer = "container:SocialIO.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -13,6 +13,7 @@ final class AppViewModel {
|
|||||||
var composeDraft = ComposeDraft()
|
var composeDraft = ComposeDraft()
|
||||||
var threads: [MailThread] = []
|
var threads: [MailThread] = []
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
|
var isSending = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
var mailboxNavigationToken = UUID()
|
var mailboxNavigationToken = UUID()
|
||||||
var threadNavigationToken = UUID()
|
var threadNavigationToken = UUID()
|
||||||
@@ -158,6 +159,7 @@ final class AppViewModel {
|
|||||||
func beginBackendControl() async {
|
func beginBackendControl() async {
|
||||||
guard !isListeningForBackendCommands else { return }
|
guard !isListeningForBackendCommands else { return }
|
||||||
isListeningForBackendCommands = true
|
isListeningForBackendCommands = true
|
||||||
|
defer { isListeningForBackendCommands = false }
|
||||||
|
|
||||||
for await command in controlService.commands() {
|
for await command in controlService.commands() {
|
||||||
apply(command: command)
|
apply(command: command)
|
||||||
@@ -206,17 +208,23 @@ final class AppViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendCurrentDraft() async {
|
func sendCurrentDraft() async -> Bool {
|
||||||
|
guard !isSending else { return false }
|
||||||
|
|
||||||
let draft = composeDraft
|
let draft = composeDraft
|
||||||
isComposing = false
|
isSending = true
|
||||||
|
defer { isSending = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let sentThread = try await service.send(draft: draft)
|
let sentThread = try await service.send(draft: draft)
|
||||||
threads.insert(sentThread, at: 0)
|
threads.insert(sentThread, at: 0)
|
||||||
selectedMailbox = .sent
|
selectedMailbox = .sent
|
||||||
openThread(withID: sentThread.id)
|
openThread(withID: sentThread.id)
|
||||||
|
isComposing = false
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Unable to send message."
|
errorMessage = "Unable to send message."
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -919,6 +919,7 @@ private struct ComposeView: View {
|
|||||||
ComposeFieldCard(title: "Subject") {
|
ComposeFieldCard(title: "Subject") {
|
||||||
TextField("What's this about?", text: $model.composeDraft.subject)
|
TextField("What's this about?", text: $model.composeDraft.subject)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
|
.disabled(model.isSending)
|
||||||
.accessibilityIdentifier("compose.subject")
|
.accessibilityIdentifier("compose.subject")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,6 +927,7 @@ private struct ComposeView: View {
|
|||||||
TextEditor(text: $model.composeDraft.body)
|
TextEditor(text: $model.composeDraft.body)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.frame(minHeight: 240)
|
.frame(minHeight: 240)
|
||||||
|
.disabled(model.isSending)
|
||||||
.accessibilityIdentifier("compose.body")
|
.accessibilityIdentifier("compose.body")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,23 +939,23 @@ private struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Compose")
|
.navigationTitle("Compose")
|
||||||
.navigationBarTitleDisplayMode(usesCompactComposeLayout ? .inline : .automatic)
|
.composeNavigationTitleDisplayMode(isCompact: usesCompactComposeLayout)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.disabled(model.isSending)
|
||||||
.accessibilityIdentifier("compose.cancel")
|
.accessibilityIdentifier("compose.cancel")
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Send") {
|
Button(model.isSending ? "Sending…" : "Send") {
|
||||||
Task {
|
Task {
|
||||||
await model.sendCurrentDraft()
|
_ = await model.sendCurrentDraft()
|
||||||
dismiss()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(model.composeDraft.to.isEmpty || model.composeDraft.body.isEmpty)
|
.disabled(model.isSending || model.composeDraft.to.isEmpty || model.composeDraft.body.isEmpty)
|
||||||
.accessibilityIdentifier("compose.send")
|
.accessibilityIdentifier("compose.send")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -968,11 +970,13 @@ private struct ComposeView: View {
|
|||||||
.textContentType(.emailAddress)
|
.textContentType(.emailAddress)
|
||||||
.keyboardType(.emailAddress)
|
.keyboardType(.emailAddress)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
|
.disabled(model.isSending)
|
||||||
.accessibilityIdentifier("compose.to")
|
.accessibilityIdentifier("compose.to")
|
||||||
#else
|
#else
|
||||||
TextField("name@example.com", text: $model.composeDraft.to)
|
TextField("name@example.com", text: $model.composeDraft.to)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.textContentType(.emailAddress)
|
.textContentType(.emailAddress)
|
||||||
|
.disabled(model.isSending)
|
||||||
.accessibilityIdentifier("compose.to")
|
.accessibilityIdentifier("compose.to")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -986,6 +990,17 @@ private struct ComposeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func composeNavigationTitleDisplayMode(isCompact: Bool) -> some View {
|
||||||
|
#if os(iOS)
|
||||||
|
navigationBarTitleDisplayMode(isCompact ? .inline : .automatic)
|
||||||
|
#else
|
||||||
|
self
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct ComposeFieldCard<Content: View>: View {
|
private struct ComposeFieldCard<Content: View>: View {
|
||||||
let title: String
|
let title: String
|
||||||
let content: Content
|
let content: Content
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import SocialIO
|
||||||
|
|
||||||
|
final class AppNavigationCommandTests: XCTestCase {
|
||||||
|
func testFromEnvironmentPrefersJSONOverRoute() {
|
||||||
|
let environment = [
|
||||||
|
AppNavigationCommand.routeEnvironmentKey: "socialio://mailbox/inbox",
|
||||||
|
AppNavigationCommand.jsonEnvironmentKey: #"{"kind":"compose","to":"team@social.io","subject":"Hello","body":"Hi"}"#
|
||||||
|
]
|
||||||
|
|
||||||
|
let command = AppNavigationCommand.from(environment: environment)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
command,
|
||||||
|
.compose(draft: ComposeDraft(to: "team@social.io", subject: "Hello", body: "Hi"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseMailboxURLIncludesSearchAndUnreadOnly() {
|
||||||
|
let command = AppNavigationCommand.parse("socialio://mailbox/starred?search=roadmap&unreadOnly=true")
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
command,
|
||||||
|
.mailbox(mailbox: .starred, search: "roadmap", unreadOnly: true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseOpenURLMapsThreadAndMessageSelection() {
|
||||||
|
let command = AppNavigationCommand.parse("socialio://open?thread=launch-copy&message=launch-copy-2&mailbox=sent")
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
command,
|
||||||
|
.thread(
|
||||||
|
threadRouteID: "launch-copy",
|
||||||
|
mailbox: .sent,
|
||||||
|
messageRouteID: "launch-copy-2",
|
||||||
|
search: nil,
|
||||||
|
unreadOnly: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParseJSONWithoutKindFallsBackToComposePayload() {
|
||||||
|
let command = AppNavigationCommand.from(json: #"{"to":"grandma@example.com","subject":"Photos","body":"Hi Grandma"}"#)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
command,
|
||||||
|
.compose(draft: ComposeDraft(to: "grandma@example.com", subject: "Photos", body: "Hi Grandma"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import SocialIO
|
||||||
|
|
||||||
|
final class AppViewModelTests: XCTestCase {
|
||||||
|
@MainActor
|
||||||
|
func testFilteredThreadsRespectMailboxUnreadAndSearch() async throws {
|
||||||
|
let inboxUnread = makeThread(
|
||||||
|
routeID: "inbox-unread",
|
||||||
|
mailbox: .inbox,
|
||||||
|
subject: "Roadmap review",
|
||||||
|
body: "Please review the roadmap before launch.",
|
||||||
|
isUnread: true,
|
||||||
|
isStarred: false,
|
||||||
|
sentAt: .now
|
||||||
|
)
|
||||||
|
let inboxRead = makeThread(
|
||||||
|
routeID: "inbox-read",
|
||||||
|
mailbox: .inbox,
|
||||||
|
subject: "Budget sync",
|
||||||
|
body: "Closing the budget sync loop.",
|
||||||
|
isUnread: false,
|
||||||
|
isStarred: false,
|
||||||
|
sentAt: .now.addingTimeInterval(-60)
|
||||||
|
)
|
||||||
|
let archivedStarred = makeThread(
|
||||||
|
routeID: "archived-starred",
|
||||||
|
mailbox: .archive,
|
||||||
|
subject: "Archived roadmap notes",
|
||||||
|
body: "Keeping the roadmap context around.",
|
||||||
|
isUnread: true,
|
||||||
|
isStarred: true,
|
||||||
|
sentAt: .now.addingTimeInterval(-120)
|
||||||
|
)
|
||||||
|
let model = AppViewModel(
|
||||||
|
service: StubMailService(threadsToLoad: [inboxRead, archivedStarred, inboxUnread]),
|
||||||
|
controlService: StubControlService()
|
||||||
|
)
|
||||||
|
|
||||||
|
await model.load()
|
||||||
|
XCTAssertEqual(model.filteredThreads.map(\.routeID), ["inbox-unread", "inbox-read"])
|
||||||
|
|
||||||
|
model.setUnreadOnly(true)
|
||||||
|
XCTAssertEqual(model.filteredThreads.map(\.routeID), ["inbox-unread"])
|
||||||
|
|
||||||
|
model.selectMailbox(.starred)
|
||||||
|
XCTAssertEqual(model.filteredThreads.map(\.routeID), ["archived-starred"])
|
||||||
|
|
||||||
|
model.setSearchText("context")
|
||||||
|
XCTAssertEqual(model.filteredThreads.map(\.routeID), ["archived-starred"])
|
||||||
|
|
||||||
|
model.setSearchText("launch")
|
||||||
|
XCTAssertTrue(model.filteredThreads.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testPendingThreadCommandAppliesAfterLoad() async throws {
|
||||||
|
let thread = makeThread(
|
||||||
|
routeID: "launch-copy",
|
||||||
|
mailbox: .inbox,
|
||||||
|
subject: "Launch copy",
|
||||||
|
body: "The second pass is ready.",
|
||||||
|
isUnread: true,
|
||||||
|
isStarred: false,
|
||||||
|
sentAt: .now,
|
||||||
|
messageRouteID: "launch-copy-2"
|
||||||
|
)
|
||||||
|
let model = AppViewModel(
|
||||||
|
service: StubMailService(threadsToLoad: [thread]),
|
||||||
|
controlService: StubControlService()
|
||||||
|
)
|
||||||
|
|
||||||
|
model.apply(
|
||||||
|
command: .thread(
|
||||||
|
threadRouteID: "launch-copy",
|
||||||
|
mailbox: .inbox,
|
||||||
|
messageRouteID: "launch-copy-2",
|
||||||
|
search: nil,
|
||||||
|
unreadOnly: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertNil(model.selectedThread)
|
||||||
|
|
||||||
|
await model.load()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.selectedThread?.routeID, "launch-copy")
|
||||||
|
XCTAssertEqual(model.focusedMessageRouteID, "launch-copy-2")
|
||||||
|
XCTAssertEqual(model.selectedMailbox, .inbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testSendCurrentDraftSuccessClosesComposeAndSelectsSentThread() async throws {
|
||||||
|
let sentThread = makeThread(
|
||||||
|
routeID: "sent-1",
|
||||||
|
mailbox: .sent,
|
||||||
|
subject: "Status",
|
||||||
|
body: "Sent body",
|
||||||
|
isUnread: false,
|
||||||
|
isStarred: false,
|
||||||
|
sentAt: .now
|
||||||
|
)
|
||||||
|
let service = StubMailService(threadsToLoad: [], sendResult: .success(sentThread))
|
||||||
|
let model = AppViewModel(service: service, controlService: StubControlService())
|
||||||
|
let draft = ComposeDraft(to: "team@social.io", subject: "Status", body: "Sent body")
|
||||||
|
model.composeDraft = draft
|
||||||
|
model.isComposing = true
|
||||||
|
|
||||||
|
let didSend = await model.sendCurrentDraft()
|
||||||
|
|
||||||
|
XCTAssertTrue(didSend)
|
||||||
|
XCTAssertEqual(service.sentDrafts, [draft])
|
||||||
|
XCTAssertFalse(model.isComposing)
|
||||||
|
XCTAssertFalse(model.isSending)
|
||||||
|
XCTAssertEqual(model.selectedMailbox, .sent)
|
||||||
|
XCTAssertEqual(model.selectedThread?.routeID, "sent-1")
|
||||||
|
XCTAssertEqual(model.threads.first?.routeID, "sent-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testSendCurrentDraftFailureKeepsComposeOpenAndPreservesDraft() async throws {
|
||||||
|
let service = StubMailService(threadsToLoad: [], sendResult: .failure(TestError.sendFailed))
|
||||||
|
let model = AppViewModel(service: service, controlService: StubControlService())
|
||||||
|
let draft = ComposeDraft(to: "team@social.io", subject: "Status", body: "Body")
|
||||||
|
model.composeDraft = draft
|
||||||
|
model.isComposing = true
|
||||||
|
|
||||||
|
let didSend = await model.sendCurrentDraft()
|
||||||
|
|
||||||
|
XCTAssertFalse(didSend)
|
||||||
|
XCTAssertEqual(service.sentDrafts, [draft])
|
||||||
|
XCTAssertTrue(model.isComposing)
|
||||||
|
XCTAssertFalse(model.isSending)
|
||||||
|
XCTAssertEqual(model.composeDraft, draft)
|
||||||
|
XCTAssertEqual(model.errorMessage, "Unable to send message.")
|
||||||
|
XCTAssertTrue(model.threads.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testBeginBackendControlCanRestartAfterPreviousStreamFinishes() async throws {
|
||||||
|
let model = AppViewModel(
|
||||||
|
service: StubMailService(threadsToLoad: [
|
||||||
|
makeThread(
|
||||||
|
routeID: "launch-copy",
|
||||||
|
mailbox: .inbox,
|
||||||
|
subject: "Launch copy",
|
||||||
|
body: "Mail body",
|
||||||
|
isUnread: true,
|
||||||
|
isStarred: false,
|
||||||
|
sentAt: .now
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
controlService: StubControlService(commandsPerCall: [
|
||||||
|
[.mailbox(mailbox: .archive, search: nil, unreadOnly: true)],
|
||||||
|
[.mailbox(mailbox: .starred, search: "roadmap", unreadOnly: false)]
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
await model.load()
|
||||||
|
await model.beginBackendControl()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.selectedMailbox, .archive)
|
||||||
|
XCTAssertTrue(model.showUnreadOnly)
|
||||||
|
|
||||||
|
await model.beginBackendControl()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.selectedMailbox, .starred)
|
||||||
|
XCTAssertEqual(model.searchText, "roadmap")
|
||||||
|
XCTAssertFalse(model.showUnreadOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum TestError: Error {
|
||||||
|
case sendFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class StubMailService: MailServicing {
|
||||||
|
private let threadsToLoad: [MailThread]
|
||||||
|
private let sendResult: Result<MailThread, Error>
|
||||||
|
private(set) var sentDrafts: [ComposeDraft] = []
|
||||||
|
|
||||||
|
init(threadsToLoad: [MailThread], sendResult: Result<MailThread, Error> = .failure(TestError.sendFailed)) {
|
||||||
|
self.threadsToLoad = threadsToLoad
|
||||||
|
self.sendResult = sendResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadThreads() async throws -> [MailThread] {
|
||||||
|
threadsToLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(draft: ComposeDraft) async throws -> MailThread {
|
||||||
|
sentDrafts.append(draft)
|
||||||
|
return try sendResult.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class StubControlService: AppControlServicing {
|
||||||
|
private let commandsPerCall: [[AppNavigationCommand]]
|
||||||
|
private var callCount = 0
|
||||||
|
|
||||||
|
init(commandsPerCall: [[AppNavigationCommand]] = []) {
|
||||||
|
self.commandsPerCall = commandsPerCall
|
||||||
|
}
|
||||||
|
|
||||||
|
func commands() -> AsyncStream<AppNavigationCommand> {
|
||||||
|
let commands = callCount < commandsPerCall.count ? commandsPerCall[callCount] : []
|
||||||
|
callCount += 1
|
||||||
|
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
for command in commands {
|
||||||
|
continuation.yield(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeThread(
|
||||||
|
routeID: String,
|
||||||
|
mailbox: Mailbox,
|
||||||
|
subject: String,
|
||||||
|
body: String,
|
||||||
|
isUnread: Bool,
|
||||||
|
isStarred: Bool,
|
||||||
|
sentAt: Date,
|
||||||
|
messageRouteID: String? = nil
|
||||||
|
) -> MailThread {
|
||||||
|
let sender = MailPerson(name: "Sender", email: "sender@social.io")
|
||||||
|
let recipient = MailPerson(name: "Recipient", email: "recipient@social.io")
|
||||||
|
|
||||||
|
return MailThread(
|
||||||
|
routeID: routeID,
|
||||||
|
mailbox: mailbox,
|
||||||
|
subject: subject,
|
||||||
|
participants: [sender, recipient],
|
||||||
|
messages: [
|
||||||
|
MailMessage(
|
||||||
|
routeID: messageRouteID ?? "\(routeID)-message",
|
||||||
|
sender: sender,
|
||||||
|
recipients: [recipient],
|
||||||
|
sentAt: sentAt,
|
||||||
|
body: body
|
||||||
|
)
|
||||||
|
],
|
||||||
|
isUnread: isUnread,
|
||||||
|
isStarred: isStarred,
|
||||||
|
tags: []
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "social-io-swiftapp-tooling",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.18.1",
|
||||||
|
"scripts": {
|
||||||
|
"swift:doctor": "tsswift doctor --path SocialIO/SocialIO.xcodeproj",
|
||||||
|
"swift:emulators": "tsswift emulators --path SocialIO/SocialIO.xcodeproj",
|
||||||
|
"swift:build": "tsswift build --path SocialIO/SocialIO.xcodeproj",
|
||||||
|
"swift:build:macos": "tsswift build --path SocialIO/SocialIO.xcodeproj --platform macos",
|
||||||
|
"swift:test": "tsswift test --path SocialIO/SocialIO.xcodeproj",
|
||||||
|
"swift:run": "tsswift run --path SocialIO/SocialIO.xcodeproj",
|
||||||
|
"swift:launch": "tsswift launch --path SocialIO/SocialIO.xcodeproj",
|
||||||
|
"swift:command:inbox": "tsswift command --path SocialIO/SocialIO.xcodeproj --route socialio://mailbox/inbox",
|
||||||
|
"swift:screenshots:ios": "tsswift screenshots --path SocialIO/SocialIO.xcodeproj --platform ios",
|
||||||
|
"swift:screenshots:ipad": "tsswift screenshots --path SocialIO/SocialIO.xcodeproj --platform ipad",
|
||||||
|
"swift:screenshots:macos": "tsswift screenshots --path SocialIO/SocialIO.xcodeproj --platform macos",
|
||||||
|
"swift:review": "tsswift review --path SocialIO/SocialIO.xcodeproj"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@git.zone/tsswift": "0.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2250
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user