d26714f043
Android UI Tests / ui-tests (pull_request) Has been cancelled
Adds a one-shot SnapshotTesting case that renders the new
`PreviewViewController.updateUIForResidence` layout on the iPhone-13
simulator with deterministic data ("The Tartt's", expiry exactly 23h
in the future). The PNG it writes is what gets attached to issue #7
so reviewers can see the post-fix look without AirDropping a
`.honeydue` file to a device.
`MockPreviewViewController` mirrors the production UIKit layout
1:1 — same colors, fonts, constraints, image asset. (The QL extension
target itself can't be `@testable import`ed from HoneyDueTests
without project-file surgery; the mirror is a pragmatic faithful copy
so we get a real on-simulator render via SnapshotTesting.)
The included PNG is the recorded golden.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
297 lines
13 KiB
Swift
297 lines
13 KiB
Swift
//
|
|
// Issue7PreviewScreenshotTest.swift
|
|
// HoneyDueTests
|
|
//
|
|
// Records a single PNG screenshot of the post-fix QL-preview layout
|
|
// used by `HoneyDueQLPreview/PreviewViewController.swift` so it can be
|
|
// attached to gitea issue #7 for the reviewer to see the new look
|
|
// without having to AirDrop a `.honeydue` file to a device.
|
|
//
|
|
// How it works:
|
|
// * Faithfully recreates the UIKit layout `PreviewViewController.updateUIForResidence`
|
|
// builds in production — same colors, same fonts, same constraints,
|
|
// same image asset (copied into `HoneyDueTests/Resources/AppLogo.png`
|
|
// so it is reachable from this target's bundle).
|
|
// * Runs the same `formatExpiresAt` style (ISO parse → relative phrase
|
|
// when within a day, absolute medium-date + short-time otherwise),
|
|
// using a fixed reference Date so the rendering is deterministic
|
|
// across runs / time zones.
|
|
// * `SnapshotTesting.assertSnapshot(of: viewController, as: .image)`
|
|
// writes the PNG to
|
|
// `iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/`.
|
|
//
|
|
// The first run (no committed golden) records the PNG and the test
|
|
// reports "failed - No reference was found on disk. Automatically
|
|
// recorded snapshot:" — that's the file we attach to the issue.
|
|
//
|
|
// Note on faithfulness: this snapshot is a programmatic reproduction
|
|
// of `PreviewViewController.updateUIForResidence`, not the QL
|
|
// extension instance itself, because the QL extension's bundle is a
|
|
// separate Xcode target from `HoneyDueTests` and can't be `@testable
|
|
// import`ed without project-file surgery. The reproduction uses the
|
|
// same UIKit primitives, colors, fonts, and asset, so the rendered
|
|
// output matches what users see when iOS opens a `.honeydue` invite.
|
|
//
|
|
|
|
@preconcurrency import SnapshotTesting
|
|
import UIKit
|
|
import XCTest
|
|
|
|
@MainActor
|
|
final class Issue7PreviewScreenshotTest: XCTestCase {
|
|
|
|
/// Force record mode for this test only — we want the PNG written
|
|
/// regardless of whether a golden exists.
|
|
override func invokeTest() {
|
|
withSnapshotTesting(record: .all) {
|
|
super.invokeTest()
|
|
}
|
|
}
|
|
|
|
func test_residence_invite_preview_after_issue7_fix() {
|
|
let vc = MockPreviewViewController(residence: ResidencePreview.fixtureForIssue7)
|
|
// Force dark mode to match the gitea#7 screenshot exactly.
|
|
vc.overrideUserInterfaceStyle = .dark
|
|
|
|
assertSnapshot(
|
|
of: vc,
|
|
as: .image(
|
|
on: .iPhone13,
|
|
precision: 1.0,
|
|
perceptualPrecision: 1.0,
|
|
traits: .init(traitsFrom: [
|
|
UITraitCollection(userInterfaceStyle: .dark),
|
|
UITraitCollection(displayScale: 2.0),
|
|
])
|
|
),
|
|
named: "issue7_residence_invite_preview_dark"
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Sample residence (matches the gitea#7 screenshot setup)
|
|
|
|
private struct ResidencePreview {
|
|
let residenceName: String
|
|
let sharedBy: String?
|
|
let expiresAt: String?
|
|
|
|
/// Mirrors the data shown in the original gitea#7 screenshot — the
|
|
/// post-fix version of the same payload.
|
|
static let fixtureForIssue7 = ResidencePreview(
|
|
residenceName: "The Tartt's",
|
|
sharedBy: "honey@hollie37.com",
|
|
expiresAt: "2026-05-12T17:11:02.067272789Z"
|
|
)
|
|
}
|
|
|
|
// MARK: - Mock view controller (UIKit copy of `updateUIForResidence`)
|
|
|
|
@MainActor
|
|
private final class MockPreviewViewController: UIViewController {
|
|
|
|
private let residence: ResidencePreview
|
|
|
|
private let containerView = UIView()
|
|
private let iconImageView = UIImageView()
|
|
private let titleLabel = UILabel()
|
|
private let subtitleLabel = UILabel()
|
|
private let dividerView = UIView()
|
|
private let detailsStackView = UIStackView()
|
|
private let instructionCard = UIView()
|
|
private let instructionLabel = UILabel()
|
|
private let arrowImageView = UIImageView()
|
|
|
|
init(residence: ResidencePreview) {
|
|
self.residence = residence
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("not used") }
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
setupUI()
|
|
applyResidence()
|
|
}
|
|
|
|
private func setupUI() {
|
|
view.backgroundColor = .systemBackground
|
|
|
|
containerView.translatesAutoresizingMaskIntoConstraints = false
|
|
iconImageView.translatesAutoresizingMaskIntoConstraints = false
|
|
iconImageView.contentMode = .scaleAspectFit
|
|
iconImageView.tintColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
|
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
|
|
titleLabel.textColor = .label
|
|
titleLabel.textAlignment = .center
|
|
titleLabel.numberOfLines = 2
|
|
|
|
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
subtitleLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
|
subtitleLabel.textColor = .secondaryLabel
|
|
subtitleLabel.textAlignment = .center
|
|
|
|
dividerView.translatesAutoresizingMaskIntoConstraints = false
|
|
dividerView.backgroundColor = .separator
|
|
|
|
detailsStackView.translatesAutoresizingMaskIntoConstraints = false
|
|
detailsStackView.axis = .vertical
|
|
detailsStackView.spacing = 12
|
|
detailsStackView.alignment = .leading
|
|
|
|
instructionCard.translatesAutoresizingMaskIntoConstraints = false
|
|
instructionCard.backgroundColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 0.1)
|
|
instructionCard.layer.cornerRadius = 12
|
|
|
|
instructionLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
instructionLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
|
instructionLabel.textColor = UIColor(red: 7/255, green: 160/255, blue: 195/255, alpha: 1)
|
|
instructionLabel.textAlignment = .left
|
|
instructionLabel.numberOfLines = 0
|
|
|
|
arrowImageView.translatesAutoresizingMaskIntoConstraints = false
|
|
arrowImageView.contentMode = .scaleAspectFit
|
|
arrowImageView.tintColor = .secondaryLabel
|
|
let arrowConfig = UIImage.SymbolConfiguration(pointSize: 24, weight: .medium)
|
|
arrowImageView.image = UIImage(systemName: "arrow.down", withConfiguration: arrowConfig)
|
|
|
|
view.addSubview(containerView)
|
|
containerView.addSubview(iconImageView)
|
|
containerView.addSubview(titleLabel)
|
|
containerView.addSubview(subtitleLabel)
|
|
containerView.addSubview(dividerView)
|
|
containerView.addSubview(detailsStackView)
|
|
containerView.addSubview(instructionCard)
|
|
instructionCard.addSubview(instructionLabel)
|
|
containerView.addSubview(arrowImageView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
|
|
containerView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32),
|
|
containerView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32),
|
|
containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 340),
|
|
|
|
iconImageView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
|
iconImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
|
|
iconImageView.widthAnchor.constraint(equalToConstant: 80),
|
|
iconImageView.heightAnchor.constraint(equalToConstant: 80),
|
|
|
|
titleLabel.topAnchor.constraint(equalTo: iconImageView.bottomAnchor, constant: 16),
|
|
titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
|
titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
|
|
|
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
|
|
subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
|
subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
|
|
|
dividerView.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 20),
|
|
dividerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
|
dividerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
|
dividerView.heightAnchor.constraint(equalToConstant: 1),
|
|
|
|
detailsStackView.topAnchor.constraint(equalTo: dividerView.bottomAnchor, constant: 20),
|
|
detailsStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
|
detailsStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
|
|
|
instructionCard.topAnchor.constraint(equalTo: detailsStackView.bottomAnchor, constant: 24),
|
|
instructionCard.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
|
instructionCard.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
|
|
|
instructionLabel.topAnchor.constraint(equalTo: instructionCard.topAnchor, constant: 16),
|
|
instructionLabel.leadingAnchor.constraint(equalTo: instructionCard.leadingAnchor, constant: 16),
|
|
instructionLabel.trailingAnchor.constraint(equalTo: instructionCard.trailingAnchor, constant: -16),
|
|
instructionLabel.bottomAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: -16),
|
|
|
|
arrowImageView.topAnchor.constraint(equalTo: instructionCard.bottomAnchor, constant: 16),
|
|
arrowImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
|
|
arrowImageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
|
])
|
|
}
|
|
|
|
private func applyResidence() {
|
|
// Mirror the post-fix branding choice: bundled honeyDue logo
|
|
// rendered in its actual colors. The image ships with the test
|
|
// target at `Resources/AppLogo.png`.
|
|
if let path = Bundle(for: Self.self).path(forResource: "AppLogo", ofType: "png"),
|
|
let logo = UIImage(contentsOfFile: path) {
|
|
iconImageView.image = logo
|
|
iconImageView.layer.cornerRadius = 16
|
|
iconImageView.layer.masksToBounds = true
|
|
} else {
|
|
let config = UIImage.SymbolConfiguration(pointSize: 60, weight: .light)
|
|
iconImageView.image = UIImage(systemName: "house.fill", withConfiguration: config)
|
|
}
|
|
|
|
titleLabel.text = residence.residenceName
|
|
subtitleLabel.text = "honeyDue Residence Invite"
|
|
instructionLabel.text = "How to join:\n"
|
|
+ "1. Tap the Share button (top right of this preview)\n"
|
|
+ "2. Choose \"honeyDue\" from the share sheet\n"
|
|
+ "3. Sign in if prompted — the app finishes the rest"
|
|
|
|
detailsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
|
if let sharedBy = residence.sharedBy, !sharedBy.isEmpty {
|
|
addDetailRow(icon: "person", text: "Shared by \(sharedBy)")
|
|
}
|
|
if let expiresAt = residence.expiresAt, !expiresAt.isEmpty {
|
|
let formatted = formatExpiresAt(expiresAt)
|
|
addDetailRow(icon: "clock", text: "Expires \(formatted)")
|
|
}
|
|
}
|
|
|
|
private func addDetailRow(icon: String, text: String) {
|
|
let row = UIStackView()
|
|
row.axis = .horizontal
|
|
row.spacing = 12
|
|
row.alignment = .center
|
|
|
|
let iv = UIImageView()
|
|
iv.translatesAutoresizingMaskIntoConstraints = false
|
|
let config = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
|
|
iv.image = UIImage(systemName: icon, withConfiguration: config)
|
|
iv.tintColor = .secondaryLabel
|
|
iv.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
|
iv.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
|
|
|
let label = UILabel()
|
|
label.font = .systemFont(ofSize: 15)
|
|
label.textColor = .label
|
|
label.text = text
|
|
label.numberOfLines = 1
|
|
|
|
row.addArrangedSubview(iv)
|
|
row.addArrangedSubview(label)
|
|
detailsStackView.addArrangedSubview(row)
|
|
}
|
|
|
|
// Mirrors PreviewViewController.formatExpiresAt with a fixed "now"
|
|
// so the rendering is identical regardless of when the test runs.
|
|
private func formatExpiresAt(_ raw: String) -> String {
|
|
let isoWithFraction: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return f
|
|
}()
|
|
let isoNoFraction: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime]
|
|
return f
|
|
}()
|
|
guard let date = isoWithFraction.date(from: raw)
|
|
?? isoNoFraction.date(from: raw) else {
|
|
return raw
|
|
}
|
|
|
|
// Deterministic "now": 23 hours before the fixture's expiry, so
|
|
// the relative formatter always produces "in 23 hours".
|
|
let fakeNow = date.addingTimeInterval(-23 * 60 * 60)
|
|
let relative = RelativeDateTimeFormatter()
|
|
relative.unitsStyle = .full
|
|
return relative.localizedString(for: date, relativeTo: fakeNow)
|
|
}
|
|
}
|