// // 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) } }