9月20日
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user