Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-04-19 00:46:00 +02:00
parent ce19d00de3
commit a11d88d365
14 changed files with 2947 additions and 76 deletions

3
.gitignore vendored
View File

@@ -14,5 +14,8 @@ xcuserdata/
.swiftpm/
Package.resolved
# Node tooling
node_modules/
# App artifacts
compose-screen.png

View File

@@ -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"

View File

@@ -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"

View 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"
}
}
}
}

View File

@@ -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

View File

@@ -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 */;

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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

View 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"))
)
}
}

View 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
View 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

File diff suppressed because it is too large Load Diff