Refactor code structure for improved readability and maintainability
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,5 +14,8 @@ xcuserdata/
|
||||
.swiftpm/
|
||||
Package.resolved
|
||||
|
||||
# Node tooling
|
||||
node_modules/
|
||||
|
||||
# App artifacts
|
||||
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"
|
||||
46
SocialIO/.smartconfig.json
Normal file
46
SocialIO/.smartconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,37 @@ Multiplatform SwiftUI mail client scaffold for macOS, iPadOS, and iOS.
|
||||
- an iPad 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
|
||||
|
||||
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
|
||||
|
||||
After the app is built and installed in Simulator, run:
|
||||
Single-platform capture examples:
|
||||
|
||||
```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
|
||||
|
||||
For UI-affecting changes, use the one-shot verification script:
|
||||
For UI-affecting changes, use:
|
||||
|
||||
```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
|
||||
- installs it into Simulator
|
||||
- runs the backend-control screenshot pass
|
||||
- saves the review set to `/tmp/socialio-ui-review`
|
||||
- builds and captures an iPhone review pass
|
||||
- builds and captures an iPad review pass
|
||||
- builds and captures a macOS review pass
|
||||
- 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
|
||||
|
||||
|
||||
@@ -14,8 +14,20 @@
|
||||
A10000000000000000000005 /* MailRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* MailRootView.swift */; };
|
||||
A10000000000000000000006 /* AppNavigationCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000007 /* AppNavigationCommand.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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
A90000000000000000000001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A60000000000000000000001 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = A50000000000000000000001;
|
||||
remoteInfo = SocialIO;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
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>"; };
|
||||
@@ -25,6 +37,9 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -35,6 +50,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A30000000000000000000004 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -50,6 +72,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A40000000000000000000003 /* Sources */,
|
||||
A4000000000000000000000B /* Tests */,
|
||||
);
|
||||
name = SocialIO;
|
||||
sourceTree = "<group>";
|
||||
@@ -112,6 +135,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A20000000000000000000006 /* SocialIO.app */,
|
||||
A2000000000000000000000B /* SocialIOTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -124,6 +148,15 @@
|
||||
path = Mail;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A4000000000000000000000B /* Tests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A20000000000000000000009 /* AppNavigationCommandTests.swift */,
|
||||
A2000000000000000000000A /* AppViewModelTests.swift */,
|
||||
);
|
||||
path = Tests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -144,6 +177,24 @@
|
||||
productReference = A20000000000000000000006 /* SocialIO.app */;
|
||||
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 */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -157,6 +208,10 @@
|
||||
A50000000000000000000001 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
A50000000000000000000002 = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
TestTargetID = A50000000000000000000001;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A70000000000000000000001 /* Build configuration list for PBXProject "SocialIO" */;
|
||||
@@ -173,6 +228,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A50000000000000000000001 /* SocialIO */,
|
||||
A50000000000000000000002 /* SocialIOTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -185,6 +241,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A30000000000000000000005 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -202,8 +265,25 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A30000000000000000000006 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A10000000000000000000008 /* AppNavigationCommandTests.swift in Sources */,
|
||||
A10000000000000000000009 /* AppViewModelTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
A90000000000000000000002 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A50000000000000000000001 /* SocialIO */;
|
||||
targetProxy = A90000000000000000000001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A80000000000000000000001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -267,9 +347,11 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SocialIO;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "social.io";
|
||||
INFOPLIST_KEY_CFBundleName = "social.io";
|
||||
INFOPLIST_KEY_CFBundleURLTypes = (
|
||||
{
|
||||
CFBundleTypeRole = Editor;
|
||||
@@ -307,7 +389,8 @@
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SocialIO;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "social.io";
|
||||
INFOPLIST_KEY_CFBundleName = "social.io";
|
||||
INFOPLIST_KEY_CFBundleURLTypes = (
|
||||
{
|
||||
CFBundleTypeRole = Editor;
|
||||
@@ -336,6 +419,51 @@
|
||||
};
|
||||
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 */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -357,6 +485,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A70000000000000000000003 /* Build configuration list for PBXNativeTarget "SocialIOTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A80000000000000000000005 /* Debug */,
|
||||
A80000000000000000000006 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
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 threads: [MailThread] = []
|
||||
var isLoading = false
|
||||
var isSending = false
|
||||
var errorMessage: String?
|
||||
var mailboxNavigationToken = UUID()
|
||||
var threadNavigationToken = UUID()
|
||||
@@ -158,6 +159,7 @@ final class AppViewModel {
|
||||
func beginBackendControl() async {
|
||||
guard !isListeningForBackendCommands else { return }
|
||||
isListeningForBackendCommands = true
|
||||
defer { isListeningForBackendCommands = false }
|
||||
|
||||
for await command in controlService.commands() {
|
||||
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
|
||||
isComposing = false
|
||||
isSending = true
|
||||
defer { isSending = false }
|
||||
|
||||
do {
|
||||
let sentThread = try await service.send(draft: draft)
|
||||
threads.insert(sentThread, at: 0)
|
||||
selectedMailbox = .sent
|
||||
openThread(withID: sentThread.id)
|
||||
isComposing = false
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = "Unable to send message."
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -919,6 +919,7 @@ private struct ComposeView: View {
|
||||
ComposeFieldCard(title: "Subject") {
|
||||
TextField("What's this about?", text: $model.composeDraft.subject)
|
||||
.textFieldStyle(.plain)
|
||||
.disabled(model.isSending)
|
||||
.accessibilityIdentifier("compose.subject")
|
||||
}
|
||||
|
||||
@@ -926,6 +927,7 @@ private struct ComposeView: View {
|
||||
TextEditor(text: $model.composeDraft.body)
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 240)
|
||||
.disabled(model.isSending)
|
||||
.accessibilityIdentifier("compose.body")
|
||||
}
|
||||
|
||||
@@ -937,23 +939,23 @@ private struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Compose")
|
||||
.navigationBarTitleDisplayMode(usesCompactComposeLayout ? .inline : .automatic)
|
||||
.composeNavigationTitleDisplayMode(isCompact: usesCompactComposeLayout)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.disabled(model.isSending)
|
||||
.accessibilityIdentifier("compose.cancel")
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Send") {
|
||||
Button(model.isSending ? "Sending…" : "Send") {
|
||||
Task {
|
||||
await model.sendCurrentDraft()
|
||||
dismiss()
|
||||
_ = await model.sendCurrentDraft()
|
||||
}
|
||||
}
|
||||
.disabled(model.composeDraft.to.isEmpty || model.composeDraft.body.isEmpty)
|
||||
.disabled(model.isSending || model.composeDraft.to.isEmpty || model.composeDraft.body.isEmpty)
|
||||
.accessibilityIdentifier("compose.send")
|
||||
}
|
||||
}
|
||||
@@ -968,11 +970,13 @@ private struct ComposeView: View {
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disabled(model.isSending)
|
||||
.accessibilityIdentifier("compose.to")
|
||||
#else
|
||||
TextField("name@example.com", text: $model.composeDraft.to)
|
||||
.textFieldStyle(.plain)
|
||||
.textContentType(.emailAddress)
|
||||
.disabled(model.isSending)
|
||||
.accessibilityIdentifier("compose.to")
|
||||
#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 {
|
||||
let title: String
|
||||
let content: Content
|
||||
|
||||
51
SocialIO/Tests/AppNavigationCommandTests.swift
Normal file
51
SocialIO/Tests/AppNavigationCommandTests.swift
Normal file
@@ -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"))
|
||||
)
|
||||
}
|
||||
}
|
||||
249
SocialIO/Tests/AppViewModelTests.swift
Normal file
249
SocialIO/Tests/AppViewModelTests.swift
Normal file
@@ -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: []
|
||||
)
|
||||
}
|
||||
22
package.json
Normal file
22
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
2250
pnpm-lock.yaml
generated
Normal file
2250
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user