diff --git a/.github/workflows/canvas-audit.yml b/.github/workflows/canvas-audit.yml index f07ce51..a9cd3fd 100644 --- a/.github/workflows/canvas-audit.yml +++ b/.github/workflows/canvas-audit.yml @@ -74,6 +74,7 @@ jobs: -only-testing:OpenNotesUITests/NotebookInteractionUITests/testInvalidNativePayloadShowsRecoveryWithoutOverwritingNote \ -collect-test-diagnostics never - name: Verify persisted lecture content + if: ${{ always() && (steps.interaction_workloads.outcome == 'success' || steps.interaction_workloads.outcome == 'failure') }} run: | simulator_id="${IOS_SIMULATOR_DESTINATION##*id=}" python3 -c 'import subprocess,sys; subprocess.run(["xcrun", "simctl", "bootstatus", sys.argv[1], "-b"], check=True, timeout=180)' "$simulator_id" @@ -84,9 +85,16 @@ jobs: run: | python3 scripts/ci/collect-canvas-audits.py "$RUNNER_TEMP/canvas-audit-reports" node scripts/ci/verify-canvas-audit.mjs "$RUNNER_TEMP/canvas-audit-reports" - - name: Profile interactions that missed the budgets - if: ${{ failure() && steps.interaction_workloads.outcome == 'success' && steps.canvas_budgets.outcome == 'failure' }} + - name: Profile failed native workloads + if: ${{ failure() && (steps.interaction_workloads.outcome == 'failure' || (steps.interaction_workloads.outcome == 'success' && steps.canvas_budgets.outcome == 'failure')) }} run: | + # Restore known geometry before rerunning the edit/relaunch case. + # Timing reports above remain the unprofiled evidence. + simulator_id="${IOS_SIMULATOR_DESTINATION##*id=}" + app_data=$(xcrun simctl get_app_container "$simulator_id" com.builderpro.opennotes data) + mkdir -p "$RUNNER_TEMP/canvas-audit-profiles" + cp -R "$app_data/Documents/notebook-bodies" "$RUNNER_TEMP/canvas-audit-profiles/before-profile-bodies" + python3 scripts/seed-native-audit.py "$simulator_id" python3 scripts/ci/sample-canvas-process.py OpenNotes "$RUNNER_TEMP/canvas-audit-profiles" & profile_pid=$! trap 'kill "$profile_pid" 2>/dev/null || true; wait "$profile_pid" || true' EXIT @@ -94,6 +102,7 @@ jobs: -workspace ios/OpenNotes.xcworkspace -scheme OpenNotesIntegrity \ -derivedDataPath /tmp/opennotes-ui-deriveddata \ -destination "$IOS_SIMULATOR_DESTINATION" -parallel-testing-enabled NO \ + -only-testing:OpenNotesUITests/NotebookInteractionUITests/testCrossPageAssetHistory \ -only-testing:OpenNotesUITests/NotebookStallUITests/testDensePagesAndKeyboardInteractionAudit \ -only-testing:OpenNotesUITests/NotebookStallUITests/testDensePanZoomInteractionAudit \ -only-testing:OpenNotesUITests/NotebookStallUITests/testDenseWritingDuringAutosaveInteractionAudit \ diff --git a/app/note/[id].tsx b/app/note/[id].tsx index 6825468..7a97f9a 100644 --- a/app/note/[id].tsx +++ b/app/note/[id].tsx @@ -1,3 +1,4 @@ +import { resolveInkDisplayAppearance } from '@mathnotes/mobile-ink'; import {selectedOverlayTouchRects} from '../../src/utils/overlayTouchRects'; import {useOverlayHistory} from '../../src/components/editor/history/useOverlayHistory'; import React, { @@ -940,6 +941,7 @@ export default function NoteScreen() { style={styles.canvasArea} > Int { +/// Count ink that contrasts with the dominant paper brightness. Sampling page +/// interiors prevents dark paper from falsely satisfying a dark-ink assertion. +func contrastingInkPixels(_ image: UIImage, rect: CGRect? = nil) -> Int { guard let source = image.cgImage else { return 0 } let width = source.width, height = source.height var pixels = [UInt8](repeating: 255, count: width * height * 4) @@ -14,11 +14,26 @@ private func visibleInkSamples(_ image: UIImage) -> Int { else { return 0 } context.draw(source, in: CGRect(x: 0, y: 0, width: width, height: height)) let data = bytes.bindMemory(to: UInt8.self) + let scale = CGFloat(width) / image.size.width + let interior = CGRect(x: width / 4, y: height / 5, width: width / 2, height: height * 7 / 15) + let area = (rect?.applying(CGAffineTransform(scaleX: scale, y: scale)) ?? interior) + .intersection(CGRect(x: 0, y: 0, width: width, height: height)) + guard !area.isNull, !area.isEmpty else { return 0 } + var histogram = [Int](repeating: 0, count: 16) + for y in Int(area.minY).. 160 : brightness < 100 { count += 1 } } } return count @@ -27,7 +42,7 @@ private func visibleInkSamples(_ image: UIImage) -> Int { func assertDenseInkVisible(_ app: XCUIApplication, context: String, file: StaticString = #filePath, line: UInt = #line) { - let predicate = NSPredicate { _, _ in visibleInkSamples(app.screenshot().image) > 100 } + let predicate = NSPredicate { _, _ in contrastingInkPixels(app.screenshot().image) > 100 } let result = XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: predicate, object: nil)], timeout: 8) if result != .completed { let attachment = XCTAttachment(screenshot: app.screenshot()) @@ -38,8 +53,7 @@ func assertDenseInkVisible(_ app: XCUIApplication, context: String, XCTAssertEqual(result, .completed, "Expected visible dense ink: \(context)", file: file, line: line) } -/// Count newly darkened pixels in a screen-coordinate rectangle, excluding -/// preexisting fixture ink. Both captures use the same transform and viewport. +/// Count changed ink brightness in either appearance at an unchanged viewport. func changedInkPixels(_ before: UIImage, _ after: UIImage, rect: CGRect) -> Int { guard let first = before.cgImage, let second = after.cgImage, first.width == second.width, first.height == second.height else { return 0 } @@ -63,7 +77,7 @@ func changedInkPixels(_ before: UIImage, _ after: UIImage, rect: CGRect) -> Int for y in Int(area.minY).. 100 { + if abs(Int(a[i]) + Int(a[i + 1]) + Int(a[i + 2]) - Int(b[i]) - Int(b[i + 1]) - Int(b[i + 2])) > 100 { changed += 1 } } diff --git a/ios/OpenNotesUITests/NotebookInteractionUITests.swift b/ios/OpenNotesUITests/NotebookInteractionUITests.swift index c78b05c..0e84ece 100644 --- a/ios/OpenNotesUITests/NotebookInteractionUITests.swift +++ b/ios/OpenNotesUITests/NotebookInteractionUITests.swift @@ -99,13 +99,7 @@ final class NotebookInteractionUITests: NotebookUITestCase { XCTAssertTrue(boundary.waitForExistence(timeout: 10)) let reopenedY = boundary.frame.midY let reopened = app.screenshot().image - // The lower page fixture is blank. Compare the persisted lower stroke - // against a white reference generated at the same screenshot dimensions. - let white = UIGraphicsImageRenderer(size: reopened.size).image { context in - UIColor.white.setFill() - context.fill(CGRect(origin: .zero, size: reopened.size)) - } - XCTAssertGreaterThan(changedInkPixels(white, reopened, + XCTAssertGreaterThan(contrastingInkPixels(reopened, rect: CGRect(x: x - 8, y: reopenedY + 25, width: 16, height: 40)), 20, "Saved crossing stroke must survive full application relaunch") attach(app, "Cross-page stroke after undo, redo, save and relaunch") diff --git a/ios/OpenNotesUITests/README.md b/ios/OpenNotesUITests/README.md index 7052738..c99ddd6 100644 --- a/ios/OpenNotesUITests/README.md +++ b/ios/OpenNotesUITests/README.md @@ -38,3 +38,7 @@ per page at the editor's 820×1061 point size and 2× native pixel scale. Keep fixture revisions identical for before/after comparisons. Simulator and standalone engine measurements do not establish physical-iPad frame rates. + +Ink visibility checks contrast against the dominant paper brightness in a page +interior, so dark paper cannot falsely satisfy an ink assertion. Cross-page +change checks accept newly visible ink in either light or dark appearance. diff --git a/package-lock.json b/package-lock.json index ed1f4c9..b9ddb84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2568,7 +2568,7 @@ "node_modules/@mathnotes/mobile-ink": { "version": "0.3.10", "resolved": "file:vendor/mathnotes-mobile-ink-0.3.10.tgz", - "integrity": "sha512-tMy6pTdzd2c3Gk+NkRPtjfHQaJm1YAi5swdi2eN8BGXsHdwgPIZa9yEILls+DG57cWWGKCQzF3H2ljqKnRRcfg==", + "integrity": "sha512-cjhDhOmsfsd5tjlrZtArttvtAFfTf/2IwkmMmXDupuxUvUrCWlE20eyLWt9UyXRT4QCKl2C3s4oHHnVe6vpE3Q==", "license": "Apache-2.0", "peerDependencies": { "@shopify/react-native-skia": ">=2.0.0", diff --git a/scripts/ci/sample-canvas-process.py b/scripts/ci/sample-canvas-process.py index 3d1ebde..a5c8db6 100644 --- a/scripts/ci/sample-canvas-process.py +++ b/scripts/ci/sample-canvas-process.py @@ -23,7 +23,9 @@ def stop(_signum, _frame): signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGINT, stop) -deadline = time.monotonic() + 300 +# The asset case launches twice, followed by the three stall workloads. +# Keep this diagnostic bounded while covering all of those app lifetimes. +deadline = time.monotonic() + 600 # Ignore an app left open by the preceding timing run. initial = subprocess.check_output(["ps", "-axo", "pid=,comm="], text=True) for line in initial.splitlines(): @@ -31,7 +33,7 @@ def stop(_signum, _frame): if len(fields) == 2 and fields[1].endswith(f"/{binary}.app/{binary}"): seen.add(int(fields[0])) try: - while not stopping and time.monotonic() < deadline and sampled < 3: + while not stopping and time.monotonic() < deadline and sampled < 6: listing = subprocess.check_output(["ps", "-axo", "pid=,comm="], text=True) for line in listing.splitlines(): fields = line.strip().split(maxsplit=1) diff --git a/src/components/editor/FloatingToolbar.tsx b/src/components/editor/FloatingToolbar.tsx index 22b8ecf..9fd40c5 100644 --- a/src/components/editor/FloatingToolbar.tsx +++ b/src/components/editor/FloatingToolbar.tsx @@ -1,3 +1,4 @@ +import { resolveInkDisplayAppearance, inkDisplayColor } from '@mathnotes/mobile-ink'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LayoutChangeEvent, @@ -478,7 +479,7 @@ export function FloatingToolbar({ > {TOOL_DESCRIPTORS.map((tool) => { const isActive = activeTool === tool.type; - const tint = tool.supportsColor ? toolColors[tool.type] : undefined; + const tint = tool.supportsColor ? inkDisplayColor(toolColors[tool.type], resolveInkDisplayAppearance(theme.isDark ? 'dark' : 'light')) : undefined; return (