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.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现") XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3), "字号增大按钮不存在") app.buttons[IDs.settingsFontIncrease].tap() app.buttons[IDs.settingsFontIncrease].tap() 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)") } }