246 lines
9.8 KiB
Swift
246 lines
9.8 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindPresentation
|
|
import Testing
|
|
import UIKit
|
|
|
|
@testable import TermindIOS
|
|
|
|
@Suite(
|
|
.requiresKeyWindow,
|
|
.serialized)
|
|
@MainActor
|
|
struct MobileEditorToolbarTests {
|
|
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
|
func saveDisablesActionsUntilCompletionAndThenDismisses(language: String) async throws {
|
|
let state = EditorState()
|
|
try await withEditor(state, language: language) { _, navigation in
|
|
let cancel = try #require(cancelButton(in: navigation))
|
|
let save = try #require(saveButton(in: navigation))
|
|
#expect(cancel.isEnabled)
|
|
#expect(!save.isEnabled)
|
|
state.canSave = true
|
|
try await settle()
|
|
let enabledSave = try #require(saveButton(in: navigation))
|
|
#expect(enabledSave.isEnabled)
|
|
try activate(enabledSave)
|
|
try await settle()
|
|
#expect(state.saveCount == 1)
|
|
#expect(state.isPresented)
|
|
#expect(cancelButton(in: navigation)?.isEnabled == false)
|
|
#expect(saveButton(in: navigation)?.isEnabled == false)
|
|
state.finishSave()
|
|
try await settle()
|
|
#expect(!state.isPresented)
|
|
}
|
|
}
|
|
|
|
@Test func failedSaveKeepsTheDraftAndShowsAnError() async throws {
|
|
let state = EditorState()
|
|
state.canSave = true
|
|
state.fails = true
|
|
try await withEditor(state) { controller, navigation in
|
|
try activate(#require(saveButton(in: navigation)))
|
|
try await settle()
|
|
state.finishSave()
|
|
try await settle()
|
|
#expect(state.isPresented)
|
|
#expect(state.title == "Example")
|
|
#expect(saveButton(in: navigation)?.isEnabled == true)
|
|
_ = try #require(descendant(of: UIAlertController.self, in: controller))
|
|
}
|
|
}
|
|
|
|
@Test func moreActionsRemainAvailableBesideSaveAndDisableDuringSave() async throws {
|
|
let state = EditorState()
|
|
state.canSave = true
|
|
state.showsActions = true
|
|
try await withEditor(state) { _, navigation in
|
|
#expect(saveButton(in: navigation)?.isEnabled == true)
|
|
let initialActions = try await moreActions(in: navigation)
|
|
#expect(initialActions.count == 2)
|
|
#expect(initialActions.allSatisfy { !$0.attributes.contains(.disabled) })
|
|
#expect(initialActions.allSatisfy { !$0.title.isEmpty && $0.image != nil })
|
|
|
|
try activate(#require(saveButton(in: navigation)))
|
|
try await settle()
|
|
let savingActions = try await moreActions(in: navigation)
|
|
#expect(savingActions.count == 2)
|
|
#expect(savingActions.allSatisfy { $0.attributes.contains(.disabled) })
|
|
|
|
state.finishSave()
|
|
try await settle()
|
|
#expect(!state.isPresented)
|
|
}
|
|
}
|
|
|
|
@Test(arguments: [false, true], ["en", "zh-Hans", "zh-Hant"])
|
|
func cancelOnlyDismissesUnmodifiedDrafts(isDirty: Bool, language: String) async throws {
|
|
let state = EditorState()
|
|
state.isDirty = isDirty
|
|
try await withEditor(state, language: language) { controller, navigation in
|
|
try activate(#require(cancelButton(in: navigation)))
|
|
try await settle()
|
|
#expect(state.isPresented == isDirty)
|
|
#expect(state.saveCount == 0)
|
|
if isDirty {
|
|
let alert = try #require(descendant(of: UIAlertController.self, in: controller))
|
|
#expect(alert.preferredStyle == .actionSheet)
|
|
#expect(alert.title?.isEmpty != false)
|
|
let locale = Locale(identifier: language)
|
|
#expect(
|
|
alert.message == AppLocalization.resolve(AppStrings.Common.discardChangesQuestion, locale: locale))
|
|
let actions = alert.actions.filter { $0.style != .cancel }
|
|
#expect(actions.count == 1)
|
|
#expect(actions.first?.style == .destructive)
|
|
#expect(
|
|
actions.first?.title == AppLocalization.resolve(AppStrings.Common.discardChanges, locale: locale))
|
|
#expect(saveButton(in: navigation) != nil)
|
|
#expect(state.cancelCount == 0)
|
|
if let window = controller.view.window {
|
|
let data = UIGraphicsImageRenderer(bounds: window.bounds).pngData { _ in
|
|
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
}
|
|
Attachment.record(data, named: "discard-confirmation-\(language).png")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func withEditor(
|
|
_ state: EditorState, language: String = "en",
|
|
check: (UIViewController, UINavigationController) async throws -> Void
|
|
) async throws {
|
|
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
|
let previousWindow = scene.keyWindow
|
|
let window = UIWindow(windowScene: scene)
|
|
let controller = UIHostingController(
|
|
rootView: EditorHost(state: state).environment(\.locale, Locale(identifier: language)))
|
|
window.rootViewController = controller
|
|
window.windowLevel = .alert + 1
|
|
window.makeKeyAndVisible()
|
|
defer {
|
|
state.finishSave()
|
|
window.isHidden = true
|
|
window.rootViewController = nil
|
|
previousWindow?.makeKey()
|
|
}
|
|
try await settle()
|
|
let navigation = try #require(descendant(of: UINavigationController.self, in: controller))
|
|
try await check(controller, navigation)
|
|
}
|
|
|
|
private func accessibleName(_ button: UIBarButtonItem) -> String? {
|
|
button.accessibilityLabel ?? button.title ?? button.customView?.accessibilityLabel
|
|
}
|
|
|
|
private func activate(_ button: UIBarButtonItem) throws {
|
|
let action = try #require(button.action)
|
|
#expect(UIApplication.shared.sendAction(action, to: button.target, from: button, for: nil))
|
|
}
|
|
|
|
private func cancelButton(in navigation: UINavigationController) -> UIBarButtonItem? {
|
|
toolbarButton(namedOneOf: ["Cancel", "取消"], in: navigation)
|
|
}
|
|
|
|
private func saveButton(in navigation: UINavigationController) -> UIBarButtonItem? {
|
|
toolbarButton(namedOneOf: ["Save", "保存", "儲存"], in: navigation)
|
|
}
|
|
|
|
private func moreButton(in navigation: UINavigationController) -> UIBarButtonItem? {
|
|
toolbarButton(namedOneOf: ["More", "更多"], in: navigation)
|
|
}
|
|
|
|
private func moreActions(in navigation: UINavigationController) async throws -> [UIAction] {
|
|
let page = try #require(navigation.topViewController)
|
|
let menu: UIMenu
|
|
if #available(iOS 27.0, *) {
|
|
menu = UIMenu(children: [try #require(page.navigationItem.additionalOverflowItems)])
|
|
} else {
|
|
menu = try #require(moreButton(in: navigation)?.menu)
|
|
}
|
|
let button = UIButton(frame: CGRect(x: 100, y: 100, width: 44, height: 44))
|
|
button.menu = menu
|
|
button.showsMenuAsPrimaryAction = true
|
|
page.view.addSubview(button)
|
|
defer { button.removeFromSuperview() }
|
|
button.performPrimaryAction()
|
|
try await settle()
|
|
let interaction = try #require(button.contextMenuInteraction)
|
|
var actions: [UIAction] = []
|
|
interaction.updateVisibleMenu { resolved in
|
|
actions = menuActions(resolved)
|
|
return resolved
|
|
}
|
|
interaction.dismissMenu()
|
|
try await settle()
|
|
return actions
|
|
}
|
|
|
|
private func toolbarButton(
|
|
namedOneOf names: Set<String>, in navigation: UINavigationController
|
|
) -> UIBarButtonItem? {
|
|
guard let item = navigation.topViewController?.navigationItem else { return nil }
|
|
let buttons =
|
|
item.leadingItemGroups.flatMap(\.barButtonItems)
|
|
+ item.trailingItemGroups.flatMap(\.barButtonItems)
|
|
+ (item.leftBarButtonItems ?? [])
|
|
+ (item.rightBarButtonItems ?? [])
|
|
return buttons.first { button in
|
|
accessibleName(button).map(names.contains) == true
|
|
}
|
|
}
|
|
|
|
private func settle() async throws { try await Task.sleep(for: .milliseconds(700)) }
|
|
}
|
|
|
|
@Observable @MainActor private final class EditorState {
|
|
var isPresented = true
|
|
var isDirty = true
|
|
var canSave = false
|
|
var fails = false
|
|
var showsActions = false
|
|
var title = "Example"
|
|
var saveCount = 0
|
|
var cancelCount = 0
|
|
var pendingSave: CheckedContinuation<Void, Never>?
|
|
|
|
func finishSave() {
|
|
pendingSave?.resume()
|
|
pendingSave = nil
|
|
}
|
|
}
|
|
|
|
private struct EditorHost: View {
|
|
@Bindable var state: EditorState
|
|
private enum SaveFailure: Error { case expected }
|
|
|
|
var body: some View {
|
|
Color.clear.sheet(isPresented: $state.isPresented) {
|
|
MobileEditorSheet(
|
|
title: "Snippet", isDirty: state.isDirty, canSave: state.canSave,
|
|
actions: state.showsActions
|
|
? [
|
|
MobileEditorToolbarAction(
|
|
id: "export", title: "Export", systemImage: "square.and.arrow.up", perform: {}),
|
|
MobileEditorToolbarAction(
|
|
id: "delete", title: "Delete", systemImage: "trash", role: .destructive,
|
|
startsGroup: true, perform: {}),
|
|
] : [],
|
|
onCancel: { state.cancelCount += 1 },
|
|
save: {
|
|
state.saveCount += 1
|
|
await withCheckedContinuation { state.pendingSave = $0 }
|
|
if state.fails { throw SaveFailure.expected }
|
|
}
|
|
) {
|
|
TextField("Title", text: $state.title)
|
|
}
|
|
}
|
|
}
|
|
}
|