阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
146 lines
5.8 KiB
Swift
146 lines
5.8 KiB
Swift
import XCTest
|
|
|
|
/// Quantitative memory-footprint harness for the long-chapter optimization
|
|
/// (Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md, P0-2 / P1-4 / P2). Reads
|
|
/// phys_footprint back through the demo state `footprintMB` field, so these
|
|
/// tests double as a decision tool: the recorded numbers are attached to the
|
|
/// result, and the assertions are deliberately loose upper bounds that only
|
|
/// catch gross regressions (e.g. each visible page pinning a full-chapter
|
|
/// copy), not few-MB noise.
|
|
final class MemoryFootprintTests: XCTestCase {
|
|
private let app = XCUIApplication()
|
|
private let largeBookQuery = "凡人修仙传"
|
|
|
|
/// Turning 20 pages must not grow footprint beyond this over the post-open
|
|
/// baseline. Sized to flag "per-page full-chapter copy" class leaks while
|
|
/// tolerating cache warmup and simulator noise.
|
|
private let pageTurnGrowthBudgetMB = 180.0
|
|
|
|
/// A settings change rebuilds display objects; peak may spike transiently
|
|
/// but must settle back within this of the pre-change reading.
|
|
private let settingsChangeGrowthBudgetMB = 220.0
|
|
|
|
override func setUpWithError() throws {
|
|
continueAfterFailure = false
|
|
}
|
|
|
|
func testFootprintStableAcrossTwentyPageTurns() throws {
|
|
app.launchAndOpenSampleBook(
|
|
bookTitleQuery: largeBookQuery,
|
|
displayType: "scroll",
|
|
resetsReaderState: true
|
|
)
|
|
app.waitForReader(timeout: 20)
|
|
app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") {
|
|
$0.display == "horizontalScroll"
|
|
}
|
|
|
|
let baseline = try requireFootprint(description: "打开长书后基线")
|
|
let paging = app.collectionViews[IDs.readerPaging]
|
|
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
|
|
|
var readings: [Double] = [baseline]
|
|
var lastPage = app.currentDemoReaderState()?.page ?? 0
|
|
var turnsMade = 0
|
|
|
|
for _ in 0..<20 {
|
|
paging.swipeLeft()
|
|
let state = app.waitForDemoReaderState(timeout: 8, description: "翻页后页码变化") { state in
|
|
guard let page = state.page else { return false }
|
|
return page != lastPage
|
|
}
|
|
guard let page = state.page, page != lastPage else { break }
|
|
lastPage = page
|
|
turnsMade += 1
|
|
if let footprint = state.footprintMB {
|
|
readings.append(footprint)
|
|
}
|
|
}
|
|
|
|
let peak = readings.max() ?? baseline
|
|
let final = readings.last ?? baseline
|
|
let growth = final - baseline
|
|
attachReport(
|
|
name: "page-turns",
|
|
lines: [
|
|
"turnsMade=\(turnsMade)",
|
|
"baselineMB=\(fmt(baseline))",
|
|
"peakMB=\(fmt(peak))",
|
|
"finalMB=\(fmt(final))",
|
|
"growthMB=\(fmt(growth))",
|
|
"budgetMB=\(fmt(pageTurnGrowthBudgetMB))",
|
|
"series=\(readings.map(fmt).joined(separator: ","))"
|
|
]
|
|
)
|
|
|
|
XCTAssertGreaterThan(turnsMade, 0, "未能翻动任何页,无法测量翻页内存")
|
|
XCTAssertLessThan(
|
|
growth, pageTurnGrowthBudgetMB,
|
|
"连续翻页后常驻内存增长 \(fmt(growth))MB 超出预算 \(fmt(pageTurnGrowthBudgetMB))MB(疑似每页驻留整章副本)"
|
|
)
|
|
}
|
|
|
|
func testFootprintSettlesAfterFontSizeChange() throws {
|
|
app.launchAndOpenSampleBook(
|
|
bookTitleQuery: largeBookQuery,
|
|
resetsReaderState: true
|
|
)
|
|
app.waitForReader(timeout: 20)
|
|
app.showReaderChromeIfNeeded()
|
|
|
|
let before = try requireFootprint(description: "改字号前")
|
|
|
|
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5), "设置按钮不存在")
|
|
app.buttons[IDs.readerSettings].tap()
|
|
XCTAssertTrue(app.waitForSettingsPanel(), "设置面板未出现")
|
|
XCTAssertTrue(app.adjustSettingsFontSize(toNormalizedPosition: 0.9), "字号滑块不存在")
|
|
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
|
|
app.buttons[IDs.settingsDone].tap()
|
|
|
|
// Allow repagination + display-cache rebuild to settle.
|
|
RunLoop.current.run(until: Date().addingTimeInterval(2.5))
|
|
let after = try requireFootprint(description: "改字号并重排后")
|
|
|
|
let growth = after - before
|
|
attachReport(
|
|
name: "font-size-change",
|
|
lines: [
|
|
"beforeMB=\(fmt(before))",
|
|
"afterMB=\(fmt(after))",
|
|
"growthMB=\(fmt(growth))",
|
|
"budgetMB=\(fmt(settingsChangeGrowthBudgetMB))"
|
|
]
|
|
)
|
|
|
|
XCTAssertLessThan(
|
|
growth, settingsChangeGrowthBudgetMB,
|
|
"改字号重排后常驻内存增长 \(fmt(growth))MB 超出预算 \(fmt(settingsChangeGrowthBudgetMB))MB(疑似旧显示对象未释放)"
|
|
)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func requireFootprint(description: String) throws -> Double {
|
|
let state = app.waitForDemoReaderState(timeout: 8, description: description) {
|
|
($0.footprintMB ?? 0) > 0
|
|
}
|
|
guard let footprint = state.footprintMB, footprint > 0 else {
|
|
throw XCTSkip("demo 状态未暴露 footprintMB,跳过内存测量:\(state.rawValue)")
|
|
}
|
|
return footprint
|
|
}
|
|
|
|
private func fmt(_ value: Double) -> String {
|
|
String(format: "%.1f", value)
|
|
}
|
|
|
|
private func attachReport(name: String, lines: [String]) {
|
|
let text = lines.joined(separator: "\n")
|
|
let attachment = XCTAttachment(string: text)
|
|
attachment.name = "memory-\(name)"
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
print("[MemoryFootprintTests] \(name)\n\(text)")
|
|
}
|
|
}
|