Files
reimburse/tests/InterfaceSmokeTests.swift
T
2026-09-20 16:31:24 +08:00

307 lines
20 KiB
Swift

import AppKit
import SwiftUI
private struct EmptyPayeeStorage: PayeeStorage {
func read() throws -> Data? { nil }
func write(_ data: Data) throws {}
func delete() throws {}
}
private final class TestWindow: NSWindow {
override var canBecomeKey: Bool { true }
}
@main
struct InterfaceSmokeTests {
@MainActor static func main() throws {
_ = NSApplication.shared
NSApp.setActivationPolicy(.accessory)
let output = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
let store = WorkspaceStore(storageURL: output.appendingPathComponent("isolated-workspace"), payeeStorage: EmptyPayeeStorage())
let fixture = output.appendingPathComponent("receipt.png")
let receipt = NSImage(size: NSSize(width: 640, height: 420))
receipt.lockFocus()
NSColor.white.setFill()
NSRect(x: 0, y: 0, width: 640, height: 420).fill()
("电子发票\n\n测试材料 · 交通费用\n\n金额 ¥128.00\n\n2026-09-20" as NSString).draw(
in: NSRect(x: 36, y: 36, width: 560, height: 340),
withAttributes: [.font: NSFont.systemFont(ofSize: 25), .foregroundColor: NSColor.black])
receipt.unlockFocus()
let bitmap = NSBitmapImageRep(data: receipt.tiffRepresentation!)!
try bitmap.representation(using: .png, properties: [:])!.write(to: fixture)
try checkImageLoading(fixture: fixture, output: output)
let layout = try checkCellSelection()
var ocr = OCRData()
ocr.status = "success"; ocr.amounts = ["128.00"]; ocr.merchants = ["测试交通服务有限公司"]; ocr.dates = ["2026-09-20"]
func material(_ index: Int, type: String) -> Material {
Material(id: "\(type)-\(index)", name: "\(index)-测试交通费用凭证.pdf", path: fixture.path, type: type,
size: 2048, matched: false, previewPath: fixture.path, ocr: ocr,
displayAmount: 128, sortDate: "2026-09-20", issueDate: "2026-09-20")
}
var workspace = Workspace(rootPath: "九月项目报销")
workspace.invoices = (1...12).map { material($0, type: "invoice") }
workspace.payments = (1...10).map { material($0, type: "payment") }
workspace.warnings = (1...5).map { "材料 \($0) 日期待核对" }
workspace.directoryPaymentTotal = 1280
for index in 0..<7 {
var invoice = workspace.invoices[index], payment = workspace.payments[index]
invoice.matched = true; payment.matched = true
workspace.invoices[index] = invoice; workspace.payments[index] = payment
workspace.matches.append(MatchGroup(id: "match-\(index)", invoices: [invoice], payments: [payment],
category: expenseCategories[index], matchType: index == 0 ? "auto" : "manual",
score: 100, reasons: [], paymentTotal: 128, expenseAmount: 128))
}
store.state = workspace
store.updateCategory(workspace.matches[0], category: "住宿")
let moved = store.presentation.categoryColumns(for: store.state.matches)
precondition(!moved.contains(where: { $0.category == "餐饮" }))
precondition(moved.first(where: { $0.category == "住宿" })!.matches.count == 2)
precondition(store.state.matches.map(\.id) == workspace.matches.map(\.id))
store.updateCategory(store.state.matches[0], category: "餐饮")
precondition(store.presentation.categoryColumns(for: store.state.matches).map(\.id) == expenseCategories)
print("Changing category updates columns without changing match identifiers or export order")
let model = AdminStore()
model.access = AdminAccess(userId: "test", platform: true, admin: true, projects: [], groups: [])
model.expenses = (1...6).map { index in
AdminExpense(id: "\(index)", groupId: 1, projectId: 1, userId: "fixture",
name: "测试申请人\(index)", team: "制片组", projectName: "测试项目",
type: "交通差旅", amountCents: 12800, amountText: "128.00", note: "",
status: "待审核", state: "pending", date: "2026-09-20", version: 1, files: [:], events: [])
}
model.policies = [AdminExpensePolicy(type: "交通差旅", description: "项目交通费用", tips: "提供行程材料",
requiredKinds: [], materials: [], active: true)]
for dark in [false, true] {
NSApp.appearance = NSAppearance(named: dark ? .darkAqua : .aqua)
var pairWorkspace = workspace
pairWorkspace.matches = []
pairWorkspace.invoices = (1...12).map { material($0, type: "invoice") }
pairWorkspace.payments = (1...12).map { material($0, type: "payment") }
store.state = pairWorkspace
let invoiceIDs = WorkspaceFilter.materials(pairWorkspace.invoices, query: "", order: .date).map(\.id)
let paymentIDs = WorkspaceFilter.materials(pairWorkspace.payments, query: "", order: .date).map(\.id)
for (invoiceCount, paymentCount) in [(1, 1), (1, 6), (6, 1), (6, 6)] {
store.selectedInvoices = Set(invoiceIDs.prefix(invoiceCount))
store.selectedPayments = Set(paymentIDs.prefix(paymentCount))
try render(PairPage().environmentObject(store), size: CGSize(width: 1280, height: 800),
name: "pair-connections-\(invoiceCount)x\(paymentCount)-\(dark)", output: output)
}
store.selectedInvoices = []; store.selectedPayments = []
for size in [CGSize(width: 1280, height: 800), CGSize(width: 1024, height: 640), CGSize(width: 900, height: 560)] {
let suffix = "\(dark ? "dark" : "light")-\(Int(size.width))"
store.state = Workspace()
try render(ScanPage(confirmClear: .constant(false)).environmentObject(store), size: size, name: "empty-\(suffix)", output: output)
store.state = workspace
try render(ScanPage(confirmClear: .constant(false)).environmentObject(store), size: size, name: "scan-\(suffix)", output: output)
try render(PairPage().environmentObject(store), size: size, name: "pair-\(suffix)", output: output)
try render(MatchedPage().environmentObject(store), size: size, name: "matched-\(suffix)", output: output)
try render(AdminExpensesTab(model: model), size: size, name: "admin-\(suffix)", output: output)
try render(AdminPoliciesTab(model: model), size: size, name: "policies-\(suffix)", output: output)
for page in WorkspacePage.allCases {
store.page = page
try render(ContentView().environmentObject(store), size: size, name: "workspace-\(page.id)-\(suffix)", output: output)
}
}
var columnWorkspace = workspace
columnWorkspace.matches = (0..<12).map { index in
var match = workspace.matches[index % 3]
match.id = "column-\(index)"
return match
}
columnWorkspace.matches[0].payments = Array(workspace.payments.prefix(4))
let noisyCandidates = ["载次数", "名称:测试购买方有限公司", "全称", "一站式企业出行与商旅平台"]
for index in columnWorkspace.matches.indices {
columnWorkspace.matches[index].invoices[0].ocr.merchants = [noisyCandidates[index % noisyCandidates.count]]
}
store.state = columnWorkspace
precondition(store.presentation.categoryColumns(for: store.state.matches).map(\.id) == Array(expenseCategories.prefix(3)))
try render(MatchedPage().environmentObject(store), size: CGSize(width: 1024, height: 640),
name: "matched-populated-types-\(dark)", output: output)
try render(MatchedCategoryBoard(columns: store.presentation.categoryColumns(for: store.state.matches),
selection: .constant(MatchedPPTSelection(ids: ["column-0", "column-1"])))
.environmentObject(store), size: CGSize(width: 1900, height: 720), name: "category-columns-\(dark)", output: output)
try render(MatchedCategoryBoard(columns: store.presentation.categoryColumns(for: store.state.matches),
selection: .constant(MatchedPPTSelection(ids: ["column-0", "column-1"])))
.environmentObject(store), size: CGSize(width: 1000, height: 560), name: "category-compact-\(dark)", output: output)
try render(MatchedGroupInspector(matchID: "column-0", orderedIDs: columnWorkspace.matches.map(\.id),
selection: .constant(MatchedPPTSelection(ids: ["column-0"])),
navigate: { _ in }).environmentObject(store),
size: AppTheme.sheetSize(width: 1040, height: 740), name: "matched-inspector-\(dark)", output: output)
store.state = Workspace()
try render(MatchedPage().environmentObject(store), size: CGSize(width: 900, height: 560),
name: "matched-empty-\(dark)", output: output)
store.state = workspace
store.openExpense(selectedMatchIDs: Set(workspace.matches.map(\.id)))
let sheetSize = AppTheme.sheetSize(width: 900, height: 700)
try render(ExpenseSheet().environmentObject(store), size: sheetSize, name: "expense-\(dark)", output: output)
try render(AdminLoginView(model: AdminStore()), size: CGSize(width: 1024, height: 640), name: "login-\(dark)", output: output)
try render(ExpenseTemplateManager().environmentObject(store),
size: AppTheme.sheetSize(width: 1100, height: 760), name: "templates-\(dark)", output: output)
try render(ExpenseSpreadsheetPreview(layout: layout, selectedReference: "A1", highlightedRow: 2,
onSelectCell: { _ in }),
size: CGSize(width: 320, height: 500), name: "interactive-sheet-\(dark)", output: output)
}
print("Native light/dark compact/desktop screenshots passed: \(output.path)")
}
@MainActor static func checkCellSelection() throws -> SpreadsheetLayout {
let style = SpreadsheetStyle(font: "Arial", fontSize: 14, bold: false, italic: false,
underline: false, strike: false, color: "000000", fill: "FFFFFF",
horizontal: "left", vertical: "center", wrap: false, shrink: false,
rotation: 0, indent: 0, borders: [:], accounting: false)
let cells = (0..<8).map { row in
SpreadsheetCell(reference: "A\(row + 1)", x: 0, y: Double(row * 50), width: 240, height: 50,
text: "A\(row + 1) 测试单元格", style: style, overflowLeft: 0, overflowRight: 240)
}
let sheet = SpreadsheetSheet(name: "测试报销模板", width: 240, height: 400, cells: cells)
for scale in [0.5, 1, 1.5] {
let canvas = SpreadsheetCanvasView(sheet: sheet, scale: scale)
let window = NSWindow(contentRect: canvas.frame, styleMask: [.borderless], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentView = canvas
var selected: String?
canvas.onSelectCell = { selected = $0 }
let location = canvas.convert(NSPoint(x: 100 * scale, y: 75 * scale), to: nil)
let event = NSEvent.mouseEvent(with: .leftMouseUp, location: location, modifierFlags: [],
timestamp: 0, windowNumber: window.windowNumber, context: nil,
eventNumber: 1, clickCount: 1, pressure: 1)!
canvas.mouseUp(with: event)
precondition(selected == "A2", "Scaled cell hit testing failed")
window.contentView = nil
window.close()
}
print("Spreadsheet cell selection passed at 50%, 100% and 150% zoom")
return try SpreadsheetLayout(version: 4, sheets: [sheet], warnings: []).validated()
}
@MainActor static func checkImageLoading(fixture: URL, output: URL) throws {
let pdf = output.appendingPathComponent("two-pages.pdf")
let data = NSMutableData()
var page = CGRect(x: 0, y: 0, width: 400, height: 600)
let context = CGContext(consumer: CGDataConsumer(data: data)!, mediaBox: &page, nil)!
for color in [CGColor(red: 1, green: 0, blue: 0, alpha: 1), CGColor(red: 0, green: 0, blue: 1, alpha: 1)] {
context.beginPDFPage(nil)
context.setFillColor(color)
context.fill(page)
context.endPDFPage()
}
context.closePDF()
try (data as Data).write(to: pdf)
var completed = false
Task { @MainActor in
let thumbnail = await MaterialImageLoader.load(fixture, pixels: 160)
precondition(thumbnail != nil && max(thumbnail!.size.width, thumbnail!.size.height) <= 160)
let image = await MaterialImageLoader.load(pdf, pixels: 300)
precondition(image != nil)
let bitmap = NSBitmapImageRep(data: image!.tiffRepresentation!)!
let center = bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2)!.usingColorSpace(.deviceRGB)!
precondition(center.redComponent > 0.9 && center.blueComponent < 0.1, "Preview must use the first PDF page")
let missing = await MaterialImageLoader.load(output.appendingPathComponent("missing.png"), pixels: 160)
precondition(missing == nil)
let cancelled = Task { await MaterialImageLoader.load(fixture, pixels: 160) }
cancelled.cancel()
let result = await cancelled.value
precondition(result == nil)
completed = true
}
let deadline = Date().addingTimeInterval(15)
while !completed && Date() < deadline { RunLoop.current.run(until: Date().addingTimeInterval(0.02)) }
precondition(completed, "Image loading timed out")
print("Async image downsampling, PDF first page, missing files and cancellation passed")
}
@MainActor static func render<V: View>(_ view: V, size: CGSize, name: String, output: URL) throws {
let dark = NSApp.appearance?.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
let host = NSHostingView(rootView: view.tint(AppTheme.accent)
.environment(\.colorScheme, dark ? .dark : .light)
.background(Color(nsColor: .windowBackgroundColor)))
let window = TestWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.borderless],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentView = host
window.appearance = NSApp.appearance
window.backgroundColor = .windowBackgroundColor
window.orderFront(nil)
host.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.35))
host.layoutSubtreeIfNeeded()
if name == "pair-light-900" || name == "matched-light-900" {
window.makeKey()
NotificationCenter.default.post(name: WorkspaceCommand.find, object: nil)
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
precondition((window.firstResponder as? NSTextView)?.isFieldEditor == true,
"Find command did not focus the search field: \(name)")
print("Search keyboard focus passed: \(name)")
}
precondition(host.bounds.width <= size.width + 1 && host.bounds.height <= size.height + 1,
"View exceeds viewport: \(name), \(host.bounds.size)")
let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds)!
host.cacheDisplay(in: host.bounds, to: bitmap)
if name.hasPrefix("pair-connections-") {
try checkPairScrolling(host: host, baseline: bitmap, name: name, output: output)
}
var shades = Set<Int>()
for x in stride(from: 0, to: bitmap.pixelsWide, by: 11) {
for y in stride(from: 0, to: bitmap.pixelsHigh, by: 11) {
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) {
shades.insert(Int((color.redComponent + color.greenComponent + color.blueComponent) * 85))
}
}
}
precondition(shades.count > 8, "Blank or incomplete rendering: \(name)")
try bitmap.representation(using: .png, properties: [:])!.write(to: output.appendingPathComponent(name + ".png"))
window.orderOut(nil)
window.contentView = nil
window.close()
}
@MainActor static func checkPairScrolling(host: NSView, baseline: NSBitmapImageRep, name: String, output: URL) throws {
func descendants(_ view: NSView) -> [NSView] {
view.subviews.flatMap { [$0] + descendants($0) }
}
let scrolls = descendants(host).compactMap { $0 as? NSScrollView }
.filter { $0.documentView != nil && $0.bounds.width > 100 }
.sorted { host.convert($0.bounds, from: $0).minX < host.convert($1.bounds, from: $1).minX }
precondition(scrolls.count == 2, "Expected two native material scroll views")
func connectorPixels(_ bitmap: NSBitmapImageRep) -> Set<Int> {
let scale = CGFloat(bitmap.pixelsWide) / host.bounds.width
let center = Int(host.bounds.midX * scale)
let top = Int(150 * scale), bottom = Int((host.bounds.height - 90) * scale)
var pixels = Set<Int>()
for x in (center - 8)...(center + 8) {
for y in top..<bottom {
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB),
color.greenComponent > color.redComponent + 0.12,
color.blueComponent > color.redComponent + 0.12 {
pixels.insert(y * bitmap.pixelsWide + x)
}
}
}
return pixels
}
var previous = connectorPixels(baseline)
precondition(previous.count > 10, "Selected pair lines missing without hover: \(name)")
let firstRowLimit = Int(host.bounds.height * 0.7 * CGFloat(baseline.pixelsWide) / host.bounds.width)
precondition(previous.contains { $0 / baseline.pixelsWide < firstRowLimit },
"Visible first-row cards are not connected: \(name)")
for (step, scroll) in [scrolls[1], scrolls[0], scrolls[1]].enumerated() {
let target = step == 2 ? CGFloat(0) : CGFloat(330)
scroll.contentView.scroll(to: NSPoint(x: 0, y: target))
scroll.reflectScrolledClipView(scroll.contentView)
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
host.layoutSubtreeIfNeeded()
precondition(abs(scroll.contentView.bounds.minY - target) < 2, "Native scroll did not move")
let snapshot = host.bitmapImageRepForCachingDisplay(in: host.bounds)!
host.cacheDisplay(in: host.bounds, to: snapshot)
let current = connectorPixels(snapshot)
precondition(current.count > 10, "Pair lines vanished on scroll: \(name), step \(step)")
precondition(current != previous, "Pair line positions did not update on scroll: \(name), step \(step)")
previous = current
try snapshot.representation(using: .png, properties: [:])!
.write(to: output.appendingPathComponent("\(name)-scroll-\(step).png"))
}
print("Persistent unhovered pair lines and independent native scrolling passed: \(name)")
}
}