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![${meta.key}](${url})`; + }) + .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() } }