diff --git a/macTEMUITests/EDXRegionsRealFileJourney.swift b/macTEMUITests/EDXRegionsRealFileJourney.swift new file mode 100644 index 0000000..866ef10 --- /dev/null +++ b/macTEMUITests/EDXRegionsRealFileJourney.swift @@ -0,0 +1,146 @@ +// +// EDXRegionsRealFileJourney.swift +// The EDS room's per-region composition on the owner's real Velox spectrum images (1339, 1840 and the 1.7 GB 1456): draw regions on +// the colour mix, Quantify each, read its composition and the region table, export the results. Opt-in like EDXRealFileJourney: +// TEST_RUNNER_MACTEM_REAL_DATA=1 TEST_RUNNER_MACEM_DATA= tools/run-tests.sh ui EDXRegionsRealFileJourney +// Regions are drawn by the room's keyboard route (XCUITest's clicks and drags do not reach the drawing gestures); the compositions are attached, never asserted (no truth for these files yet). +// + +import XCTest + +nonisolated final class EDXRegionsRealFileJourney: Journey { + static let exportName = "macTEM-edx-regions-export" + static func edx(_ name: String) -> String { Journey.dataFolder + "stem-edx/" + name } + + override func setUpWithError() throws { + try super.setUpWithError() + try XCTSkipUnless(ProcessInfo.processInfo.environment["MACTEM_REAL_DATA"] == "1", + "set MACTEM_REAL_DATA=1 (and TEST_RUNNER_MACTEM_REAL_DATA=1 for xcodebuild) to run on the real files") + } + + @MainActor func attach(_ name: String, _ string: String) { + let a = XCTAttachment(string: string); a.name = name; a.lifetime = .keepAlways; add(a) + } + + @MainActor func text(_ e: XCUIElement) -> String { + if !e.label.isEmpty { return e.label } + if let v = e.value as? String, !v.isEmpty { return v } + return e.staticTexts.firstMatch.label + } + + @MainActor func mixTile() -> XCUIElement { app.groups.matching(NSPredicate(format: "label BEGINSWITH 'Colour mix map'")).firstMatch } + @MainActor func resultsHeader() -> XCUIElement { + app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH 'Results ·' OR value BEGINSWITH 'Results ·'")).firstMatch + } + + @MainActor func open(_ file: String) { + launch(["--open", file, "--export-dir", Self.exportName]) + if !element("edx.room").waitForExistence(timeout: 60) { + let sidebar = element("workspace.edx") + if sidebar.waitForExistence(timeout: 300), sidebar.isEnabled { sidebar.click() } + } + waitFor("edx.room", timeout: 300) + XCTAssertTrue(mixTile().waitForExistence(timeout: 600), "the colour mix tile never appeared") + waitFor("edx.spectrum", timeout: 120) + } + + @MainActor func showSettings(waitingFor id: String) { + let picker = waitFor("inspector.tabPicker", timeout: 30) + let tab = picker.buttons["Settings"] + if tab.exists, !tab.isSelected { tab.click() } + waitFor(id, timeout: 20) + } + + @MainActor func chooseTool(_ index: Int) { + showSettings(waitingFor: "edx.tool") + element("edx.tool").radioButtons.element(boundBy: index).click() + } + + @MainActor func quantify() { + let verb = waitFor("workspace.primaryAction", timeout: 60) + let enabled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isEnabled == true"), object: verb) + XCTAssertEqual(XCTWaiter().wait(for: [enabled], timeout: 300), .completed, "Quantify never became enabled") + verb.click() + waitFor("edx.compositionTable", timeout: 300) + XCTAssertTrue(element("toolbar.display.idle").waitForExistence(timeout: 600), "the room did not come back to idle after Quantify") + } + + /// Every composition row of the table as "El wt%" text, for the attachment. + @MainActor func composition() -> String { + let rows = app.descendants(matching: .any).matching(NSPredicate(format: "identifier BEGINSWITH 'edx.composition.' AND identifier ENDSWITH '.wt'")).allElementsBoundByIndex + return rows.map { "\($0.identifier.replacingOccurrences(of: "edx.composition.", with: "")): \(text($0))" }.joined(separator: "\n") + } + + @MainActor func waitHeader(_ suffix: String, timeout: TimeInterval = 30) -> Bool { + let p = NSPredicate(format: "label == %@ OR value == %@", "Results · \(suffix)", "Results · \(suffix)") + return app.staticTexts.matching(p).firstMatch.waitForExistence(timeout: timeout) + } + + /// Chooses the tool (its index in the Tools control), focuses the colour mix with a click and presses Return; then `nudges` shift-arrow + /// presses move the new region ten pixels each to the lower right, so a second region does not sit on the first. + @MainActor func drawByKeyboard(tool: Int, nudges: Int) { + chooseTool(tool) + app.activate() + mixTile().click() + app.typeKey(.return, modifierFlags: []) + for _ in 0.. tools/run-tests.sh ui ParticlesRealFileJourney +// Each method opens one file, shows objects with the defaults, reads the counts, opens the object table and exports the CSV into +// --export-dir. A finding (the section is wrong) fails the step with its words; the counts are attached, never asserted (no truth yet). +// + +import XCTest + +nonisolated final class ParticlesRealFileJourney: Journey { + static let exportName = "macTEM-particles-export" + static func tem(_ name: String) -> String { Journey.dataFolder + "tem/" + name } + + override func setUpWithError() throws { + try super.setUpWithError() + try XCTSkipUnless(ProcessInfo.processInfo.environment["MACTEM_REAL_DATA"] == "1", + "set MACTEM_REAL_DATA=1 (and TEST_RUNNER_MACTEM_REAL_DATA=1 for xcodebuild) to run on the real files") + } + + @MainActor func attach(_ name: String, _ string: String) { + let a = XCTAttachment(string: string); a.name = name; a.lifetime = .keepAlways; add(a) + } + + @MainActor func text(_ e: XCUIElement) -> String { + if !e.label.isEmpty { return e.label } + if let v = e.value as? String, !v.isEmpty { return v } + return e.staticTexts.firstMatch.label + } + + /// The whole walk on one file: open, Show Objects, counts, the table, the CSV. + @MainActor func drive(_ name: String) throws { + let file = Self.tem(name) + try XCTSkipUnless(FileManager.default.fileExists(atPath: file), "the real file is not at \(file)") + try step("open \(name)") { + launch(["--open", file, "--export-dir", Self.exportName]) + waitFor("image.view", timeout: 120) + showInspector(waitingFor: "particles.show") + } + try step("show objects") { + let show = waitFor("particles.show", timeout: 30) + let enabled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isEnabled == true"), object: show) + XCTAssertEqual(XCTWaiter().wait(for: [enabled], timeout: 60), .completed, "Show Objects stayed disabled") + show.click() + } + try step("counts") { + let counted = waitValue("particles.counts", timeout: 180) { !$0.isEmpty } + attach("counted", counted) + attach("counts line", text(element("particles.counts"))) + if element("particles.density").exists { attach("density line", text(element("particles.density"))) } + let sizes = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH 'median' OR value BEGINSWITH 'median'")).firstMatch + if sizes.exists { attach("size line", text(sizes)) } + XCTAssertFalse(app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'not calibrated'")).firstMatch.exists, + "a calibrated Ceta image reads as not calibrated") + let table = waitFor("particles.table") + XCTAssertTrue(app.windows.firstMatch.frame.contains(table.frame), "Object Table is below the fold: \(table.frame)") + } + // XCUITest's snapshot of a window builds every row of its table: on the HRTEM images (10 000 objects and more under the default + // threshold) one query keeps the app's main thread busy past XCUITest's 30 s (2026-11-05, spindump in NSTableView + // viewAtColumn:row:makeIfNecessary:). A person scrolling the table builds only the rows in view, so the drive stops short of it there. + let counted = Int(((element("particles.counts").value as? String) ?? "").filter(\.isNumber)) ?? 0 // "4.677" in a German locale + if counted > 2000 { attach("object table", "not opened: \(counted) counted objects, more than XCUITest's snapshot of the table can take") } + else { try step("object table") { + waitFor("particles.table").click() + let view = waitFor("particles.tableView", timeout: 30) + let rows = view.tableRows.firstMatch.exists || view.outlineRows.firstMatch.exists ? 1 : 0 // SwiftUI's Table is an outline + attach("table rows", "\(rows)") + XCTAssertTrue(rows > 0 || counted == 0, "the table lists no row") + let tableWindow = app.windows.matching(NSPredicate(format: "title == 'Objects'")).firstMatch + if tableWindow.exists { + let shot = XCTAttachment(screenshot: tableWindow.screenshot()); shot.name = "the Objects window"; shot.lifetime = .keepAlways; add(shot) + tableWindow.typeKey("w", modifierFlags: .command) + } + } } + try step("export CSV") { + let export = waitFor("particles.export", timeout: 20) + export.click() + let bar = waitFor("status.bar", timeout: 20) + let exported = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in self.text(bar).hasPrefix("Exported ") }, object: nil) + let saved = XCTWaiter().wait(for: [exported], timeout: 30) + if saved != .completed, app.sheets.firstMatch.exists { + let shot = XCTAttachment(screenshot: app.windows.firstMatch.screenshot()); shot.name = "a save panel instead"; shot.lifetime = .keepAlways; add(shot) + app.sheets.firstMatch.buttons["Cancel"].click() + } + XCTAssertEqual(saved, .completed, "Export CSV with --export-dir: status line \"\(text(bar))\"") + attach("status after export", text(bar)) + } + } + + @MainActor func test01_Ceta1305TIFF() throws { try drive("Camera Ceta 1305 17500 x 20260605 Ceta.tif") } + @MainActor func test02_Ceta1306TIFF() throws { try drive("Camera Ceta 1306 28500 x 20260605 Ceta.tif") } + @MainActor func test03_Ceta1309TIFF() throws { try drive("Camera Ceta 1309 36000 x 20260605 Ceta.tif") } + @MainActor func test04_Ceta1309EMD() throws { try drive("Camera Ceta 1309 36000 x 20260605.emd") } + @MainActor func test05_HRTEM1620TIFF() throws { try drive("Camera Ceta 1620 630 kx 20260605 Ceta.tif") } + @MainActor func test06_HRTEM1623TIFF() throws { try drive("Camera Ceta 1623 1.05 Mx 20260605 Ceta.tif") } + @MainActor func test07_HRTEM1631TIFF() throws { try drive("Camera Ceta 1631 1.05 Mx 20260605 Ceta.tif") } +}