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)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
@main
|
||||
struct PairConnectionLayoutTests {
|
||||
static func main() {
|
||||
let left = CGRect(x: 0, y: 80, width: 300, height: 500)
|
||||
let right = CGRect(x: 340, y: 80, width: 300, height: 500)
|
||||
let invoices = (0..<4).map { "i\($0)" }, payments = (0..<4).map { "p\($0)" }
|
||||
var frames: [String: CGRect] = [:]
|
||||
for index in 0..<4 {
|
||||
frames[invoices[index]] = CGRect(x: 3, y: 90 + index * 110, width: 290, height: 100)
|
||||
frames[payments[index]] = CGRect(x: 343, y: 90 + index * 110, width: 290, height: 100)
|
||||
}
|
||||
func layout(_ selectedInvoices: Set<String>, _ selectedPayments: Set<String>,
|
||||
_ currentFrames: [String: CGRect]) -> PairConnectionLayout {
|
||||
PairConnectionLayout(invoiceIDs: invoices, paymentIDs: payments,
|
||||
selectedInvoices: selectedInvoices, selectedPayments: selectedPayments,
|
||||
frames: currentFrames, invoiceViewport: left, paymentViewport: right)
|
||||
}
|
||||
for (invoiceCount, paymentCount) in [(1, 1), (1, 4), (4, 1), (4, 4)] {
|
||||
let result = layout(Set(invoices.prefix(invoiceCount)), Set(payments.prefix(paymentCount)), frames)
|
||||
precondition(result.representedPairCount == invoiceCount * paymentCount)
|
||||
precondition(result.invoices.count == invoiceCount && result.payments.count == paymentCount)
|
||||
precondition(result.invoices.allSatisfy { $0.overflow == nil })
|
||||
precondition(result.payments.allSatisfy { $0.overflow == nil })
|
||||
precondition(result.lines.allSatisfy { result.clipRect.contains($0.start) && result.clipRect.contains($0.end) })
|
||||
}
|
||||
precondition(layout([], Set(payments), frames).lines.isEmpty)
|
||||
precondition(layout(Set(invoices), [], frames).lines.isEmpty)
|
||||
let initial = layout(["i0"], Set(payments), frames)
|
||||
var scrolled = frames
|
||||
for id in payments { scrolled[id] = frames[id]!.offsetBy(dx: 0, dy: -160) }
|
||||
let afterScroll = layout(["i0"], Set(payments), scrolled)
|
||||
precondition(afterScroll.invoices.map(\.point) == initial.invoices.map(\.point))
|
||||
precondition(afterScroll.payments.map(\.point) != initial.payments.map(\.point))
|
||||
precondition(afterScroll.payments.reduce(0) { $0 + $1.count } == 4)
|
||||
precondition(afterScroll.payments.first(where: { $0.overflow == .above })?.count == 1)
|
||||
precondition(afterScroll.representedPairCount == 4)
|
||||
|
||||
// Offscreen anchors can disappear from LazyVGrid without removing their selected relationships.
|
||||
scrolled.removeValue(forKey: "p0")
|
||||
let recycled = layout(["i0"], Set(payments), scrolled)
|
||||
precondition(recycled.lines == afterScroll.lines)
|
||||
scrolled.removeValue(forKey: "p3")
|
||||
let bothEdges = layout(Set(invoices), Set(payments), scrolled)
|
||||
precondition(bothEdges.payments.first(where: { $0.overflow == .above })?.count == 1)
|
||||
precondition(bothEdges.payments.first(where: { $0.overflow == .below })?.count == 1)
|
||||
precondition(bothEdges.invoices.reduce(0) { $0 + $1.count } * bothEdges.payments.reduce(0) { $0 + $1.count } == 16)
|
||||
|
||||
var partial = frames
|
||||
partial["p0"] = CGRect(x: 343, y: 20, width: 290, height: 100)
|
||||
let clipped = layout(["i0"], ["p0"], partial)
|
||||
precondition(clipped.payments[0].point.y == 100)
|
||||
precondition(clipped.payments[0].overflow == nil)
|
||||
let allRecycled = layout(Set(invoices), Set(payments), [:])
|
||||
precondition(allRecycled.invoices[0].count == 4 && allRecycled.payments[0].count == 4)
|
||||
precondition(allRecycled.representedPairCount == 16 && allRecycled.lines.count <= 3)
|
||||
|
||||
let filtered = PairConnectionLayout(invoiceIDs: ["i0"], paymentIDs: ["p1"], selectedInvoices: Set(invoices),
|
||||
selectedPayments: Set(payments), frames: frames, invoiceViewport: left, paymentViewport: right)
|
||||
precondition(filtered.representedPairCount == 1)
|
||||
let empty = PairConnectionLayout(invoiceIDs: invoices, paymentIDs: payments, selectedInvoices: Set(invoices),
|
||||
selectedPayments: Set(payments), frames: frames, invoiceViewport: .zero, paymentViewport: right)
|
||||
precondition(empty.lines.isEmpty)
|
||||
let grid: [String: CGRect] = [
|
||||
"i0": CGRect(x: 3, y: 90, width: 140, height: 150),
|
||||
"i1": CGRect(x: 155, y: 90, width: 140, height: 150),
|
||||
"p0": CGRect(x: 343, y: 90, width: 140, height: 150),
|
||||
"p1": CGRect(x: 495, y: 90, width: 140, height: 150)
|
||||
]
|
||||
let routed = layout(["i0", "i1"], ["p0", "p1"], grid)
|
||||
precondition(routed.representedPairCount == 4)
|
||||
for line in routed.lines {
|
||||
let stroke = CGRect(x: min(line.start.x, line.end.x) - 0.5, y: min(line.start.y, line.end.y) - 0.5,
|
||||
width: abs(line.end.x - line.start.x) + 1, height: abs(line.end.y - line.start.y) + 1)
|
||||
precondition(grid.values.allSatisfy { !stroke.intersects($0.insetBy(dx: 1, dy: 1)) },
|
||||
"Connection crosses a card's contents")
|
||||
}
|
||||
print("Pair connections: 1:1, 1:N, N:1, N:M, independent scrolling, clipping, recycled cells and filtering passed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct TemplateMappingValidationTests {
|
||||
static func main() {
|
||||
for value in ["A", "c", "IV"] { precondition(TemplateMappingValidation.column(value)) }
|
||||
for value in ["", "A1", "IW", "1", "=A"] { precondition(!TemplateMappingValidation.column(value)) }
|
||||
for value in ["C23", "iv2000", "A1"] { precondition(TemplateMappingValidation.address(value)) }
|
||||
for value in ["A0", "A2001", "A01", "IW1", "C", "C23:D24"] { precondition(!TemplateMappingValidation.address(value)) }
|
||||
var mapping = ExpenseTemplateMapping(sheetName: "Sheet", columns: ["purpose": "C", "amount": "H"],
|
||||
cells: ["recipient": "C23"], signatureCells: "A30,D30")
|
||||
precondition(TemplateMappingValidation.issues(mapping).isEmpty)
|
||||
mapping.cells["bankName"] = " "
|
||||
precondition(TemplateMappingValidation.issues(mapping).isEmpty)
|
||||
mapping.columns["amount"] = "C"
|
||||
precondition(!TemplateMappingValidation.issues(mapping).isEmpty)
|
||||
mapping.columns["amount"] = "H"
|
||||
mapping.cells["recipient"] = "C10"
|
||||
precondition(!TemplateMappingValidation.issues(mapping).isEmpty)
|
||||
mapping.cells["recipient"] = "C23"
|
||||
mapping.clearCells = "C23"
|
||||
precondition(!TemplateMappingValidation.issues(mapping).isEmpty)
|
||||
mapping.clearCells = ""
|
||||
mapping.endRow = 8
|
||||
precondition(!TemplateMappingValidation.issues(mapping).isEmpty)
|
||||
precondition(TemplateMappingValidation.references("A1,B2; C3\nD4") == ["A1", "B2", "C3", "D4"])
|
||||
print("Template column/address bounds, duplicate targets and row validation passed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct WorkspacePresentationTests {
|
||||
static func material(_ id: String, amount: Double, date: String, merchant: String = "测试商户") -> Material {
|
||||
var ocr = OCRData()
|
||||
ocr.status = "success"
|
||||
ocr.amounts = [String(format: "%.2f", amount)]
|
||||
ocr.merchants = [merchant]
|
||||
return Material(id: id, name: id + ".pdf", path: "", type: "invoice", size: 0, matched: false,
|
||||
previewPath: "", ocr: ocr, displayAmount: amount, sortDate: date, issueDate: date)
|
||||
}
|
||||
static func main() throws {
|
||||
let a = material("a", amount: 10, date: "2026-09-02", merchant: "铁路")
|
||||
let b = material("b", amount: 100, date: "2026-09-01")
|
||||
let c = material("c", amount: 20, date: "2026-09-03")
|
||||
let items = [a, b, c]
|
||||
precondition(WorkspaceFilter.materials(items, query: "", order: .amount).map(\.id) == ["a", "c", "b"])
|
||||
precondition(WorkspaceFilter.materials(items, query: "", order: .date).map(\.id) == ["b", "a", "c"])
|
||||
precondition(WorkspaceFilter.materials(items, query: "铁路 10.00", order: .name).map(\.id) == ["a"])
|
||||
precondition(WorkspaceFilter.materials(items, query: "B.PDF", order: .name).map(\.id) == ["b"])
|
||||
precondition(WorkspaceFilter.materials(items, query: "2026-09-03", order: .name).map(\.id) == ["c"])
|
||||
precondition(WorkspaceFilter.materials(items, query: "", order: .amount, descending: true).map(\.id) == ["b", "c", "a"])
|
||||
var automatic = MatchGroup(id: "auto", invoices: [a], payments: [], category: "交通", matchType: "auto", score: 100, reasons: [])
|
||||
let manual = MatchGroup(id: "manual", invoices: [b], payments: [], category: "其他", matchType: "manual", score: 100, reasons: [])
|
||||
precondition(automatic.needsAttention && !manual.needsAttention)
|
||||
automatic.explanation = try JSONDecoder().decode(MatchExplanation.self, from: Data("""
|
||||
{"version":2,"title":"test","rawScore":55,"checks":[{"title":"amount","points":55,"maximum":100,"detail":"","issue":""}],"note":""}
|
||||
""".utf8))
|
||||
precondition(automatic.needsAttention)
|
||||
var failed = manual
|
||||
failed.id = "failed"
|
||||
failed.invoices[0].ocr.status = "failed"
|
||||
precondition(failed.needsAttention)
|
||||
let matches = [automatic, manual, failed]
|
||||
precondition(WorkspaceFilter.matches(matches, query: "", category: "全部", attentionOnly: true, order: .date).count == 2)
|
||||
let visible = WorkspaceFilter.matches(matches, query: "铁路", category: "交通", attentionOnly: false, order: .date)
|
||||
precondition(visible.map(\.id) == ["auto"])
|
||||
var selection = MatchedPPTSelection(ids: ["manual"])
|
||||
selection.setVisible(visible, selected: true)
|
||||
precondition(selection.ids == ["manual", "auto"])
|
||||
selection.setVisible(visible, selected: false)
|
||||
precondition(selection.ids == ["manual"])
|
||||
var workspace = Workspace()
|
||||
workspace.matches = matches
|
||||
let presentation = WorkspacePresentation(workspace)
|
||||
precondition(presentation.matchNumbers.count == 3)
|
||||
precondition(presentation.categoryCounts["其他"] == 2 && presentation.attentionCount == 2)
|
||||
precondition(workspace.matches.map(\.id) == ["auto", "manual", "failed"])
|
||||
let columns = presentation.categoryColumns(for: matches)
|
||||
precondition(columns.map(\.category) == ["交通", "其他"])
|
||||
precondition(columns.allSatisfy { !$0.matches.isEmpty })
|
||||
let traffic = columns.first(where: { $0.category == "交通" })!
|
||||
let other = columns.first(where: { $0.category == "其他" })!
|
||||
precondition(traffic.matches.map(\.id) == ["auto"] && traffic.invoiceTotal == 10)
|
||||
precondition(other.matches.map(\.id) == ["manual", "failed"] && other.invoiceTotal == 200)
|
||||
precondition(columns.flatMap(\.matches).count == matches.count)
|
||||
selection.setVisible(traffic.matches, selected: true)
|
||||
selection.setVisible(other.matches, selected: true)
|
||||
selection.setVisible(traffic.matches, selected: false)
|
||||
precondition(selection.ids == ["manual", "failed"])
|
||||
let filteredColumns = presentation.categoryColumns(for: visible)
|
||||
precondition(filteredColumns.map(\.id) == ["交通"])
|
||||
precondition(selection.ids == ["manual", "failed"])
|
||||
precondition(presentation.categoryColumns(for: matches).map(\.id) == ["交通", "其他"])
|
||||
let sorted = WorkspaceFilter.matches(matches, query: "", category: "全部", attentionOnly: false, order: .date)
|
||||
precondition(presentation.categoryColumns(for: sorted).first(where: { $0.category == "其他" })!.matches.map(\.id) == ["manual", "failed"])
|
||||
workspace.matches[1].category = "住宿"
|
||||
var updated = WorkspacePresentation(workspace)
|
||||
precondition(updated.categoryColumns(for: workspace.matches).first(where: { $0.category == "住宿" })!.matches.map(\.id) == ["manual"])
|
||||
precondition(selection.ids == ["manual", "failed"])
|
||||
let exported = try selection.exportWorkspace(from: workspace)
|
||||
precondition(exported.matches.map(\.id) == ["manual", "failed"])
|
||||
workspace.matches[2].category = "住宿"
|
||||
updated = WorkspacePresentation(workspace)
|
||||
precondition(updated.categoryColumns(for: workspace.matches).map(\.id) == ["住宿", "交通"])
|
||||
workspace.matches.removeAll { $0.category == "住宿" }
|
||||
precondition(WorkspacePresentation(workspace).categoryColumns(for: workspace.matches).map(\.id) == ["交通"])
|
||||
workspace.matches = matches
|
||||
workspace.matches[1].category = "住宿"
|
||||
workspace.matches[0].category = "历史类型"
|
||||
workspace.matches[2].category = ""
|
||||
updated = WorkspacePresentation(workspace)
|
||||
let extra = updated.categoryColumns(for: workspace.matches)
|
||||
precondition(extra.map(\.id) == ["住宿", "", "历史类型"])
|
||||
precondition(extra.first(where: { $0.category == "" })!.title == "未分类")
|
||||
precondition(extra.first(where: { $0.category == "历史类型" })!.matches.map(\.id) == ["auto"])
|
||||
precondition(updated.categoryColumns(for: []).isEmpty)
|
||||
precondition(WorkspacePresentation().categoryColumns(for: []).isEmpty)
|
||||
var noisy = manual
|
||||
noisy.invoices[0].ocr.merchants = ["载次数", "名称:测试购买方有限公司", "全称", "一站式企业出行与商旅平台"]
|
||||
let originalCandidates = noisy.invoices[0].ocr.merchants
|
||||
precondition(WorkspaceFilter.matches([noisy], query: "测试购买方", category: "全部",
|
||||
attentionOnly: false, order: .date).map(\.id) == [manual.id])
|
||||
precondition(noisy.invoices[0].ocr.merchants == originalCandidates)
|
||||
precondition(MatchedBoardLayout.columnWidth(available: 900, count: 7) == 132)
|
||||
let desktopWidth = MatchedBoardLayout.columnWidth(available: 1100, count: 7)
|
||||
precondition(abs(desktopWidth * 7 + MatchedBoardLayout.gap * 6 - 1100) < 0.001)
|
||||
precondition(MatchedBoardLayout.columnWidth(available: 700, count: 1) == 700)
|
||||
precondition(MatchedBoardLayout.columnWidth(available: 0, count: 0).isFinite)
|
||||
print("Original OCR search preservation and compact category layout sizing passed")
|
||||
print("Search, numeric sorting, attention filtering, cached summaries and hidden selection retention passed")
|
||||
print("Automatic empty-column removal, category reappearance, filtering, selection and unchanged export order passed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const output = mkdtempSync(join(tmpdir(), 'receipt-ui-checks-'));
|
||||
console.log(`Validation output: ${output}`);
|
||||
const domain = ['Models', 'MatchExplanation'];
|
||||
const cases = {
|
||||
PairConnectionLayout: ['PairConnectionLayout'],
|
||||
WorkspacePresentation: [...domain, 'WorkspacePresentation', 'MatchedPPTSelection'],
|
||||
TemplateMappingValidation: ['ExpenseTemplate', 'TemplateMappingValidation'],
|
||||
MatchedPPTSelection: [...domain, 'MatchedPPTSelection'],
|
||||
ExpenseSummary: [...domain, 'MatchedPPTSelection', 'ExpenseSummary'],
|
||||
MatchExplanation: domain,
|
||||
ExpenseTemplate: ['ExpenseTemplate'],
|
||||
ExpensePreview: ['ExpenseTemplate', 'ExpensePreview', 'SpreadsheetLayout'],
|
||||
FolderImportSource: ['FolderImportSource'],
|
||||
PayeeProfile: ['PayeeProfile'],
|
||||
AdminAttachments: ['AdminModels'],
|
||||
AdminPPTSelection: ['AdminModels'],
|
||||
AdminPolicyEditor: ['AdminModels', 'AdminStore'],
|
||||
};
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, { cwd: root, stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
if (!process.argv.includes('--render-only')) {
|
||||
for (const [name, sources] of Object.entries(cases)) {
|
||||
const binary = join(output, name);
|
||||
run('swiftc', [...sources.map(source => `reimburse/${source}.swift`), `tests/${name}Tests.swift`, '-o', binary]);
|
||||
run(binary, []);
|
||||
}
|
||||
console.log(`${Object.keys(cases).length} Swift test suites passed.`);
|
||||
}
|
||||
|
||||
if (process.argv.includes('--render') || process.argv.includes('--render-only')) {
|
||||
const sources = readdirSync(join(root, 'reimburse'))
|
||||
.filter(name => name.endsWith('.swift') && name !== 'reimburseApp.swift')
|
||||
.map(name => `reimburse/${name}`);
|
||||
const binary = join(output, 'InterfaceSmokeTests');
|
||||
run('swiftc', [...sources, 'tests/InterfaceSmokeTests.swift', '-o', binary]);
|
||||
run(binary, [join(output, 'screenshots')]);
|
||||
}
|
||||
console.log(`Validation output: ${output}`);
|
||||
Reference in New Issue
Block a user