9月20日

This commit is contained in:
csj
2026-09-20 16:31:24 +08:00
parent 967eaaec8a
commit 68bf79ec3f
37 changed files with 3581 additions and 1627 deletions
+306
View File
@@ -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)")
}
}
+82
View File
@@ -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("A1B2; C3\nD4") == ["A1", "B2", "C3", "D4"])
print("Template column/address bounds, duplicate targets and row validation passed")
}
}
+105
View File
@@ -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")
}
}
+50
View File
@@ -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}`);