mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 04:41:23 +08:00
fix(appearance): 完善深色模式和阅读页面对比度
- 区分按钮填充与图标颜色,统一书架、书源、搜索和阅读设置的选中态 - 调整编辑器语法高亮、键盘工具栏及统计卡片的浅深色显示 - 适配 RSS 正文和登录网页背景,保留书源自定义样式和页面状态 - 补充外观切换、页面对比度和管理页面的回归测试
This commit is contained in:
@@ -440,7 +440,7 @@ struct LegadoDatabaseTests {
|
||||
@Test func rssHTMLAndResourcePoliciesApplyRulesInOrder() {
|
||||
let processor = RSSHTMLProcessor()
|
||||
#expect(processor.contentHTML("<head></head><body>x</body>", style: "body{color:red}").contains("<style>body{color:red}</style></head>"))
|
||||
#expect(processor.contentHTML("<p>x</p>", style: nil).hasPrefix("<style>\(RSSHTMLProcessor.defaultStyle)</style>"))
|
||||
#expect(processor.contentHTML("<p>x</p>", style: nil).hasPrefix("<style>\(RSSHTMLProcessor.appearanceStyle)</style><style>\(RSSHTMLProcessor.defaultStyle)</style>"))
|
||||
var source = RssSource(sourceUrl: "https://rss", sourceName: "源")
|
||||
source.startJs = "window.ready=true"; source.startStyle = "p{font-size:20px}"
|
||||
let start = processor.startHTML("<body><p>x</p></body>", source: source)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
import XCTest
|
||||
@testable import Legado
|
||||
|
||||
@MainActor
|
||||
final class AppearanceContrastTests: XCTestCase {
|
||||
func testLoginWebViewUsesAdaptiveBackgroundBeforeContentArrives() async throws {
|
||||
var source = BookSource(bookSourceUrl: "https://appearance.invalid", bookSourceName: "外观测试")
|
||||
source.loginUrl = "about:blank"
|
||||
let controller = UIHostingController(rootView: SourceLoginView(source: source))
|
||||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||
window.overrideUserInterfaceStyle = .dark
|
||||
window.rootViewController = controller
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true }
|
||||
func webView(in view: UIView) -> WKWebView? {
|
||||
if let view = view as? WKWebView { return view }
|
||||
return view.subviews.lazy.compactMap { webView(in: $0) }.first
|
||||
}
|
||||
window.layoutIfNeeded()
|
||||
for _ in 0..<20 {
|
||||
if webView(in: controller.view) != nil { break }
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
let web = try XCTUnwrap(webView(in: controller.view))
|
||||
XCTAssertFalse(web.isOpaque)
|
||||
for style in [UIUserInterfaceStyle.dark, .light] {
|
||||
window.overrideUserInterfaceStyle = style
|
||||
window.layoutIfNeeded()
|
||||
let traits = UITraitCollection(userInterfaceStyle: style)
|
||||
let background = try XCTUnwrap(web.backgroundColor).resolvedColor(with: traits)
|
||||
let scroll = try XCTUnwrap(web.scrollView.backgroundColor).resolvedColor(with: traits)
|
||||
if style == .dark {
|
||||
XCTAssertLessThan(luminance(background), 0.15)
|
||||
XCTAssertLessThan(luminance(scroll), 0.15)
|
||||
} else {
|
||||
XCTAssertGreaterThan(luminance(background), 0.6)
|
||||
XCTAssertGreaterThan(luminance(scroll), 0.6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testSyntaxTokensHaveReadableContrastInBaseAndElevatedAppearances() {
|
||||
let samples: [(SyntaxCodeEditor.Language, String)] = [
|
||||
(.json, #"{"title":"正文","count":12,"enabled":true,"empty":null}"#),
|
||||
(.rule, "@css:div && $.title {{key}} ##"),
|
||||
(.regex, #"\d+.*(word){2,4}"#),
|
||||
(.javascript, "// comment\nconst value = 'text'; return 123; /* comment */")
|
||||
]
|
||||
for style in [UIUserInterfaceStyle.light, .dark] {
|
||||
for level in [UIUserInterfaceLevel.base, .elevated] {
|
||||
let traits = UITraitCollection(traitsFrom: [
|
||||
UITraitCollection(userInterfaceStyle: style),
|
||||
UITraitCollection(userInterfaceLevel: level)
|
||||
])
|
||||
for (language, text) in samples {
|
||||
let editor = SyntaxCodeEditor(text: .constant(text), language: language)
|
||||
let view = UITextView()
|
||||
view.overrideUserInterfaceStyle = style
|
||||
view.text = text
|
||||
editor.makeCoordinator().highlight(view)
|
||||
let background = UIColor.secondarySystemGroupedBackground.resolvedColor(with: traits)
|
||||
view.textStorage.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: view.textStorage.length)) { color, _, _ in
|
||||
guard let color = color as? UIColor else { return XCTFail("Missing syntax color") }
|
||||
let foreground = color.resolvedColor(with: traits)
|
||||
let values = [luminance(foreground), luminance(background)].sorted()
|
||||
XCTAssertGreaterThanOrEqual((values[1] + 0.05) / (values[0] + 0.05), 4.5,
|
||||
"Low contrast for \(language), \(style), \(level)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func luminance(_ color: UIColor) -> Double {
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
XCTAssertTrue(color.getRed(&r, green: &g, blue: &b, alpha: &a))
|
||||
func linear(_ value: CGFloat) -> Double {
|
||||
let value = Double(value)
|
||||
return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,56 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class WebReaderBridgeTests: XCTestCase {
|
||||
func testGeneratedRSSContentFollowsAppearanceWithoutReloading() async throws {
|
||||
let source = RssSource(sourceUrl: "https://appearance-regression.test", sourceName: "测试")
|
||||
let loaded = expectation(description: "HTML loaded")
|
||||
let surface = RSSWebReaderView(
|
||||
mode: .html(content: "<body><p>订阅正文</p><a href='#'>链接</a><button>操作</button></body>", baseURL: nil),
|
||||
source: source, onTextChange: { _ in loaded.fulfill() }
|
||||
)
|
||||
let coordinator = surface.makeCoordinator()
|
||||
let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||
webView.navigationDelegate = coordinator
|
||||
let window = UIWindow(frame: webView.frame)
|
||||
let controller = UIViewController()
|
||||
controller.overrideUserInterfaceStyle = .dark
|
||||
controller.view.addSubview(webView)
|
||||
window.rootViewController = controller
|
||||
window.makeKeyAndVisible()
|
||||
defer {
|
||||
RSSWebReaderView.dismantleUIView(webView, coordinator: coordinator)
|
||||
window.isHidden = true
|
||||
}
|
||||
coordinator.installRulesAndLoad(mode: surface.mode, in: webView)
|
||||
await fulfillment(of: [loaded], timeout: 20)
|
||||
let appearanceScript = """
|
||||
[matchMedia('(prefers-color-scheme:dark)').matches,
|
||||
getComputedStyle(document.body).backgroundColor,
|
||||
getComputedStyle(document.body).color,
|
||||
getComputedStyle(document.querySelector('a')).color,
|
||||
getComputedStyle(document.documentElement).colorScheme].map(String).join('|')
|
||||
"""
|
||||
let dark = try await webView.evaluateJavaScript(appearanceScript)
|
||||
XCTAssertEqual(dark as? String, "true|rgb(17, 17, 19)|rgb(242, 242, 242)|rgb(158, 203, 255)|light dark")
|
||||
_ = try await webView.evaluateJavaScript("window.appearanceMarker = 'preserved'")
|
||||
controller.overrideUserInterfaceStyle = .light
|
||||
window.layoutIfNeeded()
|
||||
XCTAssertEqual(webView.traitCollection.userInterfaceStyle, .light)
|
||||
var light = try await webView.evaluateJavaScript(appearanceScript)
|
||||
for _ in 0..<50 {
|
||||
if (light as? String)?.hasPrefix("false|") == true { break }
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
light = try await webView.evaluateJavaScript(appearanceScript)
|
||||
}
|
||||
XCTAssertEqual(light as? String, "false|rgb(252, 252, 252)|rgb(23, 23, 23)|rgb(0, 0, 238)|light dark")
|
||||
let marker = try await webView.evaluateJavaScript("window.appearanceMarker")
|
||||
XCTAssertEqual(marker as? String, "preserved")
|
||||
// A source may intentionally override the generated article palette.
|
||||
_ = try await webView.evaluateJavaScript("document.head.insertAdjacentHTML('beforeend', '<style>body{color:rgb(120,30,40)}</style>')")
|
||||
let custom = try await webView.evaluateJavaScript("getComputedStyle(document.body).color")
|
||||
XCTAssertEqual(custom as? String, "rgb(120, 30, 40)")
|
||||
}
|
||||
|
||||
func testPageJavaScriptCanStoreVariablesAndEvaluateContentRules() async throws {
|
||||
let source = RssSource(sourceUrl: "https://bridge-regression.test/\(UUID().uuidString)", sourceName: "测试")
|
||||
let loaded = expectation(description: "HTML loaded")
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class DarkModeUITests: XCTestCase {
|
||||
func testPrimaryActionsKeepDarkFillInBothAppearances() throws {
|
||||
continueAfterFailure = false
|
||||
for theme in ["2", "1"] {
|
||||
let app = launch(["-uiDemoBookDetail", "-themeMode", theme])
|
||||
let read = app.buttons.matching(NSPredicate(format: "label IN %@", ["开始阅读", "继续阅读"])).firstMatch
|
||||
XCTAssertTrue(read.waitForExistence(timeout: 20))
|
||||
XCTAssertTrue(read.isEnabled)
|
||||
try assertReadableDarkFill(read, in: app)
|
||||
attach(app, name: "Book detail theme \(theme)")
|
||||
app.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
func testNightReaderSelectionsKeepDarkFillAndReadableLabels() throws {
|
||||
continueAfterFailure = false
|
||||
let app = launch([
|
||||
"-uiDemoReader", "-uiDemoReaderStyle", "-themeMode", "2",
|
||||
"-readerTheme", "night", "-readerUseBackgroundImage", "NO",
|
||||
"-lineSpacingExtra", "12", "-pageAnim", "0"
|
||||
])
|
||||
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 20))
|
||||
// Expand the sheet so the controls can be inspected without clipping.
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.48))
|
||||
.press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12)))
|
||||
let standard = app.buttons["标准"]
|
||||
XCTAssertTrue(standard.waitForExistence(timeout: 5))
|
||||
try assertReadableDarkFill(standard, in: app)
|
||||
attach(app, name: "Night reader spacing selection")
|
||||
let cover = app.buttons["覆盖"]
|
||||
for _ in 0..<16 {
|
||||
if cover.exists && cover.isHittable && cover.frame.maxY < app.frame.height - 50 { break }
|
||||
app.swipeUp()
|
||||
}
|
||||
XCTAssertTrue(cover.exists && cover.isHittable)
|
||||
try assertReadableDarkFill(cover, in: app)
|
||||
attach(app, name: "Night reader animation selection")
|
||||
}
|
||||
|
||||
func testDarkAppearanceAcrossTabsAndPresentedEditors() throws {
|
||||
continueAfterFailure = false
|
||||
let app = launch(["-themeMode", "2"])
|
||||
XCTAssertTrue(app.tabBars.buttons["书架"].waitForExistence(timeout: 20))
|
||||
for (tab, heading) in [("书架", "阅读,让时间有了形状"), ("发现", "下一本好书,在这里遇见"),
|
||||
("统计", "每一页,都算数"), ("我的", "按照你的方式阅读")] {
|
||||
app.tabBars.buttons[tab].tap()
|
||||
XCTAssertTrue(app.staticTexts[heading].waitForExistence(timeout: 5))
|
||||
if tab == "统计" {
|
||||
let today = app.otherElements["statistics.todayCard"]
|
||||
XCTAssertTrue(today.waitForExistence(timeout: 5))
|
||||
try assertReadableDarkFill(today, in: app)
|
||||
}
|
||||
attach(app, name: "Dark tab \(tab)")
|
||||
}
|
||||
app.buttons["profile.globe"].tap()
|
||||
XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 10))
|
||||
attach(app, name: "Dark source library")
|
||||
app.buttons["更多"].firstMatch.tap()
|
||||
app.buttons["新建书源"].tap()
|
||||
XCTAssertTrue(app.navigationBars["新建书源"].waitForExistence(timeout: 10))
|
||||
let basic = app.buttons["基础"]
|
||||
try assertReadableDarkFill(basic, in: app)
|
||||
let login = app.buttons["登录"]
|
||||
login.tap()
|
||||
try assertReadableDarkFill(login, in: app)
|
||||
attach(app, name: "Dark source category selection")
|
||||
attach(app, name: "Dark presented source editor")
|
||||
app.segmentedControls.buttons["JSON"].tap()
|
||||
XCTAssertTrue(app.textViews["syntaxCodeEditor"].waitForExistence(timeout: 5))
|
||||
attach(app, name: "Dark JSON editor")
|
||||
app.textViews["syntaxCodeEditor"].tap()
|
||||
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
||||
attach(app, name: "Dark editor keyboard and accessory toolbar")
|
||||
let visibleKeyboard = app.keyboards.firstMatch.frame.intersection(app.frame)
|
||||
if !visibleKeyboard.isNull && visibleKeyboard.width > 20 && visibleKeyboard.height > 20 {
|
||||
let keyboard = try luminances(app.keyboards.firstMatch, in: app)
|
||||
XCTAssertLessThan(keyboard[keyboard.count / 2], 0.15)
|
||||
}
|
||||
let accessory = app.buttons["完成"]
|
||||
let accessoryFrame = accessory.frame.intersection(app.frame)
|
||||
if accessory.isHittable && !accessoryFrame.isNull && accessoryFrame.width > 20 && accessoryFrame.height > 20 {
|
||||
try assertReadableDarkFill(accessory, in: app)
|
||||
}
|
||||
}
|
||||
|
||||
func testDarkSettingsRulesAndManagementPages() throws {
|
||||
continueAfterFailure = false
|
||||
let app = launch(["-uiDemoTab", "settings", "-themeMode", "2"])
|
||||
let routes: [(String, String, String?)] = [
|
||||
("textformat.size", "阅读设置", nil),
|
||||
("waveform", "在线朗读引擎", "新建"),
|
||||
("bookmark", "书签", nil),
|
||||
("clock", "阅读记录", nil),
|
||||
("link", "规则订阅", "新建"),
|
||||
("text.badge.checkmark", "净化规则", "新建规则"),
|
||||
("list.bullet.rectangle", "TXT目录规则", "新建"),
|
||||
("folder", "文件管理", nil),
|
||||
("gearshape", "其他设置", nil),
|
||||
("info.circle", "关于", nil)
|
||||
]
|
||||
for (icon, title, create) in routes {
|
||||
let entry = app.buttons["profile.\(icon)"]
|
||||
scrollTo(entry, in: app)
|
||||
entry.tap()
|
||||
XCTAssertTrue(app.navigationBars[title].waitForExistence(timeout: 10))
|
||||
try captureDarkPage(app, name: "Management \(title)")
|
||||
if let create {
|
||||
app.buttons[create].firstMatch.tap()
|
||||
XCTAssertTrue(app.buttons["取消"].waitForExistence(timeout: 5))
|
||||
try captureDarkPage(app, name: "Editor \(title)")
|
||||
if title == "净化规则" {
|
||||
app.segmentedControls.buttons["测试规则"].tap()
|
||||
try captureDarkPage(app, name: "Rule test form")
|
||||
}
|
||||
app.buttons["取消"].tap()
|
||||
}
|
||||
if title == "其他设置" {
|
||||
for (entry, destination) in [("网络与缓存设置", "网络与缓存"), ("章节缓存管理", "缓存管理"), ("日志与诊断", "日志与诊断")] {
|
||||
let link = app.buttons[entry]
|
||||
scrollTo(link, in: app)
|
||||
link.tap()
|
||||
XCTAssertTrue(app.navigationBars[destination].waitForExistence(timeout: 5))
|
||||
try captureDarkPage(app, name: "Settings \(destination)")
|
||||
app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap()
|
||||
}
|
||||
}
|
||||
if title == "关于" {
|
||||
let license = app.buttons["开源许可"]
|
||||
scrollTo(license, in: app)
|
||||
license.tap()
|
||||
XCTAssertTrue(app.navigationBars["开源许可"].waitForExistence(timeout: 5))
|
||||
try captureDarkPage(app, name: "Licenses")
|
||||
app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap()
|
||||
}
|
||||
app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap()
|
||||
XCTAssertTrue(app.scrollViews["profile.scroll"].waitForExistence(timeout: 5))
|
||||
}
|
||||
}
|
||||
|
||||
func testDarkSearchFiltersAndSourceImportSheets() throws {
|
||||
continueAfterFailure = false
|
||||
let app = launch(["-uiDemoTab", "search", "-themeMode", "2", "-uiAutomationSearch", "深色外观检查"])
|
||||
let cancelSearch = app.buttons.matching(NSPredicate(format: "label IN %@", ["取消", "Cancel"])).firstMatch
|
||||
if cancelSearch.waitForExistence(timeout: 3) { cancelSearch.tap() }
|
||||
XCTAssertTrue(app.buttons["搜索选项"].waitForExistence(timeout: 10))
|
||||
app.buttons["搜索选项"].tap()
|
||||
app.buttons["分组或书源"].tap()
|
||||
XCTAssertTrue(app.navigationBars["分组或书源"].waitForExistence(timeout: 5))
|
||||
try captureDarkPage(app, name: "Search group filter")
|
||||
app.segmentedControls.buttons["书源"].tap()
|
||||
try captureDarkPage(app, name: "Search source filter")
|
||||
app.buttons["取消"].tap()
|
||||
app.terminate()
|
||||
|
||||
app.launchArguments = ["-uiDemo", "-uiDemoTab", "sources", "-themeMode", "2"]
|
||||
app.launch()
|
||||
XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 10))
|
||||
app.buttons["排序"].tap()
|
||||
XCTAssertTrue(app.buttons["倒序"].waitForExistence(timeout: 5))
|
||||
try assertReadableDarkFill(app.buttons["倒序"], in: app)
|
||||
attach(app, name: "Source sorting menu")
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).tap()
|
||||
app.buttons["更多"].firstMatch.tap()
|
||||
app.buttons["URL 或粘贴 JSON"].tap()
|
||||
XCTAssertTrue(app.buttons["取消"].waitForExistence(timeout: 5))
|
||||
try captureDarkPage(app, name: "Source text import")
|
||||
}
|
||||
|
||||
func testDarkRSSAndBlackPhotoViewerInLightAppearance() throws {
|
||||
continueAfterFailure = false
|
||||
let app = launch(["-uiDemoTab", "rss", "-themeMode", "2"])
|
||||
XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 10))
|
||||
for tab in ["订阅", "收藏", "记录"] {
|
||||
app.segmentedControls.buttons[tab].tap()
|
||||
try captureDarkPage(app, name: "RSS \(tab)")
|
||||
}
|
||||
app.segmentedControls.buttons["订阅"].tap()
|
||||
app.buttons["RSS操作"].tap()
|
||||
app.buttons["新建RSS源"].tap()
|
||||
XCTAssertTrue(app.navigationBars["新建RSS源"].waitForExistence(timeout: 5))
|
||||
try captureDarkPage(app, name: "RSS source editor")
|
||||
app.terminate()
|
||||
|
||||
app.launchArguments = ["-uiDemo", "-uiDemoTab", "rss-photo", "-themeMode", "1"]
|
||||
app.launch()
|
||||
XCTAssertTrue(app.alerts["图片"].waitForExistence(timeout: 10))
|
||||
app.alerts["图片"].buttons["好"].tap()
|
||||
let error = app.staticTexts["图片加载失败"]
|
||||
XCTAssertTrue(error.waitForExistence(timeout: 5))
|
||||
try assertReadableDarkFill(error, in: app)
|
||||
let done = app.navigationBars.buttons["完成"]
|
||||
XCTAssertTrue(done.exists && done.isEnabled)
|
||||
attach(app, name: "Photo viewer navigation and content")
|
||||
let toolbar = try luminances(done, in: app)
|
||||
XCTAssertGreaterThanOrEqual((toolbar[toolbar.count * 98 / 100] + 0.05) / (toolbar[toolbar.count * 2 / 100] + 0.05), 4.5)
|
||||
attach(app, name: "Readable photo error on black canvas")
|
||||
}
|
||||
|
||||
func testNightReaderMenusAndSettings() throws {
|
||||
continueAfterFailure = false
|
||||
let app = launch(["-uiDemoReader", "-uiDemoReaderMenu", "-readerTheme", "night", "-themeMode", "2", "-readerUseBackgroundImage", "NO"])
|
||||
XCTAssertTrue(app.buttons["目录"].waitForExistence(timeout: 15))
|
||||
try captureDarkPage(app, name: "Night reader menu")
|
||||
app.buttons["目录"].tap()
|
||||
XCTAssertTrue(app.navigationBars["目录"].waitForExistence(timeout: 5))
|
||||
try captureDarkPage(app, name: "Night table of contents")
|
||||
app.buttons["关闭"].tap()
|
||||
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
app.buttons["设置"].tap()
|
||||
XCTAssertTrue(app.navigationBars["阅读设置"].waitForExistence(timeout: 5))
|
||||
try captureDarkPage(app, name: "Night reader settings")
|
||||
let click = app.buttons["点击区域设置"]
|
||||
scrollTo(click, in: app)
|
||||
click.tap()
|
||||
XCTAssertTrue(app.navigationBars["点击区域设置"].waitForExistence(timeout: 5))
|
||||
try captureDarkPage(app, name: "Night click action grid")
|
||||
}
|
||||
|
||||
func testAppearanceMenuRestoresSystemAndUpdatesVisiblePage() throws {
|
||||
continueAfterFailure = false
|
||||
// Launch arguments override writes to UserDefaults; leave themeMode mutable here.
|
||||
let app = launch(["-uiDemoTab", "settings"])
|
||||
let appearance = app.buttons["profile.appearance"]
|
||||
XCTAssertTrue(appearance.waitForExistence(timeout: 10))
|
||||
appearance.tap()
|
||||
let system = app.buttons["跟随系统"]
|
||||
XCTAssertTrue(system.waitForExistence(timeout: 3))
|
||||
system.tap()
|
||||
let followsSystem = expectation(for: NSPredicate(format: "value == %@", "跟随系统"), evaluatedWith: appearance)
|
||||
wait(for: [followsSystem], timeout: 5)
|
||||
let initial = try luminances(app, in: app)
|
||||
let systemIsDark = initial[initial.count / 2] < 0.15
|
||||
for (choice, expectsDark) in [("深色", true), ("浅色", false), ("跟随系统", systemIsDark)] {
|
||||
appearance.tap()
|
||||
let option = app.buttons[choice]
|
||||
if !option.waitForExistence(timeout: 2), app.buttons["外观"].exists { app.buttons["外观"].tap() }
|
||||
XCTAssertTrue(option.waitForExistence(timeout: 3))
|
||||
option.tap()
|
||||
let changed = expectation(for: NSPredicate(format: "value == %@", choice), evaluatedWith: appearance)
|
||||
wait(for: [changed], timeout: 5)
|
||||
let values = try luminances(app, in: app)
|
||||
let median = values[values.count / 2]
|
||||
if expectsDark { XCTAssertLessThan(median, 0.15) }
|
||||
else { XCTAssertGreaterThan(median, 0.6) }
|
||||
attach(app, name: "Live appearance \(choice)")
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollTo(_ element: XCUIElement, in app: XCUIApplication) {
|
||||
for _ in 0..<10 {
|
||||
if element.exists && element.isHittable { return }
|
||||
if element.exists && element.frame.maxY < app.frame.height / 2 {
|
||||
app.swipeDown()
|
||||
} else {
|
||||
app.swipeUp()
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(element.exists && element.isHittable)
|
||||
}
|
||||
|
||||
private func captureDarkPage(_ app: XCUIApplication, name: String) throws {
|
||||
let values = try luminances(app, in: app)
|
||||
XCTAssertLessThan(values[values.count / 2], 0.15, "Unexpected light page or sheet: \(name)")
|
||||
attach(app, name: name)
|
||||
}
|
||||
|
||||
// Check both the rendered label contrast and the fill's actual brightness.
|
||||
// A readable white surface must still fail the dark appearance regression.
|
||||
private func assertReadableDarkFill(_ element: XCUIElement, in app: XCUIApplication,
|
||||
file: StaticString = #filePath, line: UInt = #line) throws {
|
||||
let luminances = try luminances(element, in: app, file: file, line: line)
|
||||
let low = luminances[luminances.count * 2 / 100]
|
||||
let high = luminances[luminances.count * 98 / 100]
|
||||
XCTAssertGreaterThanOrEqual((high + 0.05) / (low + 0.05), 4.5,
|
||||
"Rendered label has insufficient contrast", file: file, line: line)
|
||||
XCTAssertLessThan(luminances[luminances.count / 2], 0.15,
|
||||
"Filled control or card is too bright for the dark palette", file: file, line: line)
|
||||
}
|
||||
|
||||
private func luminances(_ element: XCUIElement, in app: XCUIApplication,
|
||||
file: StaticString = #filePath, line: UInt = #line) throws -> [Double] {
|
||||
let image = try XCTUnwrap(app.screenshot().image.cgImage, file: file, line: line)
|
||||
let scaleX = CGFloat(image.width) / app.frame.width
|
||||
let scaleY = CGFloat(image.height) / app.frame.height
|
||||
let bounds = element.frame.intersection(app.frame).insetBy(dx: 8, dy: 8)
|
||||
let rect = CGRect(x: bounds.minX * scaleX, y: bounds.minY * scaleY,
|
||||
width: bounds.width * scaleX, height: bounds.height * scaleY).integral
|
||||
let crop = try XCTUnwrap(image.cropping(to: rect), file: file, line: line)
|
||||
var bytes = [UInt8](repeating: 0, count: crop.width * crop.height * 4)
|
||||
try bytes.withUnsafeMutableBytes { buffer in
|
||||
let context = try XCTUnwrap(CGContext(
|
||||
data: buffer.baseAddress, width: crop.width, height: crop.height,
|
||||
bitsPerComponent: 8, bytesPerRow: crop.width * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
|
||||
), file: file, line: line)
|
||||
context.draw(crop, in: CGRect(x: 0, y: 0, width: crop.width, height: crop.height))
|
||||
}
|
||||
func linear(_ component: UInt8) -> Double {
|
||||
let value = Double(component) / 255
|
||||
return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
let sampleStride = crop.width * crop.height > 250_000 ? 64 : 4
|
||||
return stride(from: 0, to: bytes.count, by: sampleStride).map {
|
||||
0.2126 * linear(bytes[$0]) + 0.7152 * linear(bytes[$0 + 1]) + 0.0722 * linear(bytes[$0 + 2])
|
||||
}.sorted()
|
||||
}
|
||||
|
||||
private func launch(_ arguments: [String]) -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-uiDemo"] + arguments
|
||||
app.launch()
|
||||
return app
|
||||
}
|
||||
|
||||
private func attach(_ app: XCUIApplication, name: String) {
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user