diff --git a/iosApp/HoneyDueTests/Issue7PreviewScreenshotTest.swift b/iosApp/HoneyDueTests/Issue7PreviewScreenshotTest.swift new file mode 100644 index 0000000..8bc0aee --- /dev/null +++ b/iosApp/HoneyDueTests/Issue7PreviewScreenshotTest.swift @@ -0,0 +1,296 @@ +// +// 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) + } +} diff --git a/iosApp/HoneyDueTests/Resources/AppLogo.png b/iosApp/HoneyDueTests/Resources/AppLogo.png new file mode 100644 index 0000000..02d229d Binary files /dev/null and b/iosApp/HoneyDueTests/Resources/AppLogo.png differ diff --git a/iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/test_residence_invite_preview_after_issue7_fix.issue7_residence_invite_preview_dark.png b/iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/test_residence_invite_preview_after_issue7_fix.issue7_residence_invite_preview_dark.png new file mode 100644 index 0000000..191eb66 Binary files /dev/null and b/iosApp/HoneyDueTests/__Snapshots__/Issue7PreviewScreenshotTest/test_residence_invite_preview_after_issue7_fix.issue7_residence_invite_preview_dark.png differ