307 lines
20 KiB
Swift
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)")
|
|
}
|
|
}
|