fix: 修复书源导入、阅读外观和书签状态

- 修复书源文件选择、访问权限与导入预览流程,支持查看分组内的书源
- 修复背景图片、字体选择与主题切换,限制背景图片解码尺寸
- 根据已保存书签恢复当前位置状态,避免重复添加
- 补充模型、阅读器和文件选择回归测试及测试样本
- 更新应用版本为 0.1.10(build 16)

Fixes #1
This commit is contained in:
Aminark
2026-10-20 23:30:42 +08:00
parent 0bf332441d
commit 0915a144a7
17 changed files with 903 additions and 101 deletions
@@ -207,6 +207,30 @@ struct ReaderRegressionTests {
#expect(bookmark.resolvedChapterIndex(in: chapters) == nil)
}
@Test func bookmarkSelectionUsesSavedPositionAndCurrentPageAfterReopening() {
let book = Book(bookUrl: "demo://bookmarks", name: "书", author: "作者")
let chapters = (0..<2).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
var saved = Bookmark(time: 1, bookName: book.name, bookAuthor: book.author)
saved.chapterIndex = 0
saved.chapterName = chapters[0].title
saved.chapterPosition = 40
var otherBook = saved
otherBook.bookAuthor = "其他作者"
var otherChapter = saved
otherChapter.chapterIndex = 1
otherChapter.chapterName = chapters[1].title
let bookmarks = [saved, otherBook, otherChapter]
let reopened = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 0, content: "正文")
#expect(reopened.bookmarks(at: 40, in: bookmarks) == [saved])
#expect(reopened.bookmarks(at: 0, in: bookmarks, pageRange: NSRange(location: 0, length: 80)) == [saved])
#expect(reopened.bookmarks(at: 80, in: bookmarks, pageRange: NSRange(location: 80, length: 80)).isEmpty)
#expect(reopened.bookmarks(at: 41, in: bookmarks).isEmpty)
#expect(reopened.bookmarks(at: 40, in: []).isEmpty)
reopened.select(index: 1)
#expect(reopened.bookmarks(at: 40, in: bookmarks) == [otherChapter])
}
@Test func continuousReadingExtendsPastThreeChaptersWithoutNavigating() async {
let book = Book(bookUrl: "demo://continuous", name: "书")
let chapters = (0..<20).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) }
@@ -25,6 +25,51 @@ private actor ImportFixtureTransport: HTTPTransport {
@MainActor
struct RuleLibraryRegressionTests {
@Test func pickedJSONFilesAreReadTogetherAndReviewedBeforeImport() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let inputs = [
("书源.json", #"{"bookSourceUrl":"https://picked-json.test","bookSourceName":"JSON 书源"}"#),
("大写扩展名.JSON", #"[{"bookSourceUrl":"https://picked-uppercase.test","bookSourceName":"大写扩展名"}]"#),
("书源.txt", #"{"bookSourceUrl":"https://picked-text.test","bookSourceName":"文本扩展名"}"#)
]
let files = try inputs.map { name, content in
let url = directory.appendingPathComponent(name)
try Data(content.utf8).write(to: url)
return SourceImportFile(url: url)
}
let model = SourceLibraryModel.demo(sources: [])
await model.previewPickedFiles(files)
#expect(model.notice == nil)
#expect(!model.isWorking)
#expect(model.pendingImport?.validCount == 3)
#expect(model.sources.isEmpty)
let selectedIDs = Set(try #require(model.pendingImport).items.map(\.id))
await model.commitPendingImport(selectedIDs: selectedIDs, keepOriginalName: false, keepOriginalGroup: false,
keepOriginalEnabled: false, customGroup: nil, addCustomGroup: true)
#expect(model.sources.count == 3)
#expect(model.pendingImport == nil)
}
@Test func missingOrDirectoryImportReportsFailureWithoutChangingSources() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let original = BookSource(bookSourceUrl: "https://existing-source.test", bookSourceName: "原有书源")
for url in [directory, directory.appendingPathComponent("missing.json")] {
let model = SourceLibraryModel.demo(sources: [original])
await model.load()
await model.previewFiles([url])
#expect(model.notice?.kind == .error)
#expect(model.pendingImport == nil)
#expect(model.sources == [original])
#expect(!model.isWorking)
}
}
@Test func largeImportKeepsTheMainActorResponsive() async throws {
let comment = String(repeating: "规则说明", count: 1_024)
let entries = (0..<1_000).map { index in
@@ -1,8 +1,161 @@
import ImageIO
import SwiftUI
import UIKit
import XCTest
@testable import Legado
private final class BackgroundPhotoProvider: NSItemProvider, @unchecked Sendable {
let photoData: Data?
let photoError: (any Error)?
init(data: Data?, error: (any Error)? = nil) {
photoData = data
photoError = error
super.init()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override var registeredTypeIdentifiers: [String] { ["public.png"] }
override func loadDataRepresentation(
forTypeIdentifier typeIdentifier: String,
completionHandler: @escaping @Sendable (Data?, (any Error)?) -> Void
) -> Progress {
DispatchQueue.global(qos: .userInitiated).async { [photoData, photoError] in
dispatchPrecondition(condition: .notOnQueue(.main))
completionHandler(photoData, photoError)
}
return Progress(totalUnitCount: 1)
}
}
@MainActor
final class ReadingPhotoPickerTests: XCTestCase {
func testPhotoDataReturnedOnBackgroundQueueReachesMainActor() async throws {
let expected = Data("photo-provider-data".utf8)
let provider = BackgroundPhotoProvider(data: expected)
let received = expectation(description: "Photo data delivered on main actor")
let coordinator = ReadingPhotoPickerController.Coordinator { result in
XCTAssertTrue(Thread.isMainThread)
XCTAssertEqual(try? result?.get(), expected)
received.fulfill()
}
coordinator.loadImage(from: provider)
await fulfillment(of: [received], timeout: 5)
withExtendedLifetime(coordinator) {}
}
func testPhotoProviderFailureOnBackgroundQueueReachesMainActor() async throws {
let errors: [(any Error)?] = [CocoaError(.fileReadNoPermission), nil]
for error in errors {
let provider = BackgroundPhotoProvider(data: nil, error: error)
let received = expectation(description: "Photo error delivered on main actor")
let coordinator = ReadingPhotoPickerController.Coordinator { result in
XCTAssertTrue(Thread.isMainThread)
guard case .failure(let failure)? = result else {
XCTFail("Expected an import error")
received.fulfill()
return
}
XCTAssertEqual((failure as NSError).domain, NSCocoaErrorDomain)
XCTAssertEqual((failure as NSError).code, (error as NSError?)?.code ?? NSFileReadCorruptFileError)
received.fulfill()
}
coordinator.loadImage(from: provider)
await fulfillment(of: [received], timeout: 5)
withExtendedLifetime(coordinator) {}
}
}
}
@MainActor
final class ReaderBackgroundStoreTests: XCTestCase {
func testLargeBackgroundIsDownsampledOnImportAndLegacyReload() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let target = directory.appendingPathComponent("background.jpg")
let format = UIGraphicsImageRendererFormat()
format.scale = 1
let data = UIGraphicsImageRenderer(size: CGSize(width: 4096, height: 3072), format: format).jpegData(withCompressionQuality: 0.9) { context in
UIColor.red.setFill()
context.fill(CGRect(x: 0, y: 0, width: 4096, height: 3072))
}
for _ in 0..<3 {
let imported = try ReaderBackgroundStore.save(data, to: target)
XCTAssertEqual(imported.size.width, CGFloat(ReaderBackgroundStore.maximumPixelSize))
XCTAssertEqual(imported.size.height / imported.size.width, 0.75, accuracy: 0.001)
let restored = try XCTUnwrap(ReaderBackgroundStore.load(from: target))
XCTAssertEqual(restored.size, imported.size)
}
try data.write(to: target)
let legacy = try XCTUnwrap(ReaderBackgroundStore.load(from: target))
XCTAssertEqual(legacy.size.width, CGFloat(ReaderBackgroundStore.maximumPixelSize))
}
func testCorruptReplacementPreservesExistingBackground() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let target = directory.appendingPathComponent("background.jpg")
let image = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 60)).image { context in
UIColor.blue.setFill()
context.fill(CGRect(x: 0, y: 0, width: 40, height: 60))
}
_ = try ReaderBackgroundStore.save(try XCTUnwrap(image.pngData()), to: target)
let saved = try Data(contentsOf: target)
XCTAssertThrowsError(try ReaderBackgroundStore.save(Data("not an image".utf8), to: target))
XCTAssertEqual(try Data(contentsOf: target), saved)
XCTAssertNotNil(ReaderBackgroundStore.load(from: target))
}
func testBackgroundPreservesPhotoOrientation() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let image = UIGraphicsImageRenderer(size: CGSize(width: 120, height: 60)).image { context in
UIColor.green.setFill()
context.fill(CGRect(x: 0, y: 0, width: 120, height: 60))
}
let data = NSMutableData()
let destination = try XCTUnwrap(CGImageDestinationCreateWithData(data, "public.jpeg" as CFString, 1, nil))
CGImageDestinationAddImage(destination, try XCTUnwrap(image.cgImage), [kCGImagePropertyOrientation: 6] as CFDictionary)
XCTAssertTrue(CGImageDestinationFinalize(destination))
let saved = try ReaderBackgroundStore.save(data as Data, to: directory.appendingPathComponent("background.jpg"))
XCTAssertEqual(saved.imageOrientation, .up)
XCTAssertEqual(saved.size.height / saved.size.width, 2, accuracy: 0.001)
}
}
@MainActor
final class ReaderBookmarkPersistenceTests: XCTestCase {
func testBookmarkSurvivesNewActivityAndReaderInstances() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let repository = FileReadingActivityRepository(
bookmarksURL: directory.appendingPathComponent("bookmarks.json"),
recordsURL: directory.appendingPathComponent("records.json")
)
let book = Book(bookUrl: "demo://saved-bookmark", name: "书", author: "作者")
let chapters = [BookChapter(url: "demo://chapter", title: "第一章", bookUrl: book.bookUrl, index: 0)]
let original = ReadingActivityModel(repository: repository, deviceID: "test")
await original.load()
await original.addBookmark(book: book, chapter: chapters[0], position: 40, content: String(repeating: "正文", count: 100))
XCTAssertNil(original.errorMessage)
let restored = ReadingActivityModel(repository: repository, deviceID: "test")
await restored.load()
let reader = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 0, content: "正文")
XCTAssertEqual(reader.bookmarks(at: 40, in: restored.bookmarks).count, 1)
XCTAssertTrue(reader.bookmarks(at: 80, in: restored.bookmarks).isEmpty)
await restored.removeBookmark(try XCTUnwrap(restored.bookmarks.first))
XCTAssertTrue(reader.bookmarks(at: 40, in: restored.bookmarks).isEmpty)
}
}
@MainActor
final class ReaderScrollGeometryTests: XCTestCase {
func testPrependingAndRecyclingStayWithinOnePhysicalPixel() async throws {
@@ -116,7 +269,7 @@ final class ReaderScrollGeometryTests: XCTestCase {
.init(configuration: configuration(fontSize: fontSize), chapters: chapters,
resetID: 0, activeIndex: active, loadingAhead: false, loadingBehind: false,
onNeedChapters: { _ in }, onPositionChanged: { _, _ in },
onRetainChapters: { _ in }, onRetry: { _ in })
onRetainChapters: { _ in })
}
private func configuration(fontSize: Double = 20) -> ReaderTextView {
@@ -1,7 +1,185 @@
import XCTest
import UIKit
@MainActor
final class ReaderScrollGestureTests: XCTestCase {
func testBackgroundSwitchRestoresPresetWithoutDeletingPhoto() throws {
try exerciseBackgroundSwitch(readingMode: "0")
}
func testScrollBackgroundSwitchRestoresPresetWithoutDeletingPhoto() throws {
try exerciseBackgroundSwitch(readingMode: "3")
}
private func exerciseBackgroundSwitch(readingMode: String) throws {
continueAfterFailure = false
let app = launchAppearance(readingMode: readingMode)
let choose = app.buttons["选择背景图片"]
scrollToAppearanceElement(choose, in: app)
app.buttons["微信读书"].tap()
choose.tap()
let photo = app.images["PXGGridLayout-Info"].firstMatch
XCTAssertTrue(photo.waitForExistence(timeout: 10))
photo.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
let dismissed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil)
wait(for: [dismissed], timeout: 15)
let useBackground = app.switches["使用背景图片"]
scrollToAppearanceElement(useBackground, in: app)
XCTAssertEqual(useBackground.value as? String, "1")
app.buttons["完成"].tap()
let imageColor = try backgroundRGB(in: app)
func openAppearance() {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
app.buttons["外观"].tap()
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5))
scrollToAppearanceElement(useBackground, in: app)
}
openAppearance()
attachScreenshot(app)
app.coordinate(withNormalizedOffset: CGVector(
dx: 0.25, dy: useBackground.frame.midY / app.frame.height
)).tap()
XCTAssertEqual(useBackground.value as? String, "0")
XCTAssertTrue(app.buttons["删除背景图片"].exists)
app.buttons["完成"].tap()
XCTAssertEqual(try backgroundRGB(in: app), [192, 237, 198])
openAppearance()
useBackground.tap()
XCTAssertEqual(useBackground.value as? String, "1")
app.buttons["完成"].tap()
XCTAssertEqual(try backgroundRGB(in: app), imageColor)
openAppearance()
app.buttons["预设2"].tap()
XCTAssertEqual(useBackground.value as? String, "0")
app.buttons["完成"].tap()
XCTAssertEqual(try backgroundRGB(in: app), [221, 192, 144])
}
private func backgroundRGB(in app: XCUIApplication) throws -> [Int] {
let image = try XCTUnwrap(app.screenshot().image.cgImage)
let pixel = try XCTUnwrap(image.cropping(to: CGRect(
x: image.width / 50, y: image.height * 28 / 100, width: 1, height: 1
)))
var bytes = [UInt8](repeating: 0, count: 4)
try bytes.withUnsafeMutableBytes { buffer in
let context = try XCTUnwrap(CGContext(
data: buffer.baseAddress, width: 1, height: 1, bitsPerComponent: 8,
bytesPerRow: 4, space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
))
context.draw(pixel, in: CGRect(x: 0, y: 0, width: 1, height: 1))
}
return bytes.prefix(3).map(Int.init)
}
func testBackgroundPickerCanBeOpenedRepeatedly() throws {
continueAfterFailure = false
let app = launchAppearance()
for _ in 0..<3 {
let choose = app.buttons["选择背景图片"]
scrollToAppearanceElement(choose, in: app)
choose.tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
cancel.tap()
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5))
}
}
func testBackgroundPhotoCanBeSelectedAndReplaced() throws {
try exerciseBackgroundPhotoSelection(usesLegacyUI: false)
}
func testLegacyBackgroundPhotoCanBeSelectedAndReplaced() throws {
try exerciseBackgroundPhotoSelection(usesLegacyUI: true)
}
private func exerciseBackgroundPhotoSelection(usesLegacyUI: Bool) throws {
continueAfterFailure = false
let app = launchAppearance(usesLegacyUI: usesLegacyUI)
for _ in 0..<2 {
let choose = app.buttons["选择背景图片"]
scrollToAppearanceElement(choose, in: app)
choose.tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
let photo = app.images["PXGGridLayout-Info"].firstMatch
XCTAssertTrue(photo.waitForExistence(timeout: 5), "Seed a demo image with simctl addmedia before running this test")
photo.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
let dismissed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil)
wait(for: [dismissed], timeout: 15)
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 15))
let useBackground = app.switches["使用背景图片"]
scrollToAppearanceElement(useBackground, in: app)
XCTAssertEqual(useBackground.value as? String, "1")
}
}
func testFontPickerCanBePresentedAboveAppearanceAndReopened() throws {
continueAfterFailure = false
let app = launchAppearance()
for _ in 0..<2 {
let choose = app.buttons["选择字体文件"]
scrollToAppearanceElement(choose, in: app)
XCTAssertTrue(app.staticTexts["段落缩进"].exists)
choose.tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
cancel.tap()
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5))
}
}
func testBookmarkSelectionSurvivesLeavingAndReenteringReader() throws {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-pageAnim", "0", "-themeMode", "1", "-textSize", "28"]
app.launch()
let book = app.staticTexts["山海入梦来"].firstMatch
XCTAssertTrue(book.waitForExistence(timeout: 20))
book.tap()
XCTAssertTrue(app.textViews["reader.text"].waitForExistence(timeout: 20))
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
let bookmark = app.buttons["书签"]
XCTAssertTrue(bookmark.waitForExistence(timeout: 5))
XCTAssertEqual(bookmark.value as? String, "添加当前位置")
bookmark.tap()
app.buttons["返回"].tap()
XCTAssertTrue(book.waitForExistence(timeout: 10))
book.tap()
XCTAssertTrue(app.textViews["reader.text"].waitForExistence(timeout: 10))
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
XCTAssertTrue(bookmark.waitForExistence(timeout: 5))
XCTAssertEqual(bookmark.value as? String, "已添加")
app.buttons["更多"].tap()
app.buttons["书签列表"].tap()
XCTAssertTrue(app.navigationBars["书签"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["第一章 · 雨夜"].firstMatch.exists)
}
private func launchAppearance(readingMode: String = "0", usesLegacyUI: Bool = false) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-uiDemoReader", "-uiDemoReaderStyle", "-pageAnim", readingMode, "-themeMode", "1", "-textFont", ""]
if usesLegacyUI { app.launchArguments.append("-uiLegacyCompatibility") }
app.launch()
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 20))
return app
}
private func scrollToAppearanceElement(_ element: XCUIElement, in app: XCUIApplication) {
for _ in 0..<12 {
if element.exists && element.isHittable { return }
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.88))
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.72))
start.press(forDuration: 0.05, thenDragTo: end)
}
XCTAssertTrue(element.exists && element.isHittable)
}
func testFailedChaptersAllowRetryMenuAndSourceSwitchInBothReadingModes() throws {
continueAfterFailure = false
for mode in ["3", "0"] {
@@ -2,6 +2,68 @@ import XCTest
@MainActor
final class RuleEditorUITests: XCTestCase {
func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-uiDemoTab", "sources"]
app.launch()
XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 20))
app.buttons["更多"].firstMatch.tap()
app.buttons["从文件导入"].tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
cancel.tap()
let pickerClosed = expectation(for: NSPredicate { _, _ in
!cancel.exists && app.navigationBars["书源"].buttons["更多"].isHittable
}, evaluatedWith: nil)
wait(for: [pickerClosed], timeout: 10)
app.buttons["更多"].firstMatch.tap()
app.buttons["从文件导入"].tap()
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
let json = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "JSON-Source")).firstMatch
let fixtures = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "书源导入测试")).firstMatch
if !json.waitForExistence(timeout: 3) {
if !fixtures.exists {
let browse = app.buttons.matching(NSPredicate(format: "label IN %@", ["Browse", "浏览", "Locations", "位置"])).firstMatch
if browse.exists { browse.tap() }
if !fixtures.waitForExistence(timeout: 2) {
let local = app.descendants(matching: .any).matching(NSPredicate(
format: "label CONTAINS %@ OR label CONTAINS %@ OR label == %@", "我的 iPhone", "我的iPhone", "On My iPhone"
)).firstMatch
if !local.waitForExistence(timeout: 5) { print("[SourceFilePicker] \(app.debugDescription)") }
XCTAssertTrue(local.exists)
local.tap()
}
}
XCTAssertTrue(fixtures.waitForExistence(timeout: 10), "Run sh scripts/prepare-source-import-fixture.sh <simulator-UUID> before this test")
fixtures.tap()
}
XCTAssertTrue(json.waitForExistence(timeout: 10))
json.tap()
let uppercase = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "JSON-Uppercase")).firstMatch
XCTAssertTrue(uppercase.waitForExistence(timeout: 5))
uppercase.tap()
let open = app.buttons.matching(NSPredicate(format: "label IN %@", ["Open", "打开", "Import", "导入"])).firstMatch
XCTAssertTrue(open.waitForExistence(timeout: 5))
XCTAssertTrue(open.isEnabled)
attach(app, name: "JSON files selected with Open available")
open.tap()
let reviewAppeared = app.navigationBars["书源导入预检"].waitForExistence(timeout: 15)
if !reviewAppeared {
print("[SourceFilePickerAfterOpen] \(app.debugDescription)")
attach(app, name: "File picker after Open")
}
XCTAssertTrue(reviewAppeared)
XCTAssertTrue(app.buttons["导入"].isEnabled)
attach(app, name: "JSON import review after file picker dismissal")
app.buttons["导入"].tap()
if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() }
XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["文件导入大写扩展名测试"].exists)
}
func testSourceFormJSONSwitchKeepsHighlightedTextAndSaves() throws {
try exerciseSourceEditor(usesLegacyUI: false)
}