diff --git a/.github/workflows/emoji-ui-proof.yml b/.github/workflows/emoji-ui-proof.yml
new file mode 100644
index 0000000..4766cbe
--- /dev/null
+++ b/.github/workflows/emoji-ui-proof.yml
@@ -0,0 +1,828 @@
+name: Emoji UI Proof
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+ - reopened
+ - ready_for_review
+ - labeled
+ paths:
+ - "COMFIE/Presentation/**"
+ - "COMFIE/App/**"
+ - "COMFIE/Domain/TextToEmoji/**"
+ - "COMFIEUITests/**"
+ - "COMFIETests/**"
+ - "COMFIE.xcodeproj/**"
+ - ".github/workflows/emoji-ui-proof.yml"
+ workflow_dispatch:
+ inputs:
+ test_scope:
+ description: "all: COMFIEUITests 전체, focused: proof test 1개"
+ required: false
+ default: all
+ type: choice
+ options:
+ - all
+ - focused
+
+concurrency:
+ group: emoji-ui-proof-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+
+jobs:
+ emoji-ui-proof:
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
+ runs-on: macos-15
+ timeout-minutes: 45
+ env:
+ # 프로젝트/테스트 기본 설정
+ PROJECT_PATH: COMFIE.xcodeproj
+ SCHEME: COMFIE
+ TEST_IDENTIFIER: COMFIEUITests/COMFIEUITests/testHangulJamoTypingShowsStepByStepProgress
+ FULL_UI_TEST_TARGET: COMFIEUITests
+ PUBLISH_BRANCH: ui-proof-media
+ PUBLISH_PREFIX: emoji-ui-proof
+ # 러너 산출물 경로 (repo 내 임시 디렉터리, .gitignore로 제외)
+ RESULT_BUNDLE_PATH: ${{ github.workspace }}/.local-ci/emoji-ui-proof.xcresult
+ ATTACHMENTS_PATH: ${{ github.workspace }}/.local-ci/emoji-ui-proof-attachments
+ VIDEO_PATH: ${{ github.workspace }}/.local-ci/emoji-ui-proof.mov
+ GIF_PATH: ${{ github.workspace }}/.local-ci/emoji-ui-proof.gif
+ GIF_DIR: ${{ github.workspace }}/.local-ci/emoji-ui-proof-gifs
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Ensure Info.plist exists in CI workspace
+ run: |
+ set -euo pipefail
+ if [ -f "COMFIE/Info.plist" ]; then
+ exit 0
+ fi
+
+ cat > "COMFIE/Info.plist" <<'PLIST'
+
+
+
+
+ UIAppFonts
+
+ Pretendard-Bold.otf
+ Pretendard-Medium.otf
+ Pretendard-Regular.otf
+
+ UIBackgroundModes
+
+ remote-notification
+
+
+
+ PLIST
+
+ - name: Resolve simulator destination
+ id: destination
+ run: |
+ set -euo pipefail
+
+ python3 <<'PY'
+ import json
+ import os
+ import subprocess
+ import sys
+
+ raw = subprocess.check_output(
+ ["xcrun", "simctl", "list", "devices", "available", "-j"],
+ text=True,
+ )
+ payload = json.loads(raw)
+
+ candidates = []
+ for runtime, devices in payload.get("devices", {}).items():
+ if "iOS" not in runtime:
+ continue
+ for device in devices:
+ if not device.get("isAvailable"):
+ continue
+ name = device.get("name", "")
+ if not name.startswith("iPhone"):
+ continue
+ candidates.append((name, device.get("udid", ""), runtime))
+
+ if not candidates:
+ print("No available iOS iPhone simulator was found.", file=sys.stderr)
+ sys.exit(1)
+
+ preferred_order = [
+ "iPhone 16 Pro",
+ "iPhone 16",
+ "iPhone 15 Pro",
+ "iPhone 15",
+ "iPhone 14 Pro",
+ "iPhone 14",
+ ]
+
+ selected = None
+ for preferred in preferred_order:
+ for candidate in candidates:
+ if candidate[0] == preferred:
+ selected = candidate
+ break
+ if selected is not None:
+ break
+
+ if selected is None:
+ selected = candidates[0]
+
+ sim_name, sim_udid, runtime = selected
+ print(f"Selected simulator: {sim_name} ({runtime}) [{sim_udid}]")
+
+ output_path = os.environ["GITHUB_OUTPUT"]
+ with open(output_path, "a", encoding="utf-8") as handle:
+ handle.write(f"sim_name={sim_name}\n")
+ handle.write(f"sim_udid={sim_udid}\n")
+ PY
+
+ - name: Boot simulator
+ run: |
+ set -euo pipefail
+ xcrun simctl boot "${{ steps.destination.outputs.sim_udid }}" || true
+ xcrun simctl bootstatus "${{ steps.destination.outputs.sim_udid }}" -b
+
+ - name: Resolve test scope
+ id: scope
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ DISPATCH_SCOPE: ${{ github.event.inputs.test_scope || '' }}
+ PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }}
+ run: |
+ set -euo pipefail
+
+ scope="all"
+
+ if [ "${EVENT_NAME}" = "pull_request" ]; then
+ if echo "${PR_LABELS_JSON}" | grep -q '"ui-proof:focused"'; then
+ scope="focused"
+ fi
+ fi
+
+ if [ "${EVENT_NAME}" = "workflow_dispatch" ] && [ -n "${DISPATCH_SCOPE}" ]; then
+ scope="${DISPATCH_SCOPE}"
+ fi
+
+ case "${scope}" in
+ focused)
+ echo "selector=-only-testing:${TEST_IDENTIFIER}" >> "$GITHUB_OUTPUT"
+ echo "scope_label=focused" >> "$GITHUB_OUTPUT"
+ ;;
+ all)
+ echo "selector=-only-testing:${FULL_UI_TEST_TARGET}" >> "$GITHUB_OUTPUT"
+ echo "scope_label=all" >> "$GITHUB_OUTPUT"
+ ;;
+ *)
+ echo "Unsupported scope: ${scope}" >&2
+ exit 1
+ ;;
+ esac
+
+ echo "resolved_scope=${scope}" >> "$GITHUB_OUTPUT"
+
+ - name: Run UI tests with recording
+ id: run_ui_test
+ run: |
+ set +e
+ set -o pipefail
+
+ xcrun simctl io "${{ steps.destination.outputs.sim_udid }}" recordVideo --codec=h264 --force "${VIDEO_PATH}" \
+ > "${RUNNER_TEMP}/emoji-ui-proof-record.log" 2>&1 &
+ video_pid=$!
+
+ xcodebuild test \
+ -project "${PROJECT_PATH}" \
+ -scheme "${SCHEME}" \
+ -destination "id=${{ steps.destination.outputs.sim_udid }}" \
+ -parallel-testing-enabled NO \
+ "${{ steps.scope.outputs.selector }}" \
+ -resultBundlePath "${RESULT_BUNDLE_PATH}" \
+ | tee "${RUNNER_TEMP}/emoji-ui-proof.log"
+ test_exit=${PIPESTATUS[0]}
+
+ kill -INT "${video_pid}" > /dev/null 2>&1 || true
+ wait "${video_pid}" > /dev/null 2>&1 || true
+
+ if [ ! -s "${VIDEO_PATH}" ]; then
+ echo "Video file missing or empty. Continuing with screenshot attachments." \
+ | tee -a "${RUNNER_TEMP}/emoji-ui-proof.log"
+ fi
+
+ echo "exit_code=${test_exit}" >> "$GITHUB_OUTPUT"
+ exit 0
+
+ - name: Export xcresult attachments
+ id: export_attachments
+ if: always()
+ run: |
+ set -euo pipefail
+
+ mkdir -p "${ATTACHMENTS_PATH}"
+ if [ -d "${RESULT_BUNDLE_PATH}" ]; then
+ xcrun xcresulttool export attachments \
+ --path "${RESULT_BUNDLE_PATH}" \
+ --output-path "${ATTACHMENTS_PATH}" || true
+ fi
+
+ attachment_count="$(find "${ATTACHMENTS_PATH}" -type f | wc -l | tr -d ' ')"
+ echo "attachment_count=${attachment_count}" >> "$GITHUB_OUTPUT"
+
+ - name: Build animated GIF from proof screenshots
+ id: build_gif
+ if: always()
+ run: |
+ set -euo pipefail
+
+ swift - <<'SWIFT'
+ import Foundation
+ import CoreGraphics
+ import ImageIO
+ import UniformTypeIdentifiers
+
+ let environment = ProcessInfo.processInfo.environment
+ guard let attachmentsPath = environment["ATTACHMENTS_PATH"],
+ let gifDirPath = environment["GIF_DIR"] else {
+ fputs("Required environment variables are missing.\n", stderr)
+ exit(1)
+ }
+
+ let attachmentsURL = URL(fileURLWithPath: attachmentsPath)
+ let gifDirURL = URL(fileURLWithPath: gifDirPath)
+ let fileManager = FileManager.default
+
+ struct ExportManifestEntry: Decodable {
+ let exportedFileName: String
+ let suggestedHumanReadableName: String?
+ let timestamp: Double?
+ }
+
+ struct ExportManifestGroup: Decodable {
+ let attachments: [ExportManifestEntry]
+ }
+
+ struct FrameInfo {
+ let fileURL: URL
+ let displayName: String
+ let timestamp: Double
+ }
+
+ func collectPNGFiles(root: URL) -> [FrameInfo] {
+ guard let enumerator = fileManager.enumerator(
+ at: root,
+ includingPropertiesForKeys: [.isRegularFileKey],
+ options: [.skipsHiddenFiles]
+ ) else {
+ return []
+ }
+
+ var files: [FrameInfo] = []
+ for case let fileURL as URL in enumerator {
+ guard fileURL.pathExtension.lowercased() == "png" else { continue }
+ let modified = (try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]))
+ .flatMap(\.contentModificationDate)?.timeIntervalSince1970 ?? 0
+ files.append(
+ FrameInfo(
+ fileURL: fileURL,
+ displayName: fileURL.deletingPathExtension().lastPathComponent,
+ timestamp: modified
+ )
+ )
+ }
+ return files
+ }
+
+ func inferGroupKey(from rawName: String) -> String {
+ let normalized = rawName
+ .lowercased()
+ .replacingOccurrences(of: ".png", with: "")
+ .replacingOccurrences(of: " ", with: "-")
+ let markers = [
+ "-step-",
+ "-after-",
+ "-before-",
+ "-key-",
+ "-syllable-",
+ "-debugsummary-",
+ "-missing-"
+ ]
+ for marker in markers {
+ if let range = normalized.range(of: marker) {
+ return String(normalized[..= 2 {
+ return "\(parts[0])-\(parts[1])"
+ }
+ return normalized.isEmpty ? "proof" : normalized
+ }
+
+ func sanitizeFileName(_ value: String) -> String {
+ let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
+ let scalarView = value.unicodeScalars.map { scalar -> Character in
+ allowed.contains(scalar) ? Character(scalar) : "-"
+ }
+ var normalized = String(scalarView)
+ while normalized.contains("--") {
+ normalized = normalized.replacingOccurrences(of: "--", with: "-")
+ }
+ normalized = normalized.trimmingCharacters(in: CharacterSet(charactersIn: "-_"))
+ return normalized.isEmpty ? "proof" : normalized
+ }
+
+ func evenlySample(_ values: [T], maxCount: Int) -> [T] {
+ guard values.count > maxCount, maxCount > 1 else { return values }
+ let lastIndex = values.count - 1
+ let step = Double(lastIndex) / Double(maxCount - 1)
+ var sampled: [T] = []
+ sampled.reserveCapacity(maxCount)
+ for index in 0.. CGImage {
+ guard image.width > maxWidth else { return image }
+ let ratio = Double(maxWidth) / Double(image.width)
+ let targetHeight = max(1, Int((Double(image.height) * ratio).rounded()))
+ guard let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB) else {
+ return image
+ }
+ guard let context = CGContext(
+ data: nil,
+ width: maxWidth,
+ height: targetHeight,
+ bitsPerComponent: 8,
+ bytesPerRow: 0,
+ space: colorSpace,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else {
+ return image
+ }
+ context.interpolationQuality = .medium
+ context.draw(image, in: CGRect(x: 0, y: 0, width: maxWidth, height: targetHeight))
+ return context.makeImage() ?? image
+ }
+
+ func orderedFramesFromManifest(root: URL) -> [FrameInfo] {
+ let manifestURL = root.appendingPathComponent("manifest.json")
+ guard fileManager.fileExists(atPath: manifestURL.path),
+ let data = try? Data(contentsOf: manifestURL),
+ let groups = try? JSONDecoder().decode([ExportManifestGroup].self, from: data) else {
+ return []
+ }
+
+ return groups.flatMap(\.attachments)
+ .filter { $0.exportedFileName.lowercased().hasSuffix(".png") }
+ .compactMap { attachment in
+ let fileURL = root.appendingPathComponent(attachment.exportedFileName)
+ guard fileManager.fileExists(atPath: fileURL.path) else { return nil }
+ return FrameInfo(
+ fileURL: fileURL,
+ displayName: attachment.suggestedHumanReadableName ?? attachment.exportedFileName,
+ timestamp: attachment.timestamp ?? 0
+ )
+ }
+ .sorted { lhs, rhs in
+ if lhs.timestamp == rhs.timestamp {
+ return lhs.displayName < rhs.displayName
+ }
+ return lhs.timestamp < rhs.timestamp
+ }
+ }
+
+ func buildGIF(frames: [FrameInfo], to outputURL: URL) -> Int {
+ guard !frames.isEmpty else { return 0 }
+ try? fileManager.removeItem(at: outputURL)
+
+ guard let destination = CGImageDestinationCreateWithURL(
+ outputURL as CFURL,
+ UTType.gif.identifier as CFString,
+ frames.count,
+ nil
+ ) else {
+ return 0
+ }
+
+ let gifProperties = [
+ kCGImagePropertyGIFDictionary as String: [
+ kCGImagePropertyGIFLoopCount as String: 0
+ ]
+ ] as CFDictionary
+ CGImageDestinationSetProperties(destination, gifProperties)
+
+ var addedFrameCount = 0
+ for (index, frame) in frames.enumerated() {
+ guard let source = CGImageSourceCreateWithURL(frame.fileURL as CFURL, nil),
+ let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
+ continue
+ }
+ let resized = resizedIfNeeded(image, maxWidth: 420)
+ let delay = index == frames.count - 1 ? 1.2 : 0.35
+ let frameProperties = [
+ kCGImagePropertyGIFDictionary as String: [
+ kCGImagePropertyGIFDelayTime as String: delay
+ ]
+ ] as CFDictionary
+ CGImageDestinationAddImage(destination, resized, frameProperties)
+ addedFrameCount += 1
+ }
+
+ guard addedFrameCount > 0 else { return 0 }
+ guard CGImageDestinationFinalize(destination) else { return 0 }
+ return addedFrameCount
+ }
+
+ var orderedFrames = orderedFramesFromManifest(root: attachmentsURL)
+ if orderedFrames.isEmpty {
+ orderedFrames = collectPNGFiles(root: attachmentsURL).sorted { lhs, rhs in
+ if lhs.timestamp == rhs.timestamp {
+ return lhs.displayName < rhs.displayName
+ }
+ return lhs.timestamp < rhs.timestamp
+ }
+ }
+
+ if orderedFrames.isEmpty {
+ fputs("No PNG frames were found. GIF generation skipped.\n", stderr)
+ exit(0)
+ }
+
+ try? fileManager.removeItem(at: gifDirURL)
+ try? fileManager.createDirectory(at: gifDirURL, withIntermediateDirectories: true)
+
+ var grouped: [String: [FrameInfo]] = [:]
+ for frame in orderedFrames {
+ let key = inferGroupKey(from: frame.displayName)
+ grouped[key, default: []].append(frame)
+ }
+
+ let groupOrder = grouped.keys.sorted { lhs, rhs in
+ let leftTimestamp = grouped[lhs]?.map(\.timestamp).min() ?? 0
+ let rightTimestamp = grouped[rhs]?.map(\.timestamp).min() ?? 0
+ if leftTimestamp == rightTimestamp { return lhs < rhs }
+ return leftTimestamp < rightTimestamp
+ }
+
+ let maxFramesPerGIF = 36
+ var generatedCount = 0
+ for key in groupOrder {
+ guard var frames = grouped[key], !frames.isEmpty else { continue }
+ frames.sort { lhs, rhs in
+ if lhs.timestamp == rhs.timestamp {
+ return lhs.displayName < rhs.displayName
+ }
+ return lhs.timestamp < rhs.timestamp
+ }
+ let sampled = evenlySample(frames, maxCount: maxFramesPerGIF)
+ let fileName = "\(sanitizeFileName(key)).gif"
+ let outputURL = gifDirURL.appendingPathComponent(fileName)
+ let added = buildGIF(frames: sampled, to: outputURL)
+ if added > 0 {
+ generatedCount += 1
+ print("Generated \(fileName) with \(added) frames.")
+ }
+ }
+
+ if generatedCount == 0 {
+ fputs("No valid GIF files were generated.\n", stderr)
+ exit(0)
+ }
+ SWIFT
+
+ generated_gifs=()
+ while IFS= read -r gif_file; do
+ generated_gifs+=("${gif_file}")
+ done < <(find "${GIF_DIR}" -maxdepth 1 -type f -name '*.gif' -print | sort)
+ if [ "${#generated_gifs[@]}" -gt 0 ]; then
+ primary_gif="${generated_gifs[0]}"
+ for preferred in "hangul-emoji" "emoji-proof" "memo-send" "memo-compose" "memo-edit-delete"; do
+ for candidate in "${generated_gifs[@]}"; do
+ if [[ "$(basename "${candidate}")" == *"${preferred}"* ]]; then
+ primary_gif="${candidate}"
+ break 2
+ fi
+ done
+ done
+
+ cp "${primary_gif}" "${GIF_PATH}"
+ gif_names_csv="$(printf '%s\n' "${generated_gifs[@]}" | xargs -n1 basename | paste -sd, -)"
+ echo "gif_created=true" >> "$GITHUB_OUTPUT"
+ echo "generated_gif_count=${#generated_gifs[@]}" >> "$GITHUB_OUTPUT"
+ echo "generated_gif_names=${gif_names_csv}" >> "$GITHUB_OUTPUT"
+ echo "primary_gif_name=$(basename "${primary_gif}")" >> "$GITHUB_OUTPUT"
+ else
+ echo "gif_created=false" >> "$GITHUB_OUTPUT"
+ echo "generated_gif_count=0" >> "$GITHUB_OUTPUT"
+ echo "generated_gif_names=" >> "$GITHUB_OUTPUT"
+ echo "primary_gif_name=" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Publish GIF for inline PR preview
+ id: publish_inline
+ if: always() && steps.build_gif.outputs.gif_created == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
+ continue-on-error: true
+ env:
+ REPOSITORY: ${{ github.repository }}
+ PR_NUMBER: ${{ github.event.pull_request.number || 'manual' }}
+ SCOPE_LABEL: ${{ steps.scope.outputs.scope_label }}
+ GITHUB_TOKEN: ${{ github.token }}
+ GENERATED_GIF_NAMES: ${{ steps.build_gif.outputs.generated_gif_names }}
+ PRIMARY_GIF_NAME: ${{ steps.build_gif.outputs.primary_gif_name }}
+ run: |
+ set -euo pipefail
+
+ publish_root="${RUNNER_TEMP}/emoji-ui-proof-media"
+ rm -rf "${publish_root}"
+ mkdir -p "${publish_root}"
+ cd "${publish_root}"
+
+ git init
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${REPOSITORY}.git"
+
+ if git ls-remote --exit-code --heads origin "${PUBLISH_BRANCH}" > /dev/null 2>&1; then
+ git fetch --depth=1 origin "${PUBLISH_BRANCH}"
+ git checkout -B "${PUBLISH_BRANCH}" "origin/${PUBLISH_BRANCH}"
+ else
+ git checkout --orphan "${PUBLISH_BRANCH}"
+ find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
+ fi
+
+ relative_root="${PUBLISH_PREFIX}/pr-${PR_NUMBER}/run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/${SCOPE_LABEL}"
+ IFS=',' read -r -a gif_names <<< "${GENERATED_GIF_NAMES:-}"
+
+ if [ "${#gif_names[@]}" -eq 0 ] || [ -z "${gif_names[0]:-}" ]; then
+ gif_names=("$(basename "${GIF_PATH}")")
+ fi
+
+ urls=()
+ primary_url=""
+ for gif_name in "${gif_names[@]}"; do
+ [ -n "${gif_name}" ] || continue
+ source_path="${GIF_DIR}/${gif_name}"
+ if [ ! -f "${source_path}" ] && [ "${gif_name}" = "$(basename "${GIF_PATH}")" ] && [ -f "${GIF_PATH}" ]; then
+ source_path="${GIF_PATH}"
+ fi
+ if [ ! -f "${source_path}" ]; then
+ continue
+ fi
+
+ target_path="${relative_root}/${gif_name}"
+ mkdir -p "$(dirname "${target_path}")"
+ cp "${source_path}" "${target_path}"
+ git add "${target_path}"
+
+ url="https://raw.githubusercontent.com/${REPOSITORY}/${PUBLISH_BRANCH}/${target_path}"
+ urls+=("${url}")
+ if [ -n "${PRIMARY_GIF_NAME}" ] && [ "${gif_name}" = "${PRIMARY_GIF_NAME}" ]; then
+ primary_url="${url}"
+ fi
+ done
+
+ if [ "${#urls[@]}" -eq 0 ]; then
+ echo "published=false" >> "$GITHUB_OUTPUT"
+ echo "published_gif_count=0" >> "$GITHUB_OUTPUT"
+ echo "all_urls=" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ if [ -z "${primary_url}" ]; then
+ primary_url="${urls[0]}"
+ fi
+ urls_csv="$(printf '%s\n' "${urls[@]}" | paste -sd, -)"
+
+ if git diff --cached --quiet; then
+ echo "published=false" >> "$GITHUB_OUTPUT"
+ echo "published_gif_count=${#urls[@]}" >> "$GITHUB_OUTPUT"
+ echo "all_urls=${urls_csv}" >> "$GITHUB_OUTPUT"
+ else
+ git commit -m "chore: add emoji ui proof gif for run ${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+ if git push origin "${PUBLISH_BRANCH}"; then
+ echo "published=true" >> "$GITHUB_OUTPUT"
+ echo "inline_url=${primary_url}" >> "$GITHUB_OUTPUT"
+ echo "published_gif_count=${#urls[@]}" >> "$GITHUB_OUTPUT"
+ echo "all_urls=${urls_csv}" >> "$GITHUB_OUTPUT"
+ else
+ echo "Publish skipped: unable to push ${PUBLISH_BRANCH} (branch protection or token permission)." >&2
+ echo "published=false" >> "$GITHUB_OUTPUT"
+ echo "published_gif_count=0" >> "$GITHUB_OUTPUT"
+ echo "all_urls=" >> "$GITHUB_OUTPUT"
+ fi
+ fi
+
+ - name: Upload visual proof artifacts
+ id: upload_artifact
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: emoji-ui-proof-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ ${{ runner.temp }}/emoji-ui-proof.log
+ ${{ runner.temp }}/emoji-ui-proof-record.log
+ ${{ env.VIDEO_PATH }}
+ ${{ env.GIF_PATH }}
+ ${{ env.GIF_DIR }}
+ ${{ env.RESULT_BUNDLE_PATH }}
+ ${{ env.ATTACHMENTS_PATH }}
+ if-no-files-found: warn
+ retention-days: 14
+
+ - name: Create or update PR comment
+ if: always() && github.event_name == 'pull_request'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const marker = "";
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const issue_number = context.payload.pull_request.number;
+
+ const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
+ const testPassed = "${{ steps.run_ui_test.outputs.exit_code }}" === "0";
+ const statusText = testPassed ? "✅ PASSED" : "❌ FAILED";
+ const artifactUrl = "${{ steps.upload_artifact.outputs.artifact-url }}";
+ const attachmentCount = "${{ steps.export_attachments.outputs.attachment_count }}";
+ const simulatorName = "${{ steps.destination.outputs.sim_name }}";
+ const gifCreated = "${{ steps.build_gif.outputs.gif_created }}" === "true";
+ const generatedGifCount = Number("${{ steps.build_gif.outputs.generated_gif_count || '0' }}");
+ const inlineGifUrl = "${{ steps.publish_inline.outputs.inline_url }}";
+ const allGifUrlsRaw = "${{ steps.publish_inline.outputs.all_urls }}";
+ const publishedGifCount = Number("${{ steps.publish_inline.outputs.published_gif_count || '0' }}");
+ const scopeLabel = "${{ steps.scope.outputs.scope_label }}";
+ const selectedTest = scopeLabel === "all"
+ ? "${{ env.FULL_UI_TEST_TARGET }}"
+ : "${{ env.TEST_IDENTIFIER }}";
+ const allGifUrls = allGifUrlsRaw
+ ? allGifUrlsRaw.split(",").map((url) => url.trim()).filter(Boolean)
+ : [];
+ const gifMetaByKey = {
+ "hangul-emoji": {
+ title: "한글 자모 단계 입력 검증",
+ description: "받침/공백 포함 문장의 단계별 입력과 변환 진행을 검증합니다.",
+ },
+ "emoji-proof": {
+ title: "영문 실시간 변환 검증",
+ description: "문자 단위로 원문/이모지 변환이 실시간 반영되는지 검증합니다.",
+ },
+ "memo-compose": {
+ title: "메모 작성 플로우",
+ description: "멀티라인/커서 이동 포함 작성 시나리오를 검증합니다.",
+ },
+ "memo-edit-delete": {
+ title: "메모 수정/삭제 플로우",
+ description: "수정 저장과 삭제 동작이 정상 동작하는지 검증합니다.",
+ },
+ "memo-send": {
+ title: "메모 전송 플로우",
+ description: "입력 후 전송으로 신규 메모가 생성되는지 검증합니다.",
+ },
+ "memo-send-typing": {
+ title: "메모 타이핑 증적",
+ description: "전송 전 타이핑 단계의 시각 증적을 검증합니다.",
+ },
+ "memo-cancel-flow": {
+ title: "메모 취소 플로우",
+ description: "작성 중 취소 시 입력 상태/UI 전환을 검증합니다.",
+ },
+ };
+
+ const parseGifFileName = (url) => {
+ try {
+ const pathname = new URL(url).pathname;
+ return pathname.split("/").pop() ?? "";
+ } catch {
+ return url.split("/").pop() ?? "";
+ }
+ };
+
+ const toGifKey = (fileName) => fileName.replace(/\.gif$/i, "").toLowerCase();
+
+ const humanizeGifKey = (key) => {
+ if (!key) return "GIF";
+ return key
+ .split("-")
+ .filter(Boolean)
+ .map((token) => token.charAt(0).toUpperCase() + token.slice(1))
+ .join(" ");
+ };
+
+ const resolveGifMeta = (url, index) => {
+ const fileName = parseGifFileName(url);
+ const key = toGifKey(fileName);
+ const mapped = gifMetaByKey[key];
+ if (mapped) {
+ return { key, title: mapped.title, description: mapped.description };
+ }
+ return {
+ key: key || `gif-${index + 1}`,
+ title: humanizeGifKey(key),
+ description: "해당 시나리오의 시각 검증 결과",
+ };
+ };
+
+ const inlinePreviewText = allGifUrls.length > 0
+ ? allGifUrls
+ .map((url, index) => {
+ const meta = resolveGifMeta(url, index);
+ return `#### ${index + 1}. ${meta.title}\n- ${meta.description}\n`;
+ })
+ .join("\n\n")
+ : gifCreated
+ ? "_Inline GIF unavailable (media branch publish blocked by branch protection or token permission). Check artifact link._"
+ : "_Inline GIF unavailable (fork PR or GIF generation skipped)._";
+
+ const galleryText = allGifUrls.length > 0
+ ? allGifUrls
+ .map((url, index) => {
+ const meta = resolveGifMeta(url, index);
+ return `- [${index + 1}. ${meta.title}](${url})`;
+ })
+ .join("\n")
+ : "_Published GIF URLs unavailable._";
+
+ const body = `${marker}
+ ## Emoji UI Visual Proof
+ - Status: ${statusText}
+ - Simulator: \`${simulatorName}\`
+ - Test scope: \`${scopeLabel}\`
+ - Selected test target: \`${selectedTest}\`
+ - Default scope: \`all\`
+ - Focused trigger: add PR label \`ui-proof:focused\` or run \`workflow_dispatch(test_scope=focused)\`
+ - Screenshot attachments exported: **${attachmentCount}**
+ - Generated GIF files: **${generatedGifCount}**
+ - Published GIF files: **${publishedGifCount}**
+ - Animated GIF artifact: ${gifCreated ? "Included (`emoji-ui-proof.gif` + grouped GIFs)" : "Not generated"}
+ - Inline GIF URL: ${inlineGifUrl || "Unavailable"}
+ - Workflow run: ${runUrl}
+ - Artifacts: ${artifactUrl || "Unavailable"}
+
+ ### Inline GIF Previews (All)
+ ${inlinePreviewText}
+
+ ### GIF Gallery
+ ${galleryText}`;
+
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number,
+ per_page: 100,
+ });
+
+ const existing = comments.find((comment) => {
+ return comment.user?.type === "Bot" && comment.body?.includes(marker);
+ });
+
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner,
+ repo,
+ comment_id: existing.id,
+ body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number,
+ body,
+ });
+ }
+
+ - name: Cleanup generated proof files on runner
+ if: always()
+ run: |
+ set +e
+ # Artifact 업로드는 이미 끝난 상태라 러너 임시 파일은 모두 지워도 된다.
+ rm -rf \
+ "${ATTACHMENTS_PATH}" \
+ "${RESULT_BUNDLE_PATH}" \
+ "${VIDEO_PATH}" \
+ "${GIF_PATH}" \
+ "${GIF_DIR}" \
+ "${RUNNER_TEMP}/emoji-ui-proof.log" \
+ "${RUNNER_TEMP}/emoji-ui-proof-record.log"
+
+ - name: Fail if UI tests failed
+ if: steps.run_ui_test.outputs.exit_code != '0'
+ run: |
+ echo "Emoji UI proof tests failed."
+ exit 1
diff --git a/.gitignore b/.gitignore
index 6f66bb7..0e68aac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,8 @@ Package.resolved
*.plist
*.xcprivacy
.DS_Store
+.local-ci/
+.derivedData/
# Documentation artifacts
docs/
diff --git a/COMFIE.xcodeproj/project.pbxproj b/COMFIE.xcodeproj/project.pbxproj
index 1dbdd5f..ea06721 100644
--- a/COMFIE.xcodeproj/project.pbxproj
+++ b/COMFIE.xcodeproj/project.pbxproj
@@ -35,9 +35,19 @@
510340F62D777C260050C718 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 510340F02D777C260050C718 /* Preview Assets.xcassets */; };
510340F72D777C260050C718 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 510340F22D777C260050C718 /* Assets.xcassets */; };
510340F82D777C260050C718 /* COMFIEApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510340F32D777C260050C718 /* COMFIEApp.swift */; };
+ A1F001AF2F04000000ABC001 /* AccessibilityID.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001AE2F04000000ABC001 /* AccessibilityID.swift */; };
+ A1F001B82F05000000ABC001 /* MemoInputUITextView+UITest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001B72F05000000ABC001 /* MemoInputUITextView+UITest.swift */; };
+ A1F001BA2F05000000ABC001 /* MemoInputUITextView+Snapshot+UITest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001B92F05000000ABC001 /* MemoInputUITextView+Snapshot+UITest.swift */; };
+ A1F001BC2F05000000ABC001 /* MemoIMETrackingTextView+UITest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001BB2F05000000ABC001 /* MemoIMETrackingTextView+UITest.swift */; };
+ A1F001BE2F05000000ABC001 /* UITestAccessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001BD2F05000000ABC001 /* UITestAccessibility.swift */; };
+ A1F001B62F05000000ABC001 /* LocationUseCase+UITest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001B52F05000000ABC001 /* LocationUseCase+UITest.swift */; };
+ E3A8C1BF44AA4EE3A6F8C2D1 /* UITestBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C0D460A1B24C008ED21A10 /* UITestBootstrap.swift */; };
510340FC2D777C290050C718 /* COMFIETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510340FA2D777C290050C718 /* COMFIETests.swift */; };
510341002D777C2B0050C718 /* COMFIEUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510340FD2D777C2B0050C718 /* COMFIEUITests.swift */; };
510341012D777C2B0050C718 /* COMFIEUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510340FE2D777C2B0050C718 /* COMFIEUITestsLaunchTests.swift */; };
+ A1F001B22F04000000ABC001 /* COMFIEUITests+Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001B12F04000000ABC001 /* COMFIEUITests+Support.swift */; };
+ A1F001B42F04000000ABC001 /* COMFIEUITests+KeyboardSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001B32F04000000ABC001 /* COMFIEUITests+KeyboardSupport.swift */; };
+ A1F001B02F04000000ABC001 /* AccessibilityID.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001AE2F04000000ABC001 /* AccessibilityID.swift */; };
510341072D777E140050C718 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510341062D777E140050C718 /* OnboardingView.swift */; };
510341092D7782670050C718 /* OnboardingStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510341082D7782670050C718 /* OnboardingStore.swift */; };
5103410C2D77828C0050C718 /* IntentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5103410B2D77828C0050C718 /* IntentStore.swift */; };
@@ -51,6 +61,7 @@
510341272D79A59A0050C718 /* UserDefaultsConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510341262D79A59A0050C718 /* UserDefaultsConstants.swift */; };
510341292D79A5C60050C718 /* UserDefaultsError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510341282D79A5C60050C718 /* UserDefaultsError.swift */; };
5103412C2D79ABC40050C718 /* DIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5103412B2D79ABC40050C718 /* DIContainer.swift */; };
+ A1F001C22F06000000ABC001 /* DIContainer+UITest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F001C12F06000000ABC001 /* DIContainer+UITest.swift */; };
51454C172DAD03D4008EAEB0 /* ComfieZoneSettingBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51454C162DAD03D4008EAEB0 /* ComfieZoneSettingBottomSheet.swift */; };
51454C1A2DAD3A0D008EAEB0 /* AddComfieZoneCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51454C192DAD3A0D008EAEB0 /* AddComfieZoneCell.swift */; };
51454C1C2DAD3A25008EAEB0 /* ComfieZoneNameCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51454C1B2DAD3A25008EAEB0 /* ComfieZoneNameCell.swift */; };
@@ -177,9 +188,18 @@
510340F02D777C260050C718 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
510340F22D777C260050C718 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
510340F32D777C260050C718 /* COMFIEApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = COMFIEApp.swift; sourceTree = ""; };
+ A1F001AE2F04000000ABC001 /* AccessibilityID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityID.swift; sourceTree = ""; };
+ A1F001B72F05000000ABC001 /* MemoInputUITextView+UITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MemoInputUITextView+UITest.swift"; sourceTree = ""; };
+ A1F001B92F05000000ABC001 /* MemoInputUITextView+Snapshot+UITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MemoInputUITextView+Snapshot+UITest.swift"; sourceTree = ""; };
+ A1F001BB2F05000000ABC001 /* MemoIMETrackingTextView+UITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MemoIMETrackingTextView+UITest.swift"; sourceTree = ""; };
+ A1F001B52F05000000ABC001 /* LocationUseCase+UITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LocationUseCase+UITest.swift"; sourceTree = ""; };
+ A1F001BD2F05000000ABC001 /* UITestAccessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestAccessibility.swift; sourceTree = ""; };
+ D2C0D460A1B24C008ED21A10 /* UITestBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestBootstrap.swift; sourceTree = ""; };
510340FA2D777C290050C718 /* COMFIETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = COMFIETests.swift; sourceTree = ""; };
510340FD2D777C2B0050C718 /* COMFIEUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = COMFIEUITests.swift; sourceTree = ""; };
510340FE2D777C2B0050C718 /* COMFIEUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = COMFIEUITestsLaunchTests.swift; sourceTree = ""; };
+ A1F001B12F04000000ABC001 /* COMFIEUITests+Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "COMFIEUITests+Support.swift"; sourceTree = ""; };
+ A1F001B32F04000000ABC001 /* COMFIEUITests+KeyboardSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "COMFIEUITests+KeyboardSupport.swift"; sourceTree = ""; };
510341062D777E140050C718 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; };
510341082D7782670050C718 /* OnboardingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingStore.swift; sourceTree = ""; };
5103410B2D77828C0050C718 /* IntentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentStore.swift; sourceTree = ""; };
@@ -193,6 +213,7 @@
510341262D79A59A0050C718 /* UserDefaultsConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsConstants.swift; sourceTree = ""; };
510341282D79A5C60050C718 /* UserDefaultsError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsError.swift; sourceTree = ""; };
5103412B2D79ABC40050C718 /* DIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DIContainer.swift; sourceTree = ""; };
+ A1F001C12F06000000ABC001 /* DIContainer+UITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DIContainer+UITest.swift"; sourceTree = ""; };
51454C162DAD03D4008EAEB0 /* ComfieZoneSettingBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComfieZoneSettingBottomSheet.swift; sourceTree = ""; };
51454C192DAD3A0D008EAEB0 /* AddComfieZoneCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddComfieZoneCell.swift; sourceTree = ""; };
51454C1B2DAD3A25008EAEB0 /* ComfieZoneNameCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComfieZoneNameCell.swift; sourceTree = ""; };
@@ -321,11 +342,14 @@
26FB03702DAD63B300129862 /* MemoInputTextView.swift */,
C7F1A0012F0A000100ABC001 /* MemoInputContracts.swift */,
26A9EC832DE802DD0059257F /* MemoInputUITextView.swift */,
+ A1F001B72F05000000ABC001 /* MemoInputUITextView+UITest.swift */,
9F79E96D20504ACFA322F680 /* MemoInputUITextView+Coordinator.swift */,
897F6CFBB90640B59332AFB2 /* MemoInputUITextView+IME.swift */,
CBC5FF8697B24E0783BB9F50 /* MemoInputUITextView+Snapshot.swift */,
+ A1F001B92F05000000ABC001 /* MemoInputUITextView+Snapshot+UITest.swift */,
195B10EA2A6C46CAA670BA4C /* MemoInputUITextView+TextViewLayout.swift */,
1240A38C406B4D70A9B72CCC /* MemoIMETrackingTextView.swift */,
+ A1F001BB2F05000000ABC001 /* MemoIMETrackingTextView+UITest.swift */,
6BBFC1CC93364600AB0B2088 /* MemoEmojiTokenAttachment.swift */,
);
path = MemoInput;
@@ -436,6 +460,8 @@
children = (
510340FD2D777C2B0050C718 /* COMFIEUITests.swift */,
510340FE2D777C2B0050C718 /* COMFIEUITestsLaunchTests.swift */,
+ A1F001B12F04000000ABC001 /* COMFIEUITests+Support.swift */,
+ A1F001B32F04000000ABC001 /* COMFIEUITests+KeyboardSupport.swift */,
);
path = COMFIEUITests;
sourceTree = "";
@@ -446,6 +472,8 @@
5103412A2D79ABB60050C718 /* DIContainer */,
510341122D7993A90050C718 /* Router */,
510340F32D777C260050C718 /* COMFIEApp.swift */,
+ A1F001AE2F04000000ABC001 /* AccessibilityID.swift */,
+ A1F001BD2F05000000ABC001 /* UITestAccessibility.swift */,
D2C0D460A1B24C008ED21A10 /* UITestBootstrap.swift */,
510341132D7993B80050C718 /* COMFIERoutingView.swift */,
);
@@ -552,6 +580,7 @@
isa = PBXGroup;
children = (
5103412B2D79ABC40050C718 /* DIContainer.swift */,
+ A1F001C12F06000000ABC001 /* DIContainer+UITest.swift */,
);
path = DIContainer;
sourceTree = "";
@@ -595,14 +624,15 @@
path = ComfieZoneRepository;
sourceTree = "";
};
- 518666F12DC0C3830078C6B4 /* UseCase */ = {
- isa = PBXGroup;
- children = (
- 518666F22DC0C3900078C6B4 /* LocationUseCase.swift */,
- );
- path = UseCase;
- sourceTree = "";
- };
+ 518666F12DC0C3830078C6B4 /* UseCase */ = {
+ isa = PBXGroup;
+ children = (
+ 518666F22DC0C3900078C6B4 /* LocationUseCase.swift */,
+ A1F001B52F05000000ABC001 /* LocationUseCase+UITest.swift */,
+ );
+ path = UseCase;
+ sourceTree = "";
+ };
51895ADA2DA69B0100AFA569 /* More */ = {
isa = PBXGroup;
children = (
@@ -988,11 +1018,14 @@
51454C1A2DAD3A0D008EAEB0 /* AddComfieZoneCell.swift in Sources */,
26A9EC842DE802DD0059257F /* MemoInputUITextView.swift in Sources */,
C7F1A0022F0A000100ABC001 /* MemoInputContracts.swift in Sources */,
+ A1F001B82F05000000ABC001 /* MemoInputUITextView+UITest.swift in Sources */,
4F4074A4D6734A29850C61B9 /* MemoInputUITextView+Coordinator.swift in Sources */,
724105E8C4EB45DB80342FF4 /* MemoInputUITextView+IME.swift in Sources */,
0CEE0DFE05B04205B1C99B8E /* MemoInputUITextView+Snapshot.swift in Sources */,
+ A1F001BA2F05000000ABC001 /* MemoInputUITextView+Snapshot+UITest.swift in Sources */,
F5DAF3CE9914431E80CE53AD /* MemoInputUITextView+TextViewLayout.swift in Sources */,
2593651730AF440880F21A3F /* MemoIMETrackingTextView.swift in Sources */,
+ A1F001BC2F05000000ABC001 /* MemoIMETrackingTextView+UITest.swift in Sources */,
80BBE641EB024D18BF2D15A7 /* MemoEmojiTokenAttachment.swift in Sources */,
26D697B12D7ECA6200AC200C /* UserRecordModel.xcdatamodeld in Sources */,
51895AEA2DA6B17700AFA569 /* StringLiterals+More.swift in Sources */,
@@ -1006,9 +1039,10 @@
510341272D79A59A0050C718 /* UserDefaultsConstants.swift in Sources */,
B96CDB3C2DA238EA004EA2E9 /* EdgeInsets+.swift in Sources */,
51895AF02DA6C65100AFA569 /* TermView.swift in Sources */,
- 517FCAC32D7AB2B100A250A3 /* Memo.swift in Sources */,
- 518666F32DC0C3900078C6B4 /* LocationUseCase.swift in Sources */,
- 51F8F5B72D95259300DD2E3D /* CFNavigationBar.swift in Sources */,
+ 517FCAC32D7AB2B100A250A3 /* Memo.swift in Sources */,
+ 518666F32DC0C3900078C6B4 /* LocationUseCase.swift in Sources */,
+ A1F001B62F05000000ABC001 /* LocationUseCase+UITest.swift in Sources */,
+ 51F8F5B72D95259300DD2E3D /* CFNavigationBar.swift in Sources */,
26D697B32D7EE39E00AC200C /* CoreDataService.swift in Sources */,
B99A081E2DABAD410094EECB /* RetrospectionRepository.swift in Sources */,
B96CDB422DA240B2004EA2E9 /* RetrospectionStore.swift in Sources */,
@@ -1021,6 +1055,8 @@
B9EA53112D7CA5DC00A32305 /* StringLiterals.swift in Sources */,
B99A081C2DABABA80094EECB /* RetrospectionRepositoryProtocol.swift in Sources */,
510340F82D777C260050C718 /* COMFIEApp.swift in Sources */,
+ A1F001AF2F04000000ABC001 /* AccessibilityID.swift in Sources */,
+ A1F001BE2F05000000ABC001 /* UITestAccessibility.swift in Sources */,
E3A8C1BF44AA4EE3A6F8C2D1 /* UITestBootstrap.swift in Sources */,
51895AF62DA8FBF500AFA569 /* MakersView.swift in Sources */,
51F8F5B02D91ACF000DD2E3D /* StringLiterals_Onboarding.swift in Sources */,
@@ -1034,9 +1070,10 @@
51D882E42DAFF4150072E3C0 /* LocalAuthenticationService.swift in Sources */,
517FCAC52D7AB31100A250A3 /* ComfieZone.swift in Sources */,
51F8F5B42D92921900DD2E3D /* ComfieZoneSettingView.swift in Sources */,
- 51895ADF2DA6AE6F00AFA569 /* CFList.swift in Sources */,
- 5103412C2D79ABC40050C718 /* DIContainer.swift in Sources */,
- );
+ 51895ADF2DA6AE6F00AFA569 /* CFList.swift in Sources */,
+ 5103412C2D79ABC40050C718 /* DIContainer.swift in Sources */,
+ A1F001C22F06000000ABC001 /* DIContainer+UITest.swift in Sources */,
+ );
runOnlyForDeploymentPostprocessing = 0;
};
510340B92D776EE90050C718 /* Sources */ = {
@@ -1059,6 +1096,9 @@
files = (
510341002D777C2B0050C718 /* COMFIEUITests.swift in Sources */,
510341012D777C2B0050C718 /* COMFIEUITestsLaunchTests.swift in Sources */,
+ A1F001B22F04000000ABC001 /* COMFIEUITests+Support.swift in Sources */,
+ A1F001B42F04000000ABC001 /* COMFIEUITests+KeyboardSupport.swift in Sources */,
+ A1F001B02F04000000ABC001 /* AccessibilityID.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/COMFIE/App/AccessibilityID.swift b/COMFIE/App/AccessibilityID.swift
new file mode 100644
index 0000000..4817dff
--- /dev/null
+++ b/COMFIE/App/AccessibilityID.swift
@@ -0,0 +1,38 @@
+//
+// AccessibilityID.swift
+// COMFIE
+//
+// Created by zaehorang on 2/13/26.
+//
+
+enum AccessibilityID {
+ // 앱 타깃과 UITest 타깃이 같은 식별자 문자열을 공유하도록 중앙 관리한다.
+ enum Memo {
+ static let comfieZoneSettingButton = "memo.comfieZoneSettingButton"
+ static let moreButton = "memo.moreButton"
+ static let sendButton = "memo.sendButton"
+ static let editingCancelButton = "memo.editingCancelButton"
+
+ static let inputTextView = "memo.inputTextView"
+ static let cellContentText = "memo.cell.contentText"
+ static let cellMenuButton = "memo.cell.menuButton"
+ static let cellMenuEditButton = "memo.cell.menu.editButton"
+ static let cellMenuRetrospectionButton = "memo.cell.menu.retrospectionButton"
+ static let cellMenuDeleteButton = "memo.cell.menu.deleteButton"
+ }
+
+ enum Popup {
+ static let leftButton = "popup.leftButton"
+ static let rightButton = "popup.rightButton"
+ }
+}
+
+enum UITestLaunchArgument: String {
+ // Debug 빌드에서 UITestBootstrap이 해석하는 런치 인자 목록이다.
+ case uiTesting = "-ui-testing"
+ case draftDebug = "-ui-testing-draft-debug"
+ case forceInsideComfieZone = "-ui-testing-force-inside-comfie-zone"
+ case forceOutsideComfieZone = "-ui-testing-force-outside-comfie-zone"
+ case forceASCIIKeyboard = "-ui-testing-force-ascii-keyboard"
+ case forceKoreanKeyboard = "-ui-testing-force-korean-keyboard"
+}
diff --git a/COMFIE/App/DIContainer/DIContainer+UITest.swift b/COMFIE/App/DIContainer/DIContainer+UITest.swift
new file mode 100644
index 0000000..e186c42
--- /dev/null
+++ b/COMFIE/App/DIContainer/DIContainer+UITest.swift
@@ -0,0 +1,23 @@
+//
+// DIContainer+UITest.swift
+// COMFIE
+//
+// Created by zaehorang on 2/14/26.
+//
+
+#if DEBUG
+
+extension DIContainer {
+ func makeLocationUseCase() -> LocationUseCase {
+ // Debug에서도 기본은 릴리즈 경로를 사용하고, UITest일 때만 테스트 전용 구현을 주입한다.
+ guard UITestBootstrap.isUITesting() else {
+ return makeReleaseLocationUseCase()
+ }
+ return UITestLocationUseCase(
+ locationService: makeLocationService,
+ comfiZoneRepository: comfieZoneRepository
+ )
+ }
+}
+
+#endif
diff --git a/COMFIE/App/DIContainer/DIContainer.swift b/COMFIE/App/DIContainer/DIContainer.swift
index 011cdac..e0c590c 100644
--- a/COMFIE/App/DIContainer/DIContainer.swift
+++ b/COMFIE/App/DIContainer/DIContainer.swift
@@ -23,12 +23,19 @@ class DIContainer {
lazy var makeLocationService: LocationService = { LocationService() }()
// MARK: - UseCase
- private func makeLocationUseCase() -> LocationUseCase {
+ func makeReleaseLocationUseCase() -> LocationUseCase {
+ // 릴리즈 기준 기본 LocationUseCase 생성 경로를 분리해 둔다.
LocationUseCase(
locationService: makeLocationService,
comfiZoneRepository: comfieZoneRepository
)
}
+
+#if !DEBUG
+ func makeLocationUseCase() -> LocationUseCase {
+ makeReleaseLocationUseCase()
+ }
+#endif
// MARK: - Intent
private func makeOnboardingIntent() -> OnboardingStore {
diff --git a/COMFIE/App/UITestAccessibility.swift b/COMFIE/App/UITestAccessibility.swift
new file mode 100644
index 0000000..b29c4f2
--- /dev/null
+++ b/COMFIE/App/UITestAccessibility.swift
@@ -0,0 +1,40 @@
+//
+// UITestAccessibility.swift
+// COMFIE
+//
+// Created by zaehorang on 2/14/26.
+//
+
+import SwiftUI
+import UIKit
+
+enum UITestAccessibility {
+ static var isEnabled: Bool {
+ UITestBootstrap.isUITesting()
+ }
+}
+
+extension View {
+ @ViewBuilder
+ func uiTestAccessibilityIdentifier(_ identifier: String) -> some View {
+ #if DEBUG
+ // 일반 실행에서는 숨기고, UITest 세션에서만 식별자를 노출한다.
+ if UITestAccessibility.isEnabled {
+ accessibilityIdentifier(identifier)
+ } else {
+ self
+ }
+ #else
+ self
+ #endif
+ }
+}
+
+extension UIView {
+ func applyUITestAccessibilityIdentifier(_ identifier: String) {
+ #if DEBUG
+ guard UITestAccessibility.isEnabled else { return }
+ accessibilityIdentifier = identifier
+ #endif
+ }
+}
diff --git a/COMFIE/App/UITestBootstrap.swift b/COMFIE/App/UITestBootstrap.swift
index 67ac02d..61520f2 100644
--- a/COMFIE/App/UITestBootstrap.swift
+++ b/COMFIE/App/UITestBootstrap.swift
@@ -7,21 +7,36 @@
import Foundation
-#if DEBUG
enum UITestBootstrap {
- private static let uiTestingArgument = "-ui-testing"
+ static func hasLaunchArgument(
+ _ argument: UITestLaunchArgument,
+ processInfo: ProcessInfo = .processInfo
+ ) -> Bool {
+#if DEBUG
+ processInfo.arguments.contains(argument.rawValue)
+#else
+ _ = argument
+ _ = processInfo
+ return false
+#endif
+ }
+
+ static func isUITesting(processInfo: ProcessInfo = .processInfo) -> Bool {
+ hasLaunchArgument(.uiTesting, processInfo: processInfo)
+ }
static func applyIfNeeded(router: Router, userDefaults: UserDefaults = .standard) {
- guard ProcessInfo.processInfo.arguments.contains(uiTestingArgument) else { return }
+#if DEBUG
+ // UITest일 때만 초기 라우팅 상태를 고정하고, 일반 실행 흐름은 건드리지 않는다.
+ guard isUITesting() else { return }
userDefaults.set(true, forKey: UserDefaultsConstants.Keys.hasEverOnboarded.rawValue)
userDefaults.set(true, forKey: UserDefaultsConstants.Keys.hasSeenTutorial.rawValue)
router.hasEverOnboarded = true
router.isLoadingViewFinished = true
- }
-}
#else
-enum UITestBootstrap {
- static func applyIfNeeded(router _: Router, userDefaults _: UserDefaults = .standard) {}
-}
+ _ = router
+ _ = userDefaults
#endif
+ }
+}
diff --git a/COMFIE/Domain/UseCase/LocationUseCase+UITest.swift b/COMFIE/Domain/UseCase/LocationUseCase+UITest.swift
new file mode 100644
index 0000000..658e18f
--- /dev/null
+++ b/COMFIE/Domain/UseCase/LocationUseCase+UITest.swift
@@ -0,0 +1,36 @@
+//
+// LocationUseCase+UITest.swift
+// COMFIE
+//
+// Created by zaehorang on 2/14/26.
+//
+
+import CoreLocation
+import Foundation
+
+#if DEBUG
+
+final class UITestLocationUseCase: LocationUseCase {
+ override func isInComfieZone(_ location: CLLocation?) -> Bool {
+ // UITest 강제값이 있으면 우선 적용하고, 없으면 실제 지오펜스 계산을 그대로 따른다.
+ if let overrideResult = UITestBootstrap.resolveComfieZoneOverride() {
+ return overrideResult
+ }
+ return super.isInComfieZone(location)
+ }
+}
+
+extension UITestBootstrap {
+ static func resolveComfieZoneOverride(processInfo: ProcessInfo = .processInfo) -> Bool? {
+ guard isUITesting(processInfo: processInfo) else { return nil }
+ if hasLaunchArgument(.forceInsideComfieZone, processInfo: processInfo) {
+ return true
+ }
+ if hasLaunchArgument(.forceOutsideComfieZone, processInfo: processInfo) {
+ return false
+ }
+ return nil
+ }
+}
+
+#endif
diff --git a/COMFIE/Domain/UseCase/LocationUseCase.swift b/COMFIE/Domain/UseCase/LocationUseCase.swift
index 44cf971..c27b500 100644
--- a/COMFIE/Domain/UseCase/LocationUseCase.swift
+++ b/COMFIE/Domain/UseCase/LocationUseCase.swift
@@ -42,7 +42,7 @@ class LocationUseCase {
if let comfieZone, let location {
let comfieZoneLocation = CLLocation(latitude: comfieZone.latitude, longitude: comfieZone.longitude)
let userComfieZoneDistance = location.distance(from: comfieZoneLocation)
- let isInComfieZone = userComfieZoneDistance <= ComfieZoneConstant.comfieZoneRadius // 컴피존 반경
+ let isInComfieZone = userComfieZoneDistance <= ComfieZoneConstant.comfieZoneRadius
return isInComfieZone
} else {
return false
diff --git a/COMFIE/Presentation/Memo/MemoCell.swift b/COMFIE/Presentation/Memo/MemoCell.swift
index fb1784e..332a625 100644
--- a/COMFIE/Presentation/Memo/MemoCell.swift
+++ b/COMFIE/Presentation/Memo/MemoCell.swift
@@ -19,6 +19,7 @@ struct MemoCell: View {
let isUserInComfieZone: Bool
private var isEditing: Bool {
+
intent.state.isEditingMemo(memo)
}
@@ -35,6 +36,7 @@ struct MemoCell: View {
HStack(spacing: 0) {
Button {
+
withAnimation(.easeIn(duration: 0.2)) {
isMemoHidden.toggle()
}
@@ -57,7 +59,6 @@ struct MemoCell: View {
.disabled(!hasRetrospection)
Spacer()
-
menuButton
}
.padding(.bottom, 4)
@@ -66,6 +67,8 @@ struct MemoCell: View {
Text(isUserInComfieZone ? memo.originalText : memo.emojiText)
.comfieFont(.body)
.foregroundStyle(Color.textBlack)
+ // 릴리즈에서는 no-op이고, Debug UITest 세션에서만 식별자를 부여한다.
+ .uiTestAccessibilityIdentifier(AccessibilityID.Memo.cellContentText)
}
if let originalRetrospectionText = memo.originalRetrospectionText,
@@ -96,6 +99,7 @@ struct MemoCell: View {
} label: {
Label(strings.editButtonTitle.localized, systemImage: "pencil")
}
+ .uiTestAccessibilityIdentifier(AccessibilityID.Memo.cellMenuEditButton)
} else {
Button {
intent(.memoCell(.retrospectionButtonTapped(memo)))
@@ -103,6 +107,7 @@ struct MemoCell: View {
Label(strings.retrospectionButtonTitle.localized, systemImage: "ellipsis.message")
.foregroundStyle(.red)
}
+ .uiTestAccessibilityIdentifier(AccessibilityID.Memo.cellMenuRetrospectionButton)
}
Button(role: .destructive) {
@@ -110,13 +115,14 @@ struct MemoCell: View {
} label: {
Label(strings.deleteButtonTitle.localized, systemImage: "trash")
}
+ .uiTestAccessibilityIdentifier(AccessibilityID.Memo.cellMenuDeleteButton)
} label: {
Image(.icEllipsis)
.resizable()
.frame(width: 19, height: 20)
}
- .accessibilityIdentifier("memo.cell.menuButton")
+ .uiTestAccessibilityIdentifier(AccessibilityID.Memo.cellMenuButton)
}
}
diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift b/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift
index 2543bd3..8e86ba8 100644
--- a/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift
+++ b/COMFIE/Presentation/Memo/MemoInput/MemoEmojiTokenAttachment.swift
@@ -9,48 +9,35 @@ import UIKit
// 텍스트뷰 안에서 "원문 1글자 + 이모지 1글자"를 같이 들고 다니는 토큰입니다.
final class MemoEmojiTokenAttachment: NSTextAttachment {
- // 토큰이 나타내는 원문 글자입니다.
let original: String
- // 화면에 보여줄 이모지 글자입니다.
let emoji: String
- // 토큰을 만들 때 원문/이모지 쌍과 폰트를 함께 받아 이미지까지 준비합니다.
+ // 원문/이모지 쌍으로 attachment를 구성하고 렌더링 크기와 baseline을 고정합니다.
init(original: String, emoji: String, font: UIFont) {
- // 스냅샷 복원을 위해 원문 값을 보관합니다.
self.original = original
- // 스냅샷 복원을 위해 이모지 값을 보관합니다.
self.emoji = emoji
- // NSTextAttachment 기본 초기화를 먼저 수행합니다.
super.init(data: nil, ofType: nil)
- // 폰트 기준으로 이모지 렌더 크기를 계산합니다.
let emojiString = NSAttributedString(string: emoji, attributes: [.font: font])
let textSize = emojiString.size()
- // 폭이 0이 되면 토큰이 깨질 수 있으니 최소 1을 보장합니다.
let width = max(1, ceil(textSize.width))
- // 높이는 라인 높이에 맞춰 caret/선택 동작을 안정화합니다.
let height = ceil(font.lineHeight)
let size = CGSize(width: width, height: height)
- // 실제 텍스트 대신 표시될 이모지 이미지를 렌더링합니다.
image = Self.renderEmojiImage(emoji, in: size, font: font)
- // baseline 정렬을 맞추기 위해 descender를 반영합니다.
bounds = CGRect(x: 0, y: font.descender, width: width, height: height)
}
- // 현재 구현에서는 아카이브 복원을 지원하지 않습니다.
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
- // 이모지 문자열을 attachment 이미지로 그려서 반환합니다.
+ // 이모지를 텍스트와 같은 폰트 기준으로 이미지화해 attachment에 맞춰 그립니다.
private static func renderEmojiImage(_ emoji: String, in size: CGSize, font: UIFont) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
- // 렌더링 때도 동일한 폰트를 써서 텍스트/토큰 높이 차이를 줄입니다.
let attributes: [NSAttributedString.Key: Any] = [.font: font]
let attributed = NSAttributedString(string: emoji, attributes: attributes)
let textSize = attributed.size()
- // 토큰 영역 가운데에 맞춰 그립니다.
let origin = CGPoint(
x: max(0, (size.width - textSize.width) * 0.5),
y: max(0, (size.height - textSize.height) * 0.5)
diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView+UITest.swift b/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView+UITest.swift
new file mode 100644
index 0000000..1ab7fc7
--- /dev/null
+++ b/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView+UITest.swift
@@ -0,0 +1,33 @@
+//
+// MemoIMETrackingTextView+UITest.swift
+// COMFIE
+//
+// Created by zaehorang on 2/14/26.
+//
+
+import UIKit
+
+#if DEBUG
+
+final class MemoUITestIMETrackingTextView: MemoIMETrackingTextView {
+ var uiTestPreferredPrimaryLanguage: String?
+
+ override var textInputMode: UITextInputMode? {
+ // 런치 인자로 요청된 경우 키보드 언어를 고정해 레이아웃 흔들림을 줄인다.
+ guard UITestBootstrap.isUITesting(),
+ let preferredLanguage = uiTestPreferredPrimaryLanguage else {
+ return super.textInputMode
+ }
+
+ if let preferredMode = UITextInputMode.activeInputModes.first(where: { mode in
+ guard let primaryLanguage = mode.primaryLanguage else { return false }
+ return primaryLanguage.hasPrefix(preferredLanguage)
+ }) {
+ return preferredMode
+ }
+
+ return super.textInputMode
+ }
+}
+
+#endif
diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift b/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift
index cac76a5..3e32778 100644
--- a/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift
+++ b/COMFIE/Presentation/Memo/MemoInput/MemoIMETrackingTextView.swift
@@ -8,22 +8,19 @@
import UIKit
// IME(한글/중국어 조합 입력) 상태 변화를 콜백으로 전달하는 UITextView입니다.
-final class MemoIMETrackingTextView: UITextView {
- // 조합 입력 구간이 생겼을 때 호출할 콜백입니다.
+class MemoIMETrackingTextView: UITextView {
var onSetMarkedText: ((NSRange) -> Void)?
- // 조합 입력이 확정되어 marked 상태가 해제됐을 때 호출할 콜백입니다.
var onUnmarkText: (() -> Void)?
- // 시스템이 marked text를 설정할 때 우리 로직도 함께 실행합니다.
+ // marked text가 생기는 즉시 범위를 Coordinator에 전달해 IME 경계를 추적합니다.
override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
super.setMarkedText(markedText, selectedRange: selectedRange)
- // 현재 marked 범위가 있으면 NSRange로 변환해 상위 코디네이터로 보냅니다.
if let range = markedTextRange {
onSetMarkedText?(memoIME_nsRange(from: range))
}
}
- // 조합 입력이 끝날 때를 감지해 후처리를 트리거합니다.
+ // 조합 입력이 확정되는 시점에 후처리 콜백을 실행합니다.
override func unmarkText() {
super.unmarkText()
onUnmarkText?()
@@ -31,11 +28,9 @@ final class MemoIMETrackingTextView: UITextView {
}
extension UITextView {
- // UITextRange를 NSRange로 변환해 배열/스토리지 인덱스 연산에 바로 쓰게 해줍니다.
+ // UITextRange를 NSRange로 변환해 textStorage 인덱스 연산에 사용합니다.
func memoIME_nsRange(from textRange: UITextRange) -> NSRange {
- // 문서 시작점부터 시작 위치까지의 오프셋이 NSRange.location입니다.
let location = offset(from: beginningOfDocument, to: textRange.start)
- // 시작점부터 끝점까지의 오프셋이 NSRange.length입니다.
let length = offset(from: textRange.start, to: textRange.end)
return NSRange(location: location, length: length)
}
diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputContracts.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputContracts.swift
index 0b4d152..7c55f6c 100644
--- a/COMFIE/Presentation/Memo/MemoInput/MemoInputContracts.swift
+++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputContracts.swift
@@ -9,56 +9,40 @@ import Foundation
// Memo 입력창을 다시 그릴 때 사용하는 seed 스냅샷입니다.
struct MemoInputSeed: Equatable {
- // seed 변경을 감지하기 위한 증가 토큰입니다.
var token: Int
- // seed 기준 원문 문자열입니다.
var originalText: String
- // seed 기준 이모지 문자열입니다.
var emojiText: String
- // 입력 초기 상태를 나타내는 기본값입니다.
static let empty = MemoInputSeed(token: 0, originalText: "", emojiText: "")
}
// 저장 직전에 Coordinator가 보내는 최종 입력 스냅샷입니다.
struct MemoInputSnapshot: Equatable {
- // 최종 원문 문자열
let originalText: String
- // 최종 이모지 문자열
let emojiText: String
}
// InputView -> Store 단방향 출력 이벤트입니다.
enum MemoInputOutputEvent: Equatable {
- // 현재 draft가 비어 있는지 알림
case draftAvailabilityChanged(isEmpty: Bool)
- // 최종 스냅샷 동기화 완료 알림
case finalSnapshotReady(requestID: UUID, snapshot: MemoInputSnapshot)
- // 최종 스냅샷 동기화 실패 알림
case finalSnapshotFailed(requestID: UUID)
}
// Store -> InputView 단방향 UI 명령입니다.
enum MemoInputUICommand: Equatable {
- // 포커스를 내리기 전에 입력 동기화까지 수행
case resignWithSync
- // 입력 동기화 없이 포커스만 내림
case resignWithoutSync
- // 최종 스냅샷 동기화를 요청한 뒤 포커스 내림
case requestFinalSyncAndResign(requestID: UUID)
- // 입력창 포커스 요청
case setFocus
}
// 동일 명령 중복 실행을 막기 위해 UUID를 함께 묶은 이벤트 래퍼입니다.
struct MemoInputUIEvent: Equatable {
- // 이벤트 고유 ID
let id: UUID
- // 실행할 실제 명령
let command: MemoInputUICommand
init(command: MemoInputUICommand) {
- // 이벤트를 새로 만들 때마다 고유 ID를 생성합니다.
self.id = UUID()
self.command = command
}
diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift
index 40bdd34..79489b7 100644
--- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift
+++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Coordinator.swift
@@ -31,7 +31,6 @@ extension MemoInputUITextView {
var isMutating = false
private(set) var pendingChange: PendingChange?
- // 조합이 끝난 뒤(handleUnmark) 안전하게 처리하려고 사용합니다.
private(set) var deferredChange: PendingChange?
var lastSelectionRange = NSRange(location: 0, length: 0)
var lastTextChangeTime: TimeInterval = 0
@@ -44,6 +43,7 @@ extension MemoInputUITextView {
private(set) var draftOriginalText = ""
private(set) var draftEmojiText = ""
+ private(set) var draftRevision = 0
var isEmojiMode: Bool {
parent.isEmojiPresentationEnabled
@@ -57,6 +57,10 @@ extension MemoInputUITextView {
}
func replaceDraft(original: String, emoji: String) {
+ let hasChanged = draftOriginalText != original || draftEmojiText != emoji
+ if hasChanged {
+ draftRevision += 1
+ }
draftOriginalText = original
draftEmojiText = emoji
}
@@ -104,6 +108,7 @@ extension MemoInputUITextView {
}
#endif
+ // seed/모드/명령 이벤트를 합쳐 현재 UITextView 상태를 일관되게 재적용합니다.
func applyStateToTextView(force: Bool) {
handleUICommandIfNeeded()
guard let textView else { return }
@@ -133,7 +138,7 @@ extension MemoInputUITextView {
updateTextViewHeight(textView)
return
}
- // IME 조합 중에는 강제 모드 렌더링으로 조합을 깨지 않도록 대기합니다.
+ // 조합 중에는 모드 강제 렌더를 미뤄 IME 입력이 깨지지 않게 보호합니다.
guard textView.markedTextRange == nil else { return }
clearPendingAndDeferredChanges()
@@ -176,7 +181,6 @@ extension MemoInputUITextView {
}
if isEmojiMode {
- // true면 한글 IME 조합 중이라는 뜻입니다.
let isComposing = (textView.markedTextRange != nil)
if isComposing {
deferPendingChangeIfNeeded()
@@ -227,7 +231,7 @@ extension MemoInputUITextView {
guard isEmojiMode else { return }
guard !isMutating else { return }
- // IME 조합 중 선택 변화는 무시합니다.
+ // 조합 중 커서 이동 이벤트는 flush 기준이 아니므로 건너뜁니다.
guard textView.markedTextRange == nil else { return }
guard !NSEqualRanges(lastSelectionRange, textView.selectedRange) else { return }
@@ -239,7 +243,7 @@ extension MemoInputUITextView {
}
}
- // MARK: - UI Command
+// MARK: - UI Command
extension MemoInputUITextView.Coordinator {
private func handleUICommandIfNeeded() {
guard let commandEvent = parent.uiCommandEvent else { return }
@@ -248,6 +252,7 @@ extension MemoInputUITextView.Coordinator {
handleUICommand(commandEvent.command)
}
+ // Store에서 내려온 입력 명령을 한 번만 실행하고 sync 정책을 함께 조정합니다.
private func handleUICommand(_ command: MemoInputUICommand) {
switch command {
case .resignWithSync:
diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift
index 9abb5d2..d4892b6 100644
--- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift
+++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+IME.swift
@@ -9,7 +9,6 @@ import UIKit
extension MemoInputUITextView.Coordinator {
// MARK: - IME Handling
- // IME 변환 흐름 지도(요약):
// marked 구간이 생겼을 때 조합 시작점 이전 글자만 안전하게 토큰화합니다.
func handleMarkedRange(in textView: UITextView, marked: NSRange) {
@@ -63,6 +62,7 @@ extension MemoInputUITextView.Coordinator {
lastTextLength = storage.length
}
+ // 조합이 끝난 실제 입력 범위를 찾아 attachment 토큰 변환을 적용합니다.
func handleNonMarkedChange(in textView: UITextView, change: PendingChange) {
guard change.replacementUTF16Length > 0 else { return }
@@ -142,6 +142,7 @@ extension MemoInputUITextView.Coordinator {
// MARK: - Mode Conversion
+ // 일반 텍스트 모드를 이모지 표시 모드로 일괄 변환합니다.
func convertAllPlainToEmoji(in textView: UITextView) {
let storage = textView.textStorage
guard storage.length > 0 else { return }
@@ -173,6 +174,7 @@ extension MemoInputUITextView.Coordinator {
lastTextLength = storage.length
}
+ // attachment 토큰을 원문 문자열로 되돌려 일반 텍스트 모드를 복원합니다.
func convertAllToPlain(in textView: UITextView) {
let currentSnapshot = snapshot(from: textView.textStorage)
let oldSelection = textView.selectedRange
diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot+UITest.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot+UITest.swift
new file mode 100644
index 0000000..6fb880e
--- /dev/null
+++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot+UITest.swift
@@ -0,0 +1,44 @@
+//
+// MemoInputUITextView+Snapshot+UITest.swift
+// COMFIE
+//
+// Created by zaehorang on 2/14/26.
+//
+
+import UIKit
+
+#if DEBUG
+
+extension MemoInputUITextView.Coordinator {
+ private var shouldExposeUITestDraftDebug: Bool {
+ UITestBootstrap.isUITesting()
+ && UITestBootstrap.hasLaunchArgument(.draftDebug)
+ }
+
+ func publishDebugSnapshotIfNeeded(_ textView: UITextView) {
+ // UITest 검증을 위해서만 draft 스냅샷을 accessibilityValue로 노출한다.
+ guard shouldExposeUITestDraftDebug else { return }
+
+ let payload: [String: Any] = [
+ "original": draftOriginalText,
+ "emoji": draftEmojiText,
+ "revision": draftRevision
+ ]
+ guard JSONSerialization.isValidJSONObject(payload),
+ let data = try? JSONSerialization.data(withJSONObject: payload),
+ let json = String(data: data, encoding: .utf8) else {
+ return
+ }
+ textView.accessibilityValue = json
+ }
+}
+
+#else
+
+extension MemoInputUITextView.Coordinator {
+ func publishDebugSnapshotIfNeeded(_ textView: UITextView) {
+ _ = textView
+ }
+}
+
+#endif
diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift
index 8fc0abf..7894bcc 100644
--- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift
+++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+Snapshot.swift
@@ -24,6 +24,7 @@ extension MemoInputUITextView.Coordinator {
return originalText
}
+ // 현재 textStorage를 draft로 동기화하고 Store에 입력 가능 상태를 알립니다.
func syncSnapshotToStore(_ textView: UITextView) {
let currentSnapshot = snapshot(from: textView.textStorage)
let emojiTextCandidate = isEmojiMode ? currentSnapshot.emoji : currentSnapshot.original
@@ -33,11 +34,12 @@ extension MemoInputUITextView.Coordinator {
emojiTextCandidate: emojiTextCandidate
)
publishDraftAvailability()
+ // +Snapshot+UITest 확장에서만 동작하고 그 외 빌드에서는 no-op입니다.
+ publishDebugSnapshotIfNeeded(textView)
}
+ // textView가 없는 생명주기 경계에서도 seed 기반 draft를 복구해 savePhase 고착을 막습니다.
func syncDraftFromFallbackIfNeeded() {
- // 주로 생명주기 경계(textView=nil)에서 final sync 요청이 들어왔을 때 사용됩니다.
- // 이때도 seed 기준으로 일관된 스냅샷을 만들 수 있어 savePhase 고착을 막을 수 있습니다.
let originalText = normalizedOriginalText()
let emojiText = normalizedEmojiText(with: originalText)
@@ -71,6 +73,7 @@ extension MemoInputUITextView.Coordinator {
return (original, emoji)
}
+ // 원문/이모지 스냅샷을 현재 모드(UIText/Attachment)에 맞춰 UITextView에 렌더링합니다.
func render(_ textView: UITextView, originalText: String, emojiText: String) {
let oldSelection = textView.selectedRange
isMutating = true
@@ -92,6 +95,8 @@ extension MemoInputUITextView.Coordinator {
updateTextViewHeight(textView)
lastTextLength = textView.textStorage.length
lastSelectionRange = textView.selectedRange
+ // +Snapshot+UITest 확장에서만 동작하고 그 외 빌드에서는 no-op입니다.
+ publishDebugSnapshotIfNeeded(textView)
}
func attributedText(originalText: String, emojiText: String, font: UIFont) -> NSAttributedString {
diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift
index c59938d..8ca9cf9 100644
--- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift
+++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+TextViewLayout.swift
@@ -14,6 +14,7 @@ extension MemoInputUITextView.Coordinator {
placeholderLabel.isHidden = !textView.textStorage.string.isEmpty
}
+ // 텍스트 높이를 최대 4줄 범위로 계산하고 스크롤/SwiftUI 높이를 함께 동기화합니다.
func updateTextViewHeight(_ textView: UITextView) {
let width = textView.bounds.width
guard width > 0 else { return }
diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+UITest.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+UITest.swift
new file mode 100644
index 0000000..ed91ac8
--- /dev/null
+++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView+UITest.swift
@@ -0,0 +1,49 @@
+//
+// MemoInputUITextView+UITest.swift
+// COMFIE
+//
+// Created by zaehorang on 2/14/26.
+//
+
+import UIKit
+
+#if DEBUG
+
+extension MemoInputUITextView {
+ func createUITestTextViewIfNeeded() -> MemoIMETrackingTextView? {
+ guard UITestBootstrap.isUITesting() else { return nil }
+ return createUITestTextView()
+ }
+
+ private func createUITestTextView() -> MemoIMETrackingTextView {
+ let textView = MemoUITestIMETrackingTextView()
+ configureBaseTextView(textView)
+ textView.applyUITestAccessibilityIdentifier(AccessibilityID.Memo.inputTextView)
+ applyUITestKeyboardOverrides(to: textView)
+ return textView
+ }
+
+ private func applyUITestKeyboardOverrides(to textView: MemoUITestIMETrackingTextView) {
+ // 시뮬레이터별 키보드 편차를 줄이기 위해 테스트 시 키보드 동작을 결정적으로 맞춘다.
+ if UITestBootstrap.hasLaunchArgument(.forceKoreanKeyboard) {
+ textView.uiTestPreferredPrimaryLanguage = "ko"
+ applyDeterministicKeyboardTraits(to: textView)
+ textView.keyboardType = .default
+ return
+ }
+
+ guard UITestBootstrap.hasLaunchArgument(.forceASCIIKeyboard) else { return }
+ applyDeterministicKeyboardTraits(to: textView)
+ textView.keyboardType = .asciiCapable
+ }
+
+ private func applyDeterministicKeyboardTraits(to textView: UITextView) {
+ textView.autocapitalizationType = .none
+ textView.autocorrectionType = .no
+ textView.smartQuotesType = .no
+ textView.smartDashesType = .no
+ textView.smartInsertDeleteType = .no
+ }
+}
+
+#endif
diff --git a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift
index 0ba09b8..bfb6f57 100644
--- a/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift
+++ b/COMFIE/Presentation/Memo/MemoInput/MemoInputUITextView.swift
@@ -44,6 +44,7 @@ struct MemoInputUITextView: UIViewRepresentable {
}
func makeUIView(context: Context) -> UIView {
+
let container = UIView()
let textView = createTextView()
let placeholderLabel = createPlaceholderLabel()
@@ -51,14 +52,11 @@ struct MemoInputUITextView: UIViewRepresentable {
let heightConstraint = createMaxHeightConstraint(for: textView)
textView.delegate = context.coordinator
- // 아래 두 콜백은 "일반 delegate만으로는 잡기 어려운 IME 조합 경계"를 잡기 위한 연결입니다.
- // setMarkedText/unmarkText 타이밍을 직접 잡아야 조합 중 글자와 확정 글자를 안전하게 구분할 수 있습니다.
- // IME marked text가 생길 때 코디네이터가 즉시 토큰화를 조정할 수 있게 연결합니다.
+ // delegate만으로 놓치기 쉬운 IME 조합 시작/종료 경계를 직접 받아 Coordinator에 전달합니다.
textView.onSetMarkedText = { [weak textView, weak coordinator = context.coordinator] markedRange in
guard let textView else { return }
coordinator?.handleMarkedRange(in: textView, marked: markedRange)
}
- // IME 조합이 끝나는 순간에도 코디네이터가 후처리하도록 연결합니다.
textView.onUnmarkText = { [weak textView, weak coordinator = context.coordinator] in
guard let textView else { return }
coordinator?.handleUnmark(in: textView)
@@ -86,12 +84,29 @@ struct MemoInputUITextView: UIViewRepresentable {
}
func updateUIView(_ uiView: UIView, context: Context) {
+
context.coordinator.parent = self
context.coordinator.applyStateToTextView(force: false)
}
private func createTextView() -> MemoIMETrackingTextView {
+ #if DEBUG
+ // Debug UITest 세션에서만 테스트 전용 TextView 서브클래스를 주입합니다.
+ if let uiTestTextView = createUITestTextViewIfNeeded() {
+ return uiTestTextView
+ }
+ #endif
+
+ return createReleaseTextView()
+ }
+
+ func createReleaseTextView() -> MemoIMETrackingTextView {
let textView = MemoIMETrackingTextView()
+ configureBaseTextView(textView)
+ return textView
+ }
+
+ func configureBaseTextView(_ textView: MemoIMETrackingTextView) {
textView.font = comfieUIBodyFont
textView.isScrollEnabled = false
textView.backgroundColor = UIColor.keyBackground
@@ -99,8 +114,6 @@ struct MemoInputUITextView: UIViewRepresentable {
textView.layer.cornerRadius = 12
textView.clipsToBounds = true
textView.translatesAutoresizingMaskIntoConstraints = false
- textView.accessibilityIdentifier = "memo.inputTextView"
- return textView
}
private func createPlaceholderLabel() -> UILabel {
diff --git a/COMFIE/Presentation/Memo/MemoView.swift b/COMFIE/Presentation/Memo/MemoView.swift
index ae0a797..723fcc8 100644
--- a/COMFIE/Presentation/Memo/MemoView.swift
+++ b/COMFIE/Presentation/Memo/MemoView.swift
@@ -17,6 +17,7 @@ struct MemoView: View {
}
private var isEditingMemo: Bool {
+
intent.state.editingMemo != nil
}
@@ -26,6 +27,7 @@ struct MemoView: View {
VStack(spacing: 0) {
ZStack(alignment: .top) {
+
MemoListView(intent: $intent, isUserInComfieZone: isUserInComfieZone)
.onTapGesture {
intent(.backgroundTapped)
@@ -33,6 +35,7 @@ struct MemoView: View {
.padding(.top, 56)
if isEditingMemo {
+
VStack {
Spacer()
editingCancelButton
@@ -45,7 +48,6 @@ struct MemoView: View {
intent(.backgroundTapped)
}
}
-
memoInputView
.ignoresSafeArea(.keyboard, edges: .bottom)
}
@@ -60,6 +62,7 @@ struct MemoView: View {
}
if intent.state.deletingMemo != nil {
+
CFPopupView(type: .deleteMemo) {
intent(.deletePopup(.confirmDeleteButtonTapped))
} rightButtonAction: {
@@ -90,8 +93,7 @@ struct MemoView: View {
.frame(width: 24, height: 24)
}
}
- .accessibilityIdentifier("memo.comfieZoneSettingButton")
-
+ .uiTestAccessibilityIdentifier(AccessibilityID.Memo.comfieZoneSettingButton)
Spacer()
Button {
@@ -103,7 +105,8 @@ struct MemoView: View {
.symbolRenderingMode(.monochrome)
.tint(.cfBlack)
}
- .accessibilityIdentifier("memo.moreButton")
+ // 릴리즈에서는 no-op이고, Debug UITest 세션에서만 식별자를 부여한다.
+ .uiTestAccessibilityIdentifier(AccessibilityID.Memo.moreButton)
}
.padding(.horizontal, 19)
.padding(.vertical, 16)
@@ -142,7 +145,7 @@ struct MemoView: View {
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
- .accessibilityIdentifier("memo.sendButton")
+ .uiTestAccessibilityIdentifier(AccessibilityID.Memo.sendButton)
.disabled(intent.state.isInputEmpty)
}
.padding(16)
@@ -171,7 +174,7 @@ struct MemoView: View {
.clipShape(RoundedRectangle(cornerRadius: 212))
.shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 0)
}
- .accessibilityIdentifier("memo.editingCancelButton")
+ .uiTestAccessibilityIdentifier(AccessibilityID.Memo.editingCancelButton)
}
private func mapMemoInputUICommand(_ sideEffect: MemoStore.SideEffect.MemoInput) -> MemoInputUICommand {
diff --git a/COMFIE/Presentation/Retrospection/RetrospectionStore.swift b/COMFIE/Presentation/Retrospection/RetrospectionStore.swift
index 3613b1c..d1877c0 100644
--- a/COMFIE/Presentation/Retrospection/RetrospectionStore.swift
+++ b/COMFIE/Presentation/Retrospection/RetrospectionStore.swift
@@ -127,7 +127,7 @@ class RetrospectionStore: IntentStore {
case .saveRetrospection:
persistRetrospection(&newState)
case .deleteRetrospection:
- deleteRetrospection(newState)
+ deleteRetrospection()
case .showCompleteButton: newState.showCompleteButton = true
case .hideCompleteButton: newState.showCompleteButton = false
@@ -200,7 +200,7 @@ extension RetrospectionStore {
}
}
- private func deleteRetrospection(_ state: State) {
+ private func deleteRetrospection() {
switch repository.delete(memo: memo) {
case .success:
print("회고 삭제 성공")
diff --git a/COMFIE/Resources/DesignSystem/Components/CFPopup/CFPopupView.swift b/COMFIE/Resources/DesignSystem/Components/CFPopup/CFPopupView.swift
index 59ea69e..fb709c5 100644
--- a/COMFIE/Resources/DesignSystem/Components/CFPopup/CFPopupView.swift
+++ b/COMFIE/Resources/DesignSystem/Components/CFPopup/CFPopupView.swift
@@ -37,6 +37,7 @@ private struct CFPopup: View {
var body: some View {
VStack(spacing: 0) {
+
CFPopupContentView(type: type,
leftButtonType: leftButtonType,
leftButtonAction: leftButtonAction,
@@ -60,6 +61,7 @@ private struct CFPopupContentView: View {
var body: some View {
VStack(spacing: 0) {
+
Text(type.title)
.comfieFont(.systemSubtitle)
.foregroundStyle(.popupBlack)
@@ -79,6 +81,8 @@ private struct CFPopupContentView: View {
description: type.leftButtonDescription)
}
+ // 릴리즈에서는 no-op이고, Debug UITest 세션에서만 식별자를 부여한다.
+ .uiTestAccessibilityIdentifier(AccessibilityID.Popup.leftButton)
Button {
rightButtonAction()
@@ -86,6 +90,8 @@ private struct CFPopupContentView: View {
CFPopupButton(type: rightButtonType,
description: type.rightButtonDescription)
}
+
+ .uiTestAccessibilityIdentifier(AccessibilityID.Popup.rightButton)
}
}
}
diff --git a/COMFIE/Resources/EmojiPool/EmojiPool.swift b/COMFIE/Resources/EmojiPool/EmojiPool.swift
index 9c8f307..d4eef37 100644
--- a/COMFIE/Resources/EmojiPool/EmojiPool.swift
+++ b/COMFIE/Resources/EmojiPool/EmojiPool.swift
@@ -7,8 +7,6 @@
// 앱 전체에서 재사용하는 랜덤 이모지 후보 풀입니다.
struct EmojiPool {
- // 변환 가능한 글자를 이모지로 치환할 때 뽑아 쓰는 원본 배열입니다.
- // 이 배열의 순서 자체에는 의미가 없고, 랜덤 선택만 사용합니다.
static let emojiPool: [Character] = [
"😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "🥲", "🥹",
"☺️", "😊", "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘",
@@ -127,9 +125,8 @@ struct EmojiPool {
"🕐"
]
- // 이모지 풀에서 임의의 글자 1개를 뽑습니다.
+ // 이모지 풀에서 무작위 이모지를 반환하고, 비어 있으면 기본값을 사용합니다.
static func getRandomEmoji() -> Character {
- // 배열이 비어 있는 예외 상황에서도 앱이 멈추지 않도록 기본값을 둡니다.
emojiPool.randomElement() ?? "🐯"
}
}
diff --git a/COMFIEUITests/COMFIEUITests+KeyboardSupport.swift b/COMFIEUITests/COMFIEUITests+KeyboardSupport.swift
new file mode 100644
index 0000000..46935e0
--- /dev/null
+++ b/COMFIEUITests/COMFIEUITests+KeyboardSupport.swift
@@ -0,0 +1,437 @@
+//
+// COMFIEUITests+KeyboardSupport.swift
+// COMFIEUITests
+//
+// Created by zaehorang on 2/14/26.
+//
+
+import XCTest
+
+extension COMFIEUITests {
+ @MainActor
+ func tapKeyboardKey(in app: XCUIApplication, key: String, timeout: TimeInterval = 3) {
+ guard ensureKeyboardVisible(
+ in: app,
+ timeout: timeout,
+ failureContext: "before-key-\(key)",
+ failureAttachmentTag: "before-key-\(key)"
+ ) else {
+ return
+ }
+
+ // 시뮬레이터 실행마다 키보드 레이아웃이 달라질 수 있어 토글을 포함해 재시도한다.
+ let keyboard = app.keyboards.firstMatch
+
+ let normalizedKey: String
+ if key.range(of: "^[a-z]$", options: .regularExpression) != nil {
+ normalizedKey = key.lowercased()
+ ensureLowercaseAlphabetModeIfNeeded(in: app, key: normalizedKey)
+ } else if key.range(of: "^[A-Z]$", options: .regularExpression) != nil {
+ normalizedKey = key.uppercased()
+ } else {
+ normalizedKey = key
+ }
+
+ let keyCandidates = [normalizedKey]
+ for attempt in 0...4 {
+ for candidate in keyCandidates {
+ let keyQuery = keyboard.keys.matching(NSPredicate(format: "label == %@", candidate))
+ if keyQuery.firstMatch.waitForExistence(timeout: min(timeout, 0.35)),
+ tapPreferredElement(in: app, query: keyQuery) {
+ return
+ }
+ }
+
+ if attempt < 4 {
+ guard switchKeyboardForKeyIfPossible(in: app, key: key) else { break }
+ RunLoop.current.run(until: Date().addingTimeInterval(0.15))
+ }
+ }
+
+ XCTFail(
+ "Keyboard key not found after layout switches: \(key). " +
+ inputDebugSummary(in: app) + " " +
+ keyboardDebugSummary(in: app) +
+ " Hint: turn off Simulator > I/O > Keyboard > Connect Hardware Keyboard."
+ )
+ }
+
+ @MainActor
+ func tapDeleteKey(in app: XCUIApplication) {
+ guard ensureKeyboardVisible(
+ in: app,
+ timeout: 3,
+ failureContext: "before-delete-key",
+ failureAttachmentTag: "before-delete-key"
+ ) else {
+ return
+ }
+
+ let keyboard = app.keyboards.firstMatch
+
+ let candidateLabels = [
+ XCUIKeyboardKey.delete.rawValue,
+ "delete",
+ "삭제",
+ "지우기"
+ ]
+
+ for label in candidateLabels {
+ let query = keyboard.keys.matching(NSPredicate(format: "label == %@", label))
+ if tapPreferredElement(in: app, query: query) {
+ return
+ }
+ }
+
+ let fallbackQuery = keyboard.keys.matching(
+ NSPredicate(format: "label CONTAINS[c] 'delete' OR label CONTAINS[c] '삭제'")
+ )
+ if tapPreferredElement(in: app, query: fallbackQuery) {
+ return
+ }
+ XCTFail("Delete key not found on current keyboard. " + inputDebugSummary(in: app) + " " + keyboardDebugSummary(in: app))
+ }
+
+ @MainActor
+ func tapSpaceKey(in app: XCUIApplication, input: XCUIElement? = nil, timeout: TimeInterval = 3) {
+ guard ensureKeyboardVisible(
+ in: app,
+ input: input,
+ timeout: timeout,
+ failureContext: "before-space-key",
+ failureAttachmentTag: "before-space-key"
+ ) else {
+ return
+ }
+
+ let keyboard = app.keyboards.firstMatch
+ let candidateLabels = [
+ XCUIKeyboardKey.space.rawValue,
+ "space",
+ "Space",
+ "띄어쓰기",
+ "공백"
+ ]
+
+ for label in candidateLabels {
+ let keyQuery = keyboard.keys.matching(NSPredicate(format: "label == %@", label))
+ if tapPreferredElement(in: app, query: keyQuery) {
+ return
+ }
+
+ let buttonQuery = keyboard.buttons.matching(NSPredicate(format: "label == %@", label))
+ if tapPreferredElement(in: app, query: buttonQuery) {
+ return
+ }
+ }
+
+ let targetInput = input ?? app.textViews[AccessibilityID.Memo.inputTextView]
+ if targetInput.exists {
+ targetInput.typeText(" ")
+ return
+ }
+
+ XCTFail("Space key not found on current keyboard. " + inputDebugSummary(in: app, input: targetInput) + " " + keyboardDebugSummary(in: app))
+ }
+
+ @MainActor
+ func ensureKeyboardVisible(
+ in app: XCUIApplication,
+ input: XCUIElement? = nil,
+ timeout: TimeInterval = 3,
+ refocusAttempts: Int = 3,
+ failureContext: String,
+ failureAttachmentTag: String
+ ) -> Bool {
+ let keyboard = app.keyboards.firstMatch
+ if keyboard.waitForExistence(timeout: timeout) {
+ return true
+ }
+
+ let targetInput = input ?? app.textViews[AccessibilityID.Memo.inputTextView]
+ var attemptedRefocus = 0
+ let retryWait = min(timeout, 0.6)
+ for attempt in 1...refocusAttempts {
+ attemptedRefocus = attempt
+ refocusInputIfPossible(in: app, input: targetInput)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.12))
+
+ if keyboard.waitForExistence(timeout: retryWait) {
+ return true
+ }
+ }
+
+ let details = """
+ context=\(failureContext)
+ attemptedRefocus=\(attemptedRefocus)
+ \(inputDebugSummary(in: app, input: targetInput))
+ \(keyboardDebugSummary(in: app))
+ """
+ attachKeyboardMissingDiagnostics(in: app, attachmentTag: failureAttachmentTag, details: details)
+
+ XCTFail(
+ "Keyboard does not exist after refocus retries. " +
+ "context=\(failureContext), refocusAttempts=\(attemptedRefocus). " +
+ details
+ )
+ return false
+ }
+
+ @MainActor
+ private func ensureLowercaseAlphabetModeIfNeeded(in app: XCUIApplication, key: String) {
+ let keyboard = app.keyboards.firstMatch
+ guard keyboard.exists else { return }
+ if doesKeyboardContainAnyKey(in: app, keyboard: keyboard, candidates: [key]) { return }
+
+ let uppercase = key.uppercased()
+ guard doesKeyboardContainAnyKey(in: app, keyboard: keyboard, candidates: [uppercase]) else { return }
+
+ for shiftLabel in ["shift", "Shift"] where tapKeyboardToggle(in: app, label: shiftLabel) {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.08))
+ if doesKeyboardContainAnyKey(in: app, keyboard: keyboard, candidates: [key]) {
+ return
+ }
+ }
+ }
+
+ @MainActor
+ private func switchKeyboardForKeyIfPossible(in app: XCUIApplication, key: String) -> Bool {
+ if switchAlphabetModeIfNeeded(in: app, key: key) {
+ return true
+ }
+ if switchKoreanModeIfNeeded(in: app, key: key) {
+ return true
+ }
+ return switchKeyboardLayoutIfPossible(in: app)
+ }
+
+ @MainActor
+ private func switchAlphabetModeIfNeeded(in app: XCUIApplication, key: String) -> Bool {
+ guard key.range(of: "^[A-Za-z]$", options: .regularExpression) != nil else {
+ return false
+ }
+
+ let keyboard = app.keyboards.firstMatch
+ guard keyboard.exists else { return false }
+
+ let keyCandidates = [key, key.lowercased(), key.uppercased()]
+ if doesKeyboardContainAnyKey(in: app, keyboard: keyboard, candidates: keyCandidates) {
+ return true
+ }
+
+ let toggleAttempts = [
+ ["영문", "English", "ABC", "abc", "한/영", "한영", "가나다", "한글", "문자", "123", "숫자"],
+ ["숫자", "123", "문자", "ABC", "abc", "영문", "English", "한/영", "한영", "가나다", "한글"],
+ ["ABC", "abc", "영문", "English", "가나다", "한글", "문자"]
+ ]
+
+ for labels in toggleAttempts {
+ for label in labels where tapKeyboardToggle(in: app, label: label) {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.1))
+ let refreshedKeyboard = app.keyboards.firstMatch
+ if doesKeyboardContainAnyKey(in: app, keyboard: refreshedKeyboard, candidates: keyCandidates) {
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+
+ @MainActor
+ private func switchKoreanModeIfNeeded(in app: XCUIApplication, key: String) -> Bool {
+ guard key.range(of: "^[ㄱ-ㅎㅏ-ㅣ]$", options: .regularExpression) != nil else {
+ return false
+ }
+
+ let keyboard = app.keyboards.firstMatch
+ guard keyboard.exists else { return false }
+ if doesKeyboardContainAnyKey(in: app, keyboard: keyboard, candidates: [key]) { return true }
+
+ let toggleCandidates = [
+ "한글", "가나다", "한/영", "한영", "한국어", "영문", "English", "ABC", "abc", "문자"
+ ]
+
+ for label in toggleCandidates where tapKeyboardToggle(in: app, label: label) {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.1))
+ let refreshedKeyboard = app.keyboards.firstMatch
+ if doesKeyboardContainAnyKey(in: app, keyboard: refreshedKeyboard, candidates: [key]) {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ @MainActor
+ private func switchKeyboardLayoutIfPossible(in app: XCUIApplication) -> Bool {
+ let keyboard = app.keyboards.firstMatch
+ guard keyboard.exists else { return false }
+
+ let buttonLabels = [
+ "Next keyboard",
+ "next keyboard",
+ "다음 키보드",
+ "키보드 변경",
+ "지구본"
+ ]
+
+ for label in buttonLabels {
+ let query = keyboard.buttons.matching(NSPredicate(format: "label == %@", label))
+ if tapPreferredElement(in: app, query: query) {
+ return true
+ }
+ }
+
+ for label in ["🌐", "🌍", "🌎", "🌏"] {
+ let query = keyboard.keys.matching(NSPredicate(format: "label == %@", label))
+ if tapPreferredElement(in: app, query: query) {
+ return true
+ }
+ }
+
+ let fallbackQuery = keyboard.buttons.matching(
+ NSPredicate(format: "label CONTAINS[c] 'next' OR label CONTAINS[c] '다음' OR label CONTAINS[c] '키보드'")
+ )
+ if tapPreferredElement(in: app, query: fallbackQuery) {
+ return true
+ }
+
+ return false
+ }
+
+ @MainActor
+ private func tapKeyboardToggle(in app: XCUIApplication, label: String) -> Bool {
+ let keyboard = app.keyboards.firstMatch
+ guard keyboard.exists else { return false }
+
+ let keyQuery = keyboard.keys.matching(NSPredicate(format: "label == %@", label))
+ if tapPreferredElement(in: app, query: keyQuery) {
+ return true
+ }
+
+ let buttonQuery = keyboard.buttons.matching(NSPredicate(format: "label == %@", label))
+ if tapPreferredElement(in: app, query: buttonQuery) {
+ return true
+ }
+
+ return false
+ }
+
+ @MainActor
+ private func doesKeyboardContainAnyKey(
+ in app: XCUIApplication,
+ keyboard: XCUIElement,
+ candidates: [String]
+ ) -> Bool {
+ for candidate in candidates {
+ let keyQuery = keyboard.keys.matching(NSPredicate(format: "label == %@", candidate))
+ if preferredTapTarget(in: app, query: keyQuery) != nil {
+ return true
+ }
+ }
+ return false
+ }
+
+ @MainActor
+ private func keyboardDebugSummary(in app: XCUIApplication) -> String {
+ let keyboard = app.keyboards.firstMatch
+ guard keyboard.exists else {
+ return "Keyboard does not exist."
+ }
+
+ let keyLabels = keyboard.keys.allElementsBoundByIndex
+ .map {
+ let label = $0.label.isEmpty ? "" : $0.label
+ let frame = $0.frame
+ let hittable = $0.isHittable ? "hit" : "nohit"
+ return "\(label)[\(hittable):\(Int(frame.minX)),\(Int(frame.minY)),\(Int(frame.width))x\(Int(frame.height))]"
+ }
+ .joined(separator: ",")
+ let buttonLabels = keyboard.buttons.allElementsBoundByIndex
+ .map {
+ let label = $0.label.isEmpty ? "" : $0.label
+ let frame = $0.frame
+ let hittable = $0.isHittable ? "hit" : "nohit"
+ return "\(label)[\(hittable):\(Int(frame.minX)),\(Int(frame.minY)),\(Int(frame.width))x\(Int(frame.height))]"
+ }
+ .joined(separator: ",")
+ return "keys=[\(keyLabels)] buttons=[\(buttonLabels)]"
+ }
+
+ @MainActor
+ private func inputDebugSummary(in app: XCUIApplication, input: XCUIElement? = nil) -> String {
+ let targetInput = input ?? app.textViews[AccessibilityID.Memo.inputTextView]
+ guard targetInput.exists else {
+ return "input=[exists:false]"
+ }
+
+ let frame = targetInput.frame
+ return "input=[exists:true,hittable:\(targetInput.isHittable),frame:\(Int(frame.minX)),\(Int(frame.minY)),\(Int(frame.width))x\(Int(frame.height))]"
+ }
+
+ @MainActor
+ private func refocusInputIfPossible(in app: XCUIApplication, input: XCUIElement) {
+ if input.exists {
+ if input.isHittable {
+ input.tap()
+ return
+ }
+
+ if isElementWithinVisibleWindow(input, in: app) {
+ input.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
+ return
+ }
+ }
+
+ let inputQuery = app.textViews.matching(identifier: AccessibilityID.Memo.inputTextView)
+ _ = tapPreferredElement(in: app, query: inputQuery)
+ }
+
+ @MainActor
+ private func attachKeyboardMissingDiagnostics(in app: XCUIApplication, attachmentTag: String, details: String) {
+ attachScreenshot(app, named: "keyboard-missing-\(attachmentTag)")
+
+ let textAttachment = XCTAttachment(string: details)
+ textAttachment.name = "keyboardDebugSummary-\(attachmentTag)"
+ textAttachment.lifetime = .keepAlways
+ add(textAttachment)
+ }
+
+ @MainActor
+ private func isElementWithinVisibleWindow(_ element: XCUIElement, in app: XCUIApplication) -> Bool {
+ guard element.exists else { return false }
+ let elementFrame = element.frame
+ guard !elementFrame.isEmpty else { return false }
+
+ let windowFrame = app.windows.firstMatch.frame
+ guard !windowFrame.isEmpty else { return false }
+ return windowFrame.intersects(elementFrame)
+ }
+
+ @MainActor
+ private func preferredTapTarget(in app: XCUIApplication, query: XCUIElementQuery) -> XCUIElement? {
+ let candidates = query.allElementsBoundByIndex.filter { $0.exists }
+ if let hittable = candidates.first(where: { $0.isHittable }) {
+ return hittable
+ }
+ if let visible = candidates.first(where: { isElementWithinVisibleWindow($0, in: app) }) {
+ return visible
+ }
+ return candidates.first
+ }
+
+ @MainActor
+ private func tapPreferredElement(in app: XCUIApplication, query: XCUIElementQuery) -> Bool {
+ guard let element = preferredTapTarget(in: app, query: query) else { return false }
+ if element.isHittable {
+ element.tap()
+ return true
+ }
+ guard isElementWithinVisibleWindow(element, in: app) else {
+ return false
+ }
+ element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
+ return true
+ }
+}
diff --git a/COMFIEUITests/COMFIEUITests+Support.swift b/COMFIEUITests/COMFIEUITests+Support.swift
new file mode 100644
index 0000000..9a8352c
--- /dev/null
+++ b/COMFIEUITests/COMFIEUITests+Support.swift
@@ -0,0 +1,262 @@
+//
+// COMFIEUITests+Support.swift
+// COMFIEUITests
+//
+// Created by zaehorang on 2/14/26.
+//
+
+import XCTest
+
+@MainActor
+func enforcePortraitOrientationForUITests() {
+ XCUIDevice.shared.orientation = .portrait
+ RunLoop.current.run(until: Date().addingTimeInterval(0.25))
+}
+
+extension COMFIEUITests {
+ @MainActor
+ func launchAppForMemoScenario(
+ forceOutsideComfieZone: Bool = false,
+ forceEnglishLocale: Bool = false,
+ forceKoreanLocale: Bool = false
+ ) -> XCUIApplication {
+ let app = XCUIApplication()
+ // 앱에서 draft 스냅샷을 accessibilityValue로 노출하도록 draftDebug 인자를 함께 준다.
+ app.launchArguments += [UITestLaunchArgument.uiTesting.rawValue, UITestLaunchArgument.draftDebug.rawValue]
+ precondition(!(forceEnglishLocale && forceKoreanLocale), "Only one locale override can be enabled.")
+ if forceEnglishLocale {
+ app.launchArguments += [
+ "-AppleLanguages",
+ "(en-US)",
+ "-AppleLocale",
+ "en_US",
+ UITestLaunchArgument.forceASCIIKeyboard.rawValue
+ ]
+ } else if forceKoreanLocale {
+ app.launchArguments += [
+ "-AppleLanguages",
+ "(ko-KR)",
+ "-AppleLocale",
+ "ko_KR",
+ UITestLaunchArgument.forceKoreanKeyboard.rawValue
+ ]
+ }
+ if forceOutsideComfieZone {
+ app.launchArguments += [UITestLaunchArgument.forceOutsideComfieZone.rawValue]
+ } else {
+ app.launchArguments += [UITestLaunchArgument.forceInsideComfieZone.rawValue]
+ }
+ app.launch()
+ enforcePortraitOrientation()
+ return app
+ }
+
+ @MainActor
+ func enforcePortraitOrientation() {
+ enforcePortraitOrientationForUITests()
+ }
+
+ @MainActor
+ func attachScreenshot(_ app: XCUIApplication, named name: String) {
+ enforcePortraitOrientation()
+ let attachment = XCTAttachment(screenshot: app.screenshot())
+ attachment.name = name
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+
+ func waitUntil(
+ timeout: TimeInterval = 8,
+ pollInterval: TimeInterval = 0.1,
+ condition: @escaping () -> Bool
+ ) -> Bool {
+ let endTime = Date().addingTimeInterval(timeout)
+ while Date() < endTime {
+ if condition() { return true }
+ RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
+ }
+ return condition()
+ }
+
+ func readDraftDebug(from input: XCUIElement) -> DraftDebugSnapshot? {
+ guard let rawValue = input.value as? String,
+ let data = rawValue.data(using: .utf8) else {
+ return nil
+ }
+ return try? JSONDecoder().decode(DraftDebugSnapshot.self, from: data)
+ }
+
+ @MainActor
+ func memoComposerElements(in app: XCUIApplication) -> (input: XCUIElement, sendButton: XCUIElement) {
+ let input = app.textViews[AccessibilityID.Memo.inputTextView]
+ let sendButton = app.buttons[AccessibilityID.Memo.sendButton]
+
+ XCTAssertTrue(input.waitForExistence(timeout: 8))
+ XCTAssertTrue(sendButton.waitForExistence(timeout: 3))
+ return (input, sendButton)
+ }
+
+ func waitForDraftSnapshot(
+ in input: XCUIElement,
+ timeout: TimeInterval = 3,
+ pollInterval: TimeInterval = 0.05,
+ condition: @escaping (DraftDebugSnapshot) -> Bool
+ ) -> Bool {
+ waitUntil(timeout: timeout, pollInterval: pollInterval) {
+ guard let snapshot = self.readDraftDebug(from: input) else { return false }
+ return condition(snapshot)
+ }
+ }
+
+ func currentMemoCount(in app: XCUIApplication) -> Int {
+ app.buttons.matching(identifier: AccessibilityID.Memo.cellMenuButton).count
+ }
+
+ func waitForMemoCount(
+ in app: XCUIApplication,
+ expectedCount: Int,
+ timeout: TimeInterval = 8
+ ) -> Bool {
+ waitUntil(timeout: timeout) {
+ self.currentMemoCount(in: app) == expectedCount
+ }
+ }
+
+ func waitForMemoCountAtLeast(
+ in app: XCUIApplication,
+ minimumCount: Int,
+ timeout: TimeInterval = 8
+ ) -> Bool {
+ waitUntil(timeout: timeout) {
+ self.currentMemoCount(in: app) >= minimumCount
+ }
+ }
+
+ func assertSendButtonEnabled(_ sendButton: XCUIElement, timeout: TimeInterval = 8) {
+ XCTAssertTrue(
+ waitUntil(timeout: timeout) {
+ sendButton.isEnabled
+ }
+ )
+ }
+
+ @MainActor
+ func tapSendWhenEnabled(_ sendButton: XCUIElement, timeout: TimeInterval = 8) {
+ assertSendButtonEnabled(sendButton, timeout: timeout)
+ sendButton.tap()
+ }
+
+ @MainActor
+ func openLatestMemoMenu(in app: XCUIApplication) {
+ XCTAssertTrue(waitForMemoCountAtLeast(in: app, minimumCount: 1))
+ let menuButtons = app.buttons.matching(identifier: AccessibilityID.Memo.cellMenuButton)
+ let latestIndex = max(0, menuButtons.count - 1)
+ let latestMenuButton = menuButtons.element(boundBy: latestIndex)
+ XCTAssertTrue(latestMenuButton.exists)
+ latestMenuButton.tap()
+ }
+
+ @MainActor
+ func tapMenuAction(_ app: XCUIApplication, identifier: String) {
+ let actionButton = app.buttons[identifier]
+ XCTAssertTrue(actionButton.waitForExistence(timeout: 3))
+ actionButton.tap()
+ }
+
+ @MainActor
+ func resetMemoInputIfNeeded(in app: XCUIApplication, input: XCUIElement) {
+ input.tap()
+ if let initialSnapshot = readDraftDebug(from: input), !initialSnapshot.original.isEmpty {
+ for _ in 0..<(initialSnapshot.original.count + 4) {
+ tapDeleteKey(in: app)
+ RunLoop.current.run(until: Date().addingTimeInterval(0.03))
+ }
+ XCTAssertTrue(waitForDraftSnapshot(in: input) { $0.original.isEmpty })
+ }
+ }
+
+ @MainActor
+ func typeHangulJamoSequence(
+ in app: XCUIApplication,
+ input: XCUIElement,
+ jamoKeys: [String],
+ checkpointByInputIndex: [Int: String]
+ ) {
+ var previousRevision = readDraftDebug(from: input)?.revision ?? 0
+ for (index, key) in jamoKeys.enumerated() {
+ if key == " " {
+ tapSpaceKey(in: app, input: input)
+ } else {
+ tapKeyboardKey(in: app, key: key)
+ }
+ XCTAssertTrue(
+ waitForDraftSnapshot(in: input) { snapshot in
+ snapshot.revision > previousRevision
+ }
+ )
+ previousRevision = readDraftDebug(from: input)?.revision ?? previousRevision
+ let displayKey = key == " " ? "space" : key
+ attachScreenshot(app, named: "hangul-emoji-key-\(String(format: "%02d", index + 1))-\(displayKey)")
+ captureHangulCheckpointProofIfNeeded(
+ in: app,
+ input: input,
+ keyIndex: index + 1,
+ checkpointByInputIndex: checkpointByInputIndex
+ )
+ }
+ }
+
+ @MainActor
+ private func captureHangulCheckpointProofIfNeeded(
+ in app: XCUIApplication,
+ input: XCUIElement,
+ keyIndex: Int,
+ checkpointByInputIndex: [Int: String]
+ ) {
+ guard let expectedOriginal = checkpointByInputIndex[keyIndex] else { return }
+ XCTAssertTrue(
+ waitForDraftSnapshot(in: input) { snapshot in
+ self.hangulSyllableSnapshotMatches(snapshot, expectedOriginal: expectedOriginal)
+ }
+ )
+
+ let checkpointOrder = checkpointByInputIndex.keys.filter { $0 <= keyIndex }.count
+ let attachmentToken = expectedOriginal.replacingOccurrences(of: " ", with: "_")
+ attachScreenshot(app, named: "hangul-emoji-syllable-\(String(format: "%02d", checkpointOrder))-\(attachmentToken)")
+ }
+
+ private func hangulSyllableSnapshotMatches(
+ _ snapshot: DraftDebugSnapshot,
+ expectedOriginal: String
+ ) -> Bool {
+ guard snapshot.original == expectedOriginal else { return false }
+
+ let originalChars = Array(snapshot.original)
+ let emojiChars = Array(snapshot.emoji)
+ guard !originalChars.isEmpty, originalChars.count == emojiChars.count else {
+ return false
+ }
+
+ let currentIndex = originalChars.count - 1
+ if requiresHangulConversionCheck(originalChars[currentIndex]) {
+ guard emojiChars[currentIndex] == originalChars[currentIndex] else {
+ return false
+ }
+ }
+
+ return (0.. Bool {
+ guard character.unicodeScalars.count == 1,
+ let scalar = character.unicodeScalars.first else {
+ return false
+ }
+ return (0xAC00...0xD7A3).contains(scalar.value)
+ }
+}
diff --git a/COMFIEUITests/COMFIEUITests.swift b/COMFIEUITests/COMFIEUITests.swift
index 1ff0a3e..e5e8294 100644
--- a/COMFIEUITests/COMFIEUITests.swift
+++ b/COMFIEUITests/COMFIEUITests.swift
@@ -8,93 +8,307 @@
import XCTest
final class COMFIEUITests: XCTestCase {
+ // MemoInputUITextView+Snapshot+UITest가 발행하는 JSON 스키마와 동일한 구조체다.
+ struct DraftDebugSnapshot: Decodable {
+ let original: String
+ let emoji: String
+ let revision: Int
+ }
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
- private func launchAppForMemoScenario() -> XCUIApplication {
- let app = XCUIApplication()
- app.launchArguments += ["-ui-testing"]
- app.launch()
- return app
- }
+ func testMemoInputSendCreatesNewMemoCell() throws {
+ let app = launchAppForMemoScenario(forceEnglishLocale: true)
+ let (input, sendButton) = memoComposerElements(in: app)
+ let beforeCount = currentMemoCount(in: app)
+
+ input.tap()
+ let rawInput = "stepsendproofflow"
+ var expectedOriginal = ""
+ var previousRevision = readDraftDebug(from: input)?.revision ?? 0
+ for (index, character) in rawInput.enumerated() {
+ tapKeyboardKey(in: app, key: String(character))
+ expectedOriginal.append(character)
- private func waitUntil(
- timeout: TimeInterval = 8,
- pollInterval: TimeInterval = 0.1,
- condition: @escaping () -> Bool
- ) -> Bool {
- let endTime = Date().addingTimeInterval(timeout)
- while Date() < endTime {
- if condition() { return true }
- RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
+ XCTAssertTrue(
+ waitForDraftSnapshot(in: input) { snapshot in
+ snapshot.revision > previousRevision && snapshot.original == expectedOriginal
+ }
+ )
+ previousRevision = readDraftDebug(from: input)?.revision ?? previousRevision
+ attachScreenshot(app, named: "memo-send-typing-step-\(String(format: "%02d", index + 1))")
+ RunLoop.current.run(until: Date().addingTimeInterval(0.22))
}
- return condition()
+
+ attachScreenshot(app, named: "memo-send-before-send")
+
+ tapSendWhenEnabled(sendButton)
+ XCTAssertTrue(waitForMemoCountAtLeast(in: app, minimumCount: beforeCount + 1))
+ attachScreenshot(app, named: "memo-send-after-save")
}
@MainActor
- func testMemoInputSendCreatesNewMemoCell() throws {
- let app = launchAppForMemoScenario()
- let input = app.textViews["memo.inputTextView"]
- let sendButton = app.buttons["memo.sendButton"]
+ func testHangulJamoTypingShowsStepByStepProgress() throws {
+ let app = launchAppForMemoScenario(forceOutsideComfieZone: true, forceKoreanLocale: true)
+ let (input, sendButton) = memoComposerElements(in: app)
+ let memoContentTexts = app.staticTexts.matching(identifier: AccessibilityID.Memo.cellContentText)
+ let beforeCount = currentMemoCount(in: app)
+ let rawInput = "이게 정말 되는 건가 정말로 리얼로 이게 되는건가"
+
+ resetMemoInputIfNeeded(in: app, input: input)
+ guard ensureKeyboardVisible(
+ in: app,
+ input: input,
+ timeout: 3,
+ failureContext: "hangul-sequence-initial-focus",
+ failureAttachmentTag: "before-key-hangul-sequence-initial"
+ ) else {
+ return
+ }
+ attachScreenshot(app, named: "hangul-emoji-step-0-empty")
+ guard ensureKeyboardVisible(
+ in: app,
+ input: input,
+ timeout: 3,
+ failureContext: "hangul-sequence-before-first-jamo",
+ failureAttachmentTag: "before-key-hangul-first-jamo"
+ ) else {
+ return
+ }
- XCTAssertTrue(input.waitForExistence(timeout: 8))
- XCTAssertTrue(sendButton.waitForExistence(timeout: 3))
+ let jamoKeys = [
+ "ㅇ", "ㅣ", "ㄱ", "ㅔ", " ",
+ "ㅈ", "ㅓ", "ㅇ", "ㅁ", "ㅏ", "ㄹ", " ",
+ "ㄷ", "ㅗ", "ㅣ", "ㄴ", "ㅡ", "ㄴ", " ",
+ "ㄱ", "ㅓ", "ㄴ", "ㄱ", "ㅏ", " ",
+ "ㅈ", "ㅓ", "ㅇ", "ㅁ", "ㅏ", "ㄹ", "ㄹ", "ㅗ", " ",
+ "ㄹ", "ㅣ", "ㅇ", "ㅓ", "ㄹ", "ㄹ", "ㅗ", " ",
+ "ㅇ", "ㅣ", "ㄱ", "ㅔ", " ",
+ "ㄷ", "ㅗ", "ㅣ", "ㄴ", "ㅡ", "ㄴ", "ㄱ", "ㅓ", "ㄴ", "ㄱ", "ㅏ"
+ ]
+ let checkpointByInputIndex: [Int: String] = [
+ 2: "이",
+ 4: "이게",
+ 8: "이게 정",
+ 11: "이게 정말",
+ 15: "이게 정말 되",
+ 18: "이게 정말 되는",
+ 22: "이게 정말 되는 건",
+ 24: "이게 정말 되는 건가",
+ 28: "이게 정말 되는 건가 정",
+ 31: "이게 정말 되는 건가 정말",
+ 33: "이게 정말 되는 건가 정말로",
+ 36: "이게 정말 되는 건가 정말로 리",
+ 39: "이게 정말 되는 건가 정말로 리얼",
+ 41: "이게 정말 되는 건가 정말로 리얼로",
+ 44: "이게 정말 되는 건가 정말로 리얼로 이",
+ 46: "이게 정말 되는 건가 정말로 리얼로 이게",
+ 50: "이게 정말 되는 건가 정말로 리얼로 이게 되",
+ 53: "이게 정말 되는 건가 정말로 리얼로 이게 되는",
+ 56: "이게 정말 되는 건가 정말로 리얼로 이게 되는건",
+ 58: "이게 정말 되는 건가 정말로 리얼로 이게 되는건가"
+ ]
+ typeHangulJamoSequence(
+ in: app,
+ input: input,
+ jamoKeys: jamoKeys,
+ checkpointByInputIndex: checkpointByInputIndex
+ )
- let beforeCount = app.buttons.matching(identifier: "memo.cell.menuButton").count
+ XCTAssertTrue(
+ waitForDraftSnapshot(in: input) { snapshot in
+ snapshot.original == rawInput && snapshot.emoji != snapshot.original
+ }
+ )
+ tapSendWhenEnabled(sendButton)
+ XCTAssertTrue(waitForMemoCountAtLeast(in: app, minimumCount: beforeCount + 1))
+
+ XCTAssertTrue(waitUntil { memoContentTexts.count > 0 })
+ attachScreenshot(app, named: "hangul-emoji-after-save")
+
+ let rawInputStillVisible = memoContentTexts.allElementsBoundByIndex
+ .contains(where: { $0.label.contains(rawInput) })
+ XCTAssertFalse(rawInputStillVisible)
+ }
+
+ @MainActor
+ func testEmojiInputConvertsPerCharacterRealtimeAndAttachesVisualProof() throws {
+ let app = launchAppForMemoScenario(forceOutsideComfieZone: true, forceEnglishLocale: true)
+ let (input, sendButton) = memoComposerElements(in: app)
+ let memoContentTexts = app.staticTexts.matching(identifier: AccessibilityID.Memo.cellContentText)
+ let beforeCount = currentMemoCount(in: app)
+ let rawInput = "abcdefghij"
input.tap()
- input.typeText("UITEST\(Int(Date().timeIntervalSince1970))")
+ var typedOriginal = ""
+ for (index, character) in rawInput.enumerated() {
+ typedOriginal.append(character)
+ tapKeyboardKey(in: app, key: String(character))
- let becameEnabled = waitUntil {
- sendButton.isEnabled
- }
- XCTAssertTrue(becameEnabled)
- sendButton.tap()
+ let realtimeUpdated = waitForDraftSnapshot(in: input) { snapshot in
+ guard snapshot.original == typedOriginal else { return false }
+
+ let originalChars = Array(snapshot.original)
+ let emojiChars = Array(snapshot.emoji)
+ guard !originalChars.isEmpty, originalChars.count == emojiChars.count else { return false }
+
+ if index == 0 {
+ return emojiChars[0] == originalChars[0]
+ }
+
+ let previousIndex = index - 1
+ let previousConverted = emojiChars[previousIndex] != originalChars[previousIndex]
+ let currentStillPlain = emojiChars[index] == originalChars[index]
+ return previousConverted && currentStillPlain
+ }
+ XCTAssertTrue(realtimeUpdated)
+ attachScreenshot(app, named: "emoji-proof-step-\(index + 1)")
- let countIncreased = waitUntil {
- app.buttons.matching(identifier: "memo.cell.menuButton").count >= beforeCount + 1
+ RunLoop.current.run(until: Date().addingTimeInterval(0.25))
}
- XCTAssertTrue(countIncreased)
+
+ tapSendWhenEnabled(sendButton)
+ XCTAssertTrue(waitForMemoCountAtLeast(in: app, minimumCount: beforeCount + 1))
+
+ XCTAssertTrue(waitUntil { memoContentTexts.count > 0 })
+ attachScreenshot(app, named: "emoji-proof-after-save")
+
+ let rawInputStillVisible = memoContentTexts.allElementsBoundByIndex
+ .contains(where: { $0.label.contains(rawInput) })
+ XCTAssertFalse(rawInputStillVisible)
}
@MainActor
- func testHangulTypingAndCursorTapKeepsInputInteractive() throws {
+ func testMemoComposeFlowCoversMultipleWritingPatterns() throws {
let app = launchAppForMemoScenario()
- let input = app.textViews["memo.inputTextView"]
- let sendButton = app.buttons["memo.sendButton"]
+ let (input, sendButton) = memoComposerElements(in: app)
- XCTAssertTrue(input.waitForExistence(timeout: 8))
- XCTAssertTrue(sendButton.waitForExistence(timeout: 3))
+ let beforeCount = currentMemoCount(in: app)
input.tap()
- input.typeText("가나")
+ input.typeText(
+ "STEP-COMPOSE-1 |\(Int(Date().timeIntervalSince1970))| " +
+ "첫 작성: 한글/영문 혼합 텍스트 1234567890"
+ )
+ attachScreenshot(app, named: "memo-compose-step-1-first-typed")
+ tapSendWhenEnabled(sendButton)
- let firstEnable = waitUntil {
- sendButton.isEnabled
- }
- XCTAssertTrue(firstEnable)
+ XCTAssertTrue(waitForMemoCountAtLeast(in: app, minimumCount: beforeCount + 1))
+ XCTAssertTrue(waitUntil { !sendButton.isEnabled })
+ attachScreenshot(app, named: "memo-compose-step-2-first-sent")
+ input.tap()
+ input.typeText(
+ "STEP-COMPOSE-2 line-1: 멀티라인 첫 줄\n" +
+ "STEP-COMPOSE-2 line-2: second line 1234567890"
+ )
input.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5)).tap()
- input.typeText("다")
+ input.typeText(" + STEP-COMPOSE-2-Tail")
+ attachScreenshot(app, named: "memo-compose-step-3-multiline-cursor")
+ tapSendWhenEnabled(sendButton)
- let secondEnable = waitUntil {
- sendButton.isEnabled
- }
- XCTAssertTrue(secondEnable)
- XCTAssertTrue(input.exists)
+ XCTAssertTrue(waitForMemoCountAtLeast(in: app, minimumCount: beforeCount + 2))
+ XCTAssertTrue(waitUntil { !sendButton.isEnabled })
+ attachScreenshot(app, named: "memo-compose-step-4-second-sent")
+
+ input.tap()
+ input.typeText("STEP-COMPOSE-3: 마지막 입력. 한글 English 1234567890.")
+ attachScreenshot(app, named: "memo-compose-step-5-mixed-typed")
+ tapSendWhenEnabled(sendButton)
+
+ XCTAssertTrue(waitForMemoCountAtLeast(in: app, minimumCount: beforeCount + 3))
+ XCTAssertFalse(sendButton.isEnabled)
+ attachScreenshot(app, named: "memo-compose-step-6-third-sent")
}
@MainActor
- func testLaunchPerformance() throws {
- if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
- measure(metrics: [XCTApplicationLaunchMetric()]) {
- let app = XCUIApplication()
- app.launchArguments += ["-ui-testing"]
- app.launch()
- }
- }
+ func testMemoEditUpdateAndDeleteLifecycle() throws {
+ let app = launchAppForMemoScenario(forceOutsideComfieZone: true)
+ let (input, sendButton) = memoComposerElements(in: app)
+ let editingCancelButton = app.buttons[AccessibilityID.Memo.editingCancelButton]
+
+ let beforeCount = currentMemoCount(in: app)
+
+ input.tap()
+ input.typeText(
+ "수정흐름1 |\(Int(Date().timeIntervalSince1970))| " +
+ "수정 대상 메모 생성 테스트"
+ )
+ tapSendWhenEnabled(sendButton)
+
+ let createdCount = beforeCount + 1
+ XCTAssertTrue(waitForMemoCount(in: app, expectedCount: createdCount))
+ XCTAssertTrue(waitUntil { !sendButton.isEnabled })
+ attachScreenshot(app, named: "memo-edit-delete-step-1-created")
+
+ openLatestMemoMenu(in: app)
+ attachScreenshot(app, named: "memo-edit-delete-step-2-menu-opened-for-edit")
+
+ tapMenuAction(app, identifier: AccessibilityID.Memo.cellMenuEditButton)
+ XCTAssertTrue(editingCancelButton.waitForExistence(timeout: 3))
+ attachScreenshot(app, named: "memo-edit-delete-step-3-enter-edit")
+
+ input.tap()
+ input.typeText(" + 수정흐름2 최종수정")
+ assertSendButtonEnabled(sendButton)
+ attachScreenshot(app, named: "memo-edit-delete-step-4-edited-before-save")
+
+ sendButton.tap()
+ XCTAssertTrue(waitForMemoCount(in: app, expectedCount: createdCount))
+ XCTAssertTrue(waitUntil { !editingCancelButton.exists })
+ XCTAssertFalse(sendButton.isEnabled)
+ attachScreenshot(app, named: "memo-edit-delete-step-5-updated")
+
+ openLatestMemoMenu(in: app)
+ attachScreenshot(app, named: "memo-edit-delete-step-6-menu-opened-for-delete")
+
+ tapMenuAction(app, identifier: AccessibilityID.Memo.cellMenuDeleteButton)
+
+ let popupDeleteButton = app.buttons[AccessibilityID.Popup.leftButton]
+ XCTAssertTrue(popupDeleteButton.waitForExistence(timeout: 3))
+ attachScreenshot(app, named: "memo-edit-delete-step-7-delete-popup-shown")
+ popupDeleteButton.tap()
+
+ XCTAssertTrue(waitForMemoCount(in: app, expectedCount: beforeCount))
+ attachScreenshot(app, named: "memo-edit-delete-step-8-deleted")
+ }
+
+ @MainActor
+ func testMemoEditingCancelInteractionFlow() throws {
+ let app = launchAppForMemoScenario(forceOutsideComfieZone: true)
+ let (input, sendButton) = memoComposerElements(in: app)
+
+ let beforeCount = currentMemoCount(in: app)
+
+ input.tap()
+ input.typeText(
+ "취소흐름1 |\(Int(Date().timeIntervalSince1970))| " +
+ "취소 플로우용 원본 메모"
+ )
+ tapSendWhenEnabled(sendButton)
+
+ let createdCount = beforeCount + 1
+ XCTAssertTrue(waitForMemoCount(in: app, expectedCount: createdCount))
+ attachScreenshot(app, named: "memo-cancel-flow-step-1-created")
+
+ openLatestMemoMenu(in: app)
+ attachScreenshot(app, named: "memo-cancel-flow-step-2-menu-opened")
+
+ tapMenuAction(app, identifier: AccessibilityID.Memo.cellMenuDeleteButton)
+
+ let popupDeleteButton = app.buttons[AccessibilityID.Popup.leftButton]
+ let popupCancelButton = app.buttons[AccessibilityID.Popup.rightButton]
+ XCTAssertTrue(popupDeleteButton.waitForExistence(timeout: 3))
+ XCTAssertTrue(popupCancelButton.waitForExistence(timeout: 3))
+ attachScreenshot(app, named: "memo-cancel-flow-step-3-delete-popup-shown")
+
+ popupCancelButton.tap()
+ XCTAssertTrue(input.exists)
+ XCTAssertTrue(sendButton.exists)
+ XCTAssertTrue(waitForMemoCount(in: app, expectedCount: createdCount))
+ attachScreenshot(app, named: "memo-cancel-flow-step-4-after-delete-cancel")
}
}
diff --git a/COMFIEUITests/COMFIEUITestsLaunchTests.swift b/COMFIEUITests/COMFIEUITestsLaunchTests.swift
index 0e9d3b7..1980fe5 100644
--- a/COMFIEUITests/COMFIEUITestsLaunchTests.swift
+++ b/COMFIEUITests/COMFIEUITestsLaunchTests.swift
@@ -7,27 +7,22 @@
import XCTest
-final class COMFIEUITestsLaunchTests: XCTestCase {
+class COMFIEUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
+
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
+
let app = XCUIApplication()
app.launch()
-
- // Insert steps here to perform after app launch but before taking a screenshot,
- // such as logging into a test account or navigating somewhere in the app
-
- let attachment = XCTAttachment(screenshot: app.screenshot())
- attachment.name = "Launch Screen"
- attachment.lifetime = .keepAlways
- add(attachment)
+ enforcePortraitOrientationForUITests()
}
}