1
This commit is contained in:
@@ -36,7 +36,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
|
|||||||
2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。
|
2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。
|
||||||
3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。
|
3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。
|
||||||
4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。
|
4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。
|
||||||
5. “已核对”中可查看依据、改分类、撤销及导出。自动核对结果不代表已人工复核。
|
5. “已核对”中可改分类、预览材料、撤销及导出。首列勾选一组或多组材料,或使用表格上方“全选当前分组”,再点击“导出所选 N 组 PPT”;切换分组保留勾选,取消当前分组全选不影响其他分组,未勾选时不能导出 PPT。“全部”页可全选所有已核对组。行程 Excel 和个人报销单仍使用全部已核对材料。自动核对结果不代表已人工复核。
|
||||||
6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。
|
6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。
|
||||||
|
|
||||||
## 功能对应
|
## 功能对应
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct MatchedPPTSelection {
|
||||||
|
var ids: Set<String> = []
|
||||||
|
|
||||||
|
func containsAll(_ matches: [MatchGroup]) -> Bool {
|
||||||
|
!matches.isEmpty && matches.allSatisfy { ids.contains($0.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func setVisible(_ matches: [MatchGroup], selected: Bool) {
|
||||||
|
let visible = Set(matches.map(\.id))
|
||||||
|
if selected { ids.formUnion(visible) }
|
||||||
|
else { ids.subtract(visible) }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func retain(_ identifiers: [String]) {
|
||||||
|
ids.formIntersection(identifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportWorkspace(from workspace: Workspace) throws -> Workspace {
|
||||||
|
guard !ids.isEmpty else {
|
||||||
|
throw SelectionFailure(message: "请先勾选要导出的已核对材料。")
|
||||||
|
}
|
||||||
|
guard ids.isSubset(of: Set(workspace.matches.map(\.id))) else {
|
||||||
|
throw SelectionFailure(message: "部分勾选材料已撤销或被替换,请重新选择后导出。")
|
||||||
|
}
|
||||||
|
var result = workspace
|
||||||
|
result.matches = workspace.matches.filter { ids.contains($0.id) }
|
||||||
|
result.invoices = result.matches.flatMap(\.invoices)
|
||||||
|
result.payments = result.matches.flatMap(\.payments)
|
||||||
|
result.photos = []
|
||||||
|
result.directoryPaymentTotal = result.paymentTotal
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SelectionFailure: LocalizedError {
|
||||||
|
let message: String
|
||||||
|
var errorDescription: String? { message }
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
-47
@@ -2,87 +2,180 @@ import SwiftUI
|
|||||||
|
|
||||||
struct MatchedPage: View {
|
struct MatchedPage: View {
|
||||||
@EnvironmentObject var store: WorkspaceStore
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
|
@State private var selectedGroup = "全部"
|
||||||
|
@State private var pptSelection = MatchedPPTSelection()
|
||||||
|
|
||||||
|
private var selectedMatches: [MatchGroup] {
|
||||||
|
store.state.matches.filter { pptSelection.ids.contains($0.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var groups: [String] {
|
||||||
|
["全部"] + Array(Set(store.state.matches.map(\.category))).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 18) {
|
VStack(spacing: 18) {
|
||||||
HStack {
|
HStack {
|
||||||
Picker("排版", selection: $store.classified) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("按类型分类").tag(true)
|
Text("已核对材料").font(.title2.bold())
|
||||||
Text("不分类").tag(false)
|
Text("按费用分组查看,每个分组独立一页").font(.caption).foregroundStyle(.secondary)
|
||||||
}.pickerStyle(.segmented).frame(width: 200)
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Button { store.export(.travel) } label: { Label("行程 Excel", systemImage: "tram") }.disabled(store.state.travelCount == 0)
|
Button { store.export(.travel) } label: { Label("行程 Excel", systemImage: "tram") }.disabled(store.state.travelCount == 0)
|
||||||
Button(action: store.openExpense) { Label("个人报销单", systemImage: "tablecells") }.disabled(store.state.matches.isEmpty)
|
Button(action: store.openExpense) { Label("个人报销单", systemImage: "tablecells") }.disabled(store.state.matches.isEmpty)
|
||||||
Button { store.export(.ppt) } label: { Label("导出 PPT", systemImage: "square.and.arrow.up") }
|
Button { store.export(.ppt, selectedMatchIDs: pptSelection.ids) } label: {
|
||||||
.buttonStyle(.borderedProminent).disabled(store.state.matches.isEmpty)
|
Label("导出所选 \(selectedMatches.count) 组 PPT", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent).disabled(selectedMatches.isEmpty)
|
||||||
}
|
}
|
||||||
if store.state.matches.isEmpty {
|
if store.state.matches.isEmpty {
|
||||||
EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "扫描目录或完成人工配对后,结果会出现在这里。")
|
EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "扫描目录或完成人工配对后,结果会出现在这里。")
|
||||||
} else {
|
} else {
|
||||||
|
Picker("分组", selection: $selectedGroup) {
|
||||||
|
ForEach(groups, id: \.self) { group in
|
||||||
|
Text(group == "全部" ? "全部(\(store.state.matches.count))" : "\(group)(\(count(for: group)))")
|
||||||
|
.tag(group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: groups) { _, available in
|
||||||
|
if !available.contains(selectedGroup) { selectedGroup = "全部" }
|
||||||
|
}
|
||||||
|
MatchGroupPage(
|
||||||
|
title: selectedGroup,
|
||||||
|
matches: matches(for: selectedGroup),
|
||||||
|
baseIndex: baseIndex(for: selectedGroup),
|
||||||
|
classified: $store.classified,
|
||||||
|
selection: $pptSelection
|
||||||
|
)
|
||||||
HStack {
|
HStack {
|
||||||
Text("按发票开票日期排列 · 自动匹配结果也计入已核对").font(.caption).foregroundStyle(.secondary)
|
Text("已选 \(selectedMatches.count) 组 · 发票合计 \(currency(selectedMatches.reduce(0) { $0 + $1.invoiceTotal }))")
|
||||||
|
.monospacedDigit()
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(store.state.matches.count) 组").font(.caption.monospacedDigit())
|
Text("其中 \(selectedMatches.filter { selectedGroup != "全部" && $0.category != selectedGroup }.count) 组在其他分组")
|
||||||
}
|
.foregroundStyle(.secondary)
|
||||||
ScrollView {
|
Button("清空选择") { pptSelection.ids.removeAll() }.disabled(pptSelection.ids.isEmpty)
|
||||||
LazyVStack(spacing: 18) {
|
}.font(.callout)
|
||||||
ForEach(Array(store.state.sortedMatches.enumerated()), id: \.element.id) { index, match in
|
Text("切换分组保留勾选;PPT 仅导出所选组的完整发票与付款材料。行程 Excel 和个人报销单仍使用全部已核对材料。")
|
||||||
MatchRow(match: match, index: index + 1)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
|
||||||
}.padding(2)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}.padding(28).disabled(store.busy)
|
}.padding(28).disabled(store.busy)
|
||||||
|
.onChange(of: store.state.matches.map(\.id)) { _, identifiers in
|
||||||
|
pptSelection.retain(identifiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matches(for group: String) -> [MatchGroup] {
|
||||||
|
let matches = group == "全部" ? store.state.sortedMatches : store.state.sortedMatches.filter { $0.category == group }
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
private func count(for group: String) -> Int {
|
||||||
|
matches(for: group).count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func baseIndex(for group: String) -> Int {
|
||||||
|
guard group != "全部", let first = store.state.sortedMatches.firstIndex(where: { $0.category == group }) else { return 1 }
|
||||||
|
return first + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MatchRow: View {
|
struct MatchGroupPage: View {
|
||||||
@EnvironmentObject var store: WorkspaceStore
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
let match: MatchGroup
|
let title: String
|
||||||
let index: Int
|
let matches: [MatchGroup]
|
||||||
|
let baseIndex: Int
|
||||||
|
@Binding var classified: Bool
|
||||||
|
@Binding var selection: MatchedPPTSelection
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Panel {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(String(format: "%02d", index)).font(.title2.monospacedDigit().bold()).foregroundStyle(.teal)
|
Toggle("全选当前分组", isOn: Binding(
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
get: { selection.containsAll(matches) },
|
||||||
Text("\(match.invoices.count) 张发票 · \(match.payments.count) 张付款").font(.headline)
|
set: { selection.setVisible(matches, selected: $0) }
|
||||||
Text("发票 \(currency(match.invoiceTotal)) / 付款 \(currency(match.paymentTotal))").font(.caption).foregroundStyle(.secondary)
|
))
|
||||||
}
|
.toggleStyle(.checkbox)
|
||||||
|
Text(title).font(.headline)
|
||||||
|
Text("\(matches.count) 组 · 已选 \(matches.filter { selection.ids.contains($0.id) }.count)")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Text("按发票日期排列").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if matches.isEmpty {
|
||||||
|
EmptyPanel(symbol: "tray", title: "此分组暂无材料", message: "完成核对后,材料会自动归入对应分组。")
|
||||||
|
} else {
|
||||||
|
Table(matches) {
|
||||||
|
TableColumn("选择") { match in
|
||||||
|
Toggle("选择第 \(index(of: match)) 组", isOn: Binding(
|
||||||
|
get: { selection.ids.contains(match.id) },
|
||||||
|
set: { selected in
|
||||||
|
if selected { selection.ids.insert(match.id) }
|
||||||
|
else { selection.ids.remove(match.id) }
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.toggleStyle(.checkbox).labelsHidden()
|
||||||
|
}.width(48)
|
||||||
|
TableColumn("序号") { match in
|
||||||
|
Text(String(format: "%02d", index(of: match))).monospacedDigit().foregroundStyle(.secondary)
|
||||||
|
}.width(min: 48, ideal: 56, max: 64)
|
||||||
|
TableColumn("发票") { match in
|
||||||
|
MaterialCell(items: match.invoices, store: store)
|
||||||
|
}.width(min: 170, ideal: 235)
|
||||||
|
TableColumn("付款截图") { match in
|
||||||
|
MaterialCell(items: match.payments, store: store)
|
||||||
|
}.width(min: 170, ideal: 235)
|
||||||
|
TableColumn("金额") { match in
|
||||||
|
VStack(alignment: .trailing, spacing: 3) {
|
||||||
|
Text(currency(match.expenseAmount > 0 ? match.expenseAmount : match.invoiceTotal)).monospacedDigit()
|
||||||
|
Text("付 \(currency(match.paymentTotal))").font(.caption).foregroundStyle(.secondary).monospacedDigit()
|
||||||
|
}.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}.width(min: 115, ideal: 135)
|
||||||
|
TableColumn("日期") { match in
|
||||||
|
Text(match.date == "9999-12-31" ? "未识别" : match.date).monospacedDigit()
|
||||||
|
}.width(min: 92, ideal: 105)
|
||||||
|
TableColumn("匹配") { match in
|
||||||
Text(match.matchType == "auto" ? "自动 \(match.score)%" : "人工确认")
|
Text(match.matchType == "auto" ? "自动 \(match.score)%" : "人工确认")
|
||||||
.font(.caption.weight(.medium)).padding(.horizontal, 10).padding(.vertical, 5)
|
.foregroundStyle(match.matchType == "auto" ? .teal : .blue)
|
||||||
.background((match.matchType == "auto" ? Color.teal : Color.blue).opacity(0.1), in: Capsule())
|
}.width(min: 90, ideal: 105)
|
||||||
if store.classified {
|
TableColumn("操作") { match in
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
if classified {
|
||||||
Picker("分类", selection: Binding(get: { match.category }, set: { store.updateCategory(match, category: $0) })) {
|
Picker("分类", selection: Binding(get: { match.category }, set: { store.updateCategory(match, category: $0) })) {
|
||||||
ForEach(expenseCategories, id: \.self) { Text($0) }
|
ForEach(expenseCategories, id: \.self) { Text($0) }
|
||||||
}.labelsHidden().frame(width: 110)
|
}.labelsHidden().frame(width: 95)
|
||||||
}
|
}
|
||||||
Button { store.undo(match) } label: { Image(systemName: "arrow.uturn.backward") }.help("撤销本组配对")
|
Button { store.undo(match) } label: { Image(systemName: "arrow.uturn.backward") }
|
||||||
|
.buttonStyle(.borderless).help("撤销本组配对")
|
||||||
}
|
}
|
||||||
HStack(alignment: .top, spacing: 18) {
|
}.width(min: classified ? 135 : 48, ideal: classified ? 145 : 60)
|
||||||
thumbnails(match.invoices.sorted { $0.issueDate < $1.issueDate })
|
|
||||||
Image(systemName: "link").foregroundStyle(.teal).padding(.top, 50)
|
|
||||||
thumbnails(match.payments.sorted { $0.sortDate < $1.sortDate })
|
|
||||||
}
|
}
|
||||||
Text(match.reasons.joined(separator: " · ")).font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
|
.frame(minHeight: 330)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func thumbnails(_ items: [Material]) -> some View {
|
private func index(of match: MatchGroup) -> Int {
|
||||||
ScrollView(.horizontal) {
|
guard let index = matches.firstIndex(where: { $0.id == match.id }) else { return baseIndex }
|
||||||
HStack(alignment: .top, spacing: 12) {
|
return baseIndex + index
|
||||||
ForEach(items) { item in
|
|
||||||
Button { store.preview = item } label: {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
MaterialThumbnail(item: item, height: 115)
|
|
||||||
Text(item.name).font(.caption).lineLimit(2).frame(height: 32, alignment: .topLeading)
|
|
||||||
}.frame(width: 155)
|
|
||||||
}.buttonStyle(.plain).help("点击放大 \(item.name)")
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MaterialCell: View {
|
||||||
|
let items: [Material]
|
||||||
|
@ObservedObject var store: WorkspaceStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("\(items.count) 份").font(.caption.weight(.medium))
|
||||||
|
ForEach(items.prefix(2)) { item in
|
||||||
|
Button(item.name) { store.preview = item }
|
||||||
|
.buttonStyle(.link).font(.caption).lineLimit(1).help("点击预览 \(item.name)")
|
||||||
}
|
}
|
||||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
if items.count > 2 {
|
||||||
|
Text("还有 \(items.count - 2) 份").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -215,8 +215,19 @@ final class WorkspaceStore: ObservableObject {
|
|||||||
showExpense = true
|
showExpense = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func export(_ kind: ExportKind) {
|
func export(_ kind: ExportKind, selectedMatchIDs: Set<String>? = nil) {
|
||||||
guard !busy, !state.matches.isEmpty else { return }
|
guard !busy, !state.matches.isEmpty else { return }
|
||||||
|
let exportState: Workspace
|
||||||
|
do {
|
||||||
|
if kind == .ppt, let selectedMatchIDs {
|
||||||
|
exportState = try MatchedPPTSelection(ids: selectedMatchIDs).exportWorkspace(from: state)
|
||||||
|
} else {
|
||||||
|
exportState = state
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
return
|
||||||
|
}
|
||||||
let panel = NSSavePanel()
|
let panel = NSSavePanel()
|
||||||
panel.allowedContentTypes = [UTType(filenameExtension: kind.fileExtension) ?? .data]
|
panel.allowedContentTypes = [UTType(filenameExtension: kind.fileExtension) ?? .data]
|
||||||
panel.canCreateDirectories = true
|
panel.canCreateDirectories = true
|
||||||
@@ -236,7 +247,7 @@ final class WorkspaceStore: ObservableObject {
|
|||||||
try? FileManager.default.removeItem(at: temporary)
|
try? FileManager.default.removeItem(at: temporary)
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
_ = try await run(["operation": kind.rawValue, "state": try jsonObject(state), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures])
|
_ = try await run(["operation": kind.rawValue, "state": try jsonObject(exportState), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures])
|
||||||
try Data(contentsOf: temporary).write(to: destination, options: .atomic)
|
try Data(contentsOf: temporary).write(to: destination, options: .atomic)
|
||||||
if kind == .expense { showExpense = false }
|
if kind == .expense { showExpense = false }
|
||||||
notice = "\(kind.title)已导出到 \(destination.lastPathComponent)"
|
notice = "\(kind.title)已导出到 \(destination.lastPathComponent)"
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct MatchedPPTSelectionTests {
|
||||||
|
static func match(_ id: String, category: String) -> MatchGroup {
|
||||||
|
MatchGroup(id: id, invoices: [material(id + "-invoice", type: "invoice")],
|
||||||
|
payments: [material(id + "-payment", type: "payment"), material(id + "-payment-2", type: "payment")],
|
||||||
|
category: category, matchType: "manual", score: 100, reasons: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func material(_ id: String, type: String) -> Material {
|
||||||
|
Material(id: id, name: id + ".png", path: "/tmp/" + id + ".png", type: type, size: 1,
|
||||||
|
matched: true, previewPath: "", ocr: OCRData(), displayAmount: 10,
|
||||||
|
sortDate: "2026-09-16", issueDate: "2026-09-16")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func main() throws {
|
||||||
|
let meal = match("meal", category: "餐饮")
|
||||||
|
let travel = match("travel", category: "交通")
|
||||||
|
let hotel = match("hotel", category: "住宿")
|
||||||
|
var workspace = Workspace()
|
||||||
|
workspace.matches = [meal, travel, hotel]
|
||||||
|
workspace.invoices = workspace.matches.flatMap(\.invoices)
|
||||||
|
workspace.payments = workspace.matches.flatMap(\.payments)
|
||||||
|
var selection = MatchedPPTSelection()
|
||||||
|
precondition(!selection.containsAll([]))
|
||||||
|
precondition(!selection.containsAll(workspace.matches))
|
||||||
|
precondition((try? selection.exportWorkspace(from: workspace)) == nil)
|
||||||
|
selection.setVisible([meal], selected: true)
|
||||||
|
selection.setVisible([travel], selected: true)
|
||||||
|
precondition(selection.ids == ["meal", "travel"])
|
||||||
|
precondition(selection.containsAll([meal]))
|
||||||
|
precondition(!selection.containsAll(workspace.matches))
|
||||||
|
let exported = try selection.exportWorkspace(from: workspace)
|
||||||
|
precondition(exported.matches.map(\.id) == ["meal", "travel"])
|
||||||
|
precondition(exported.invoices.map(\.id) == ["meal-invoice", "travel-invoice"])
|
||||||
|
precondition(exported.payments.count == 4)
|
||||||
|
precondition(exported.matches[0].payments.count == 2)
|
||||||
|
let payload = try JSONDecoder().decode(Workspace.self, from: JSONEncoder().encode(exported))
|
||||||
|
precondition(payload.matches.map(\.id) == ["meal", "travel"])
|
||||||
|
precondition(!payload.invoices.contains { $0.id.hasPrefix("hotel") })
|
||||||
|
precondition(workspace.matches.count == 3)
|
||||||
|
precondition(workspace.invoices.count == 3)
|
||||||
|
selection.setVisible([meal], selected: false)
|
||||||
|
precondition(selection.ids == ["travel"])
|
||||||
|
let single = try selection.exportWorkspace(from: workspace)
|
||||||
|
precondition(single.matches.map(\.id) == ["travel"])
|
||||||
|
selection.setVisible(workspace.matches, selected: true)
|
||||||
|
precondition(selection.containsAll(workspace.matches))
|
||||||
|
let all = try selection.exportWorkspace(from: workspace)
|
||||||
|
precondition(all.matches.map(\.id) == workspace.matches.map(\.id))
|
||||||
|
selection.setVisible(workspace.matches, selected: false)
|
||||||
|
precondition(selection.ids.isEmpty)
|
||||||
|
selection.ids = ["removed", "meal"]
|
||||||
|
precondition((try? selection.exportWorkspace(from: workspace)) == nil)
|
||||||
|
selection.retain(workspace.matches.map(\.id))
|
||||||
|
precondition(selection.ids == ["meal"])
|
||||||
|
selection.retain([])
|
||||||
|
precondition(selection.ids.isEmpty)
|
||||||
|
print("Matched PPT selection tests passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user