From b64116c18a9307f6fe35e1a02d01fcf67b9b1c4f Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Fri, 23 Oct 2026 08:25:34 +0800 Subject: [PATCH] Restore visible native query disclosure labels --- apple/Package.resolved | 2 +- apple/Package.swift | 2 +- .../Tests/UI/query-disclosure-lifecycle.swift | 114 ++++++++++++++++++ .../016-mobile-interaction-regressions.md | 8 ++ logseq.opam | 2 +- shared/test/logseq/query_block_test.ml | 43 +++++++ 6 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 apple/Tests/UI/query-disclosure-lifecycle.swift diff --git a/apple/Package.resolved b/apple/Package.resolved index 5e7a93eb..3a8156de 100644 --- a/apple/Package.resolved +++ b/apple/Package.resolved @@ -15,7 +15,7 @@ "kind" : "remoteSourceControl", "location" : "ssh://git@github.com/logseq/lui.git", "state" : { - "revision" : "4281a5160ca9833d3b71901071dcb40228dcc36d" + "revision" : "71964a686a8fb1d0878e2fe122f28bd155e657ef" } }, { diff --git a/apple/Package.swift b/apple/Package.swift index f1159b97..c8cf2165 100644 --- a/apple/Package.swift +++ b/apple/Package.swift @@ -44,7 +44,7 @@ let package = Package( .library(name: "LogseqModel", type: .dynamic, targets: ["LogseqModel"]), ], dependencies: [ - .package(url: "ssh://git@github.com/logseq/lui.git", revision: "4281a5160ca9833d3b71901071dcb40228dcc36d"), + .package(url: "ssh://git@github.com/logseq/lui.git", revision: "71964a686a8fb1d0878e2fe122f28bd155e657ef"), .package(url: "https://github.com/gonzalezreal/swiftui-math", from: "0.1.0"), .package(url: "https://github.com/appstefan/highlightswift.git", from: "1.1.0") ], diff --git a/apple/Tests/UI/query-disclosure-lifecycle.swift b/apple/Tests/UI/query-disclosure-lifecycle.swift new file mode 100644 index 00000000..4ff203fd --- /dev/null +++ b/apple/Tests/UI/query-disclosure-lifecycle.swift @@ -0,0 +1,114 @@ +import SwiftUI +import UIKit +import Vision +@testable import Logseq +@testable import LUIAppleBackend + +@MainActor private final class QueryDisclosureEffects: LGEffectExecuting { + var snapshot: ((Bool) throws -> String)! + var expansions: [Bool] = [] + func execute(_ effect: LGEffect) async -> LGEffectResolution { + guard effect.kind == "result-view" else { + return LGEffectResolution(succeeded: true, message: "", output: .discard) + } + do { + let input = try JSONSerialization.jsonObject(with: Data(effect.text.utf8)) as! [String: Any] + let expanded = input["expanded"] as! Bool + expansions.append(expanded) + return LGEffectResolution(succeeded: true, message: try snapshot(expanded)) + } catch { return LGEffectResolution(succeeded: false, message: String(describing: error)) } + } +} + +@main struct QueryDisclosureLifecycleCheck { + @MainActor static func main() async throws { + for zoomed in [false, true] { + func snapshot(_ expanded: Bool) throws -> String { + let rows: [[String: Any]] = expanded ? (0..<2).map { index in + ["block": ["uuid": "result-\(index)", "title": "Project item \(index + 1)", "pageId": "page"]] + } : [] + var query: [String: Any] = ["ownerUuid": "query-owner", "featureType": "query-result", + "kind": expanded ? "simple" : "deferred", "expanded": expanded, "rows": rows, "views": []] + if expanded { + query["views"] = [["uuid": "query-owner:default", "name": "All", "type": "list", "rows": rows, + "columns": [], "error": NSNull()]] + } + let outline: [[String: Any]] = [["depth": 0, "hasChildren": false, "isCollapsed": false, + "block": ["uuid": "query-owner", "title": "Project query", "pageId": "page", "query": query]]] + var result: [String: Any] = ["selectedGraphId": "query-disclosure", "graphName": "Queries"] + if zoomed { + result["nodeRoutes"] = [["uuid": "query-owner", "page": ["uuid": "page", "title": "Workspace"], + "outlinerRows": outline]] + } else { result["outlinerRows"] = outline } + return String(decoding: try JSONSerialization.data(withJSONObject: ["ok": true, "result": result]), as: UTF8.self) + } + let effects = QueryDisclosureEffects() + effects.snapshot = snapshot + let runtime = LGRuntime(native: LGCoreNativeCaller(), effectExecutor: effects) + try runtime.applyCoreResponse(snapshot(false)) + try runtime.start(platformCode: 2, hostCode: 2, authenticationCode: 3) + try runtime.applyHostUpdate(kind: "graph-loading", payload: "false") + if zoomed { try runtime.applyHostUpdate(kind: "open-node", payload: "\"query-owner\"") } + let backend = runtime.renderer.backend + var frames: [Int: CGRect] = [:] + backend.frameReportingEnabled = true + backend.onFramesReport = { frames = $0 } + func ids(_ id: Int) -> [Int] { + [id] + (backend.model(id: id)?.children ?? backend.extensionModel(id: id)?.children ?? []).flatMap(ids) + } + func find(_ identifier: String) -> Int? { + backend.rootIDs.flatMap(ids).last { + backend.model(id: $0)?.property(.accessibilityIdentifier)?.stringValue == identifier + } + } + func wait(_ message: String, until predicate: () -> Bool) async throws { + for _ in 0..<100 { + if predicate() { return } + try await Task.sleep(for: .milliseconds(20)) + } + throw failure(message + "; runtime=" + (runtime.lastError ?? "none")) + } + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: LGRendererRoot(renderer: runtime.renderer)) + window.makeKeyAndVisible() + defer { window.isHidden = true; window.rootViewController = nil; runtime.stop() } + func visibleText(_ phase: String) async throws -> String { + try await Task.sleep(for: .milliseconds(350)) + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + try image.pngData()!.write(to: URL.documentsDirectory.appendingPathComponent("query-disclosure-\(zoomed)-\(phase).png")) + guard let icon = find("query.disclosure.query-owner"), let frame = frames[icon], + abs(frame.width - 10) < 0.5, abs(frame.height - 10) < 0.5 else { + throw failure("The query disclosure must actually render at the block's 10pt icon size") + } + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + try VNImageRequestHandler(cgImage: image.cgImage!, options: [:]).perform([request]) + return (request.results ?? []).compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") + } + try await wait("Query must mount") { find("query.toggle.query-owner") != nil } + let folded = try await visibleText("folded") + guard folded.contains("Project query"), folded.contains("Results"), effects.expansions.isEmpty else { + throw failure("A deferred query must show its native Results label without executing: \(folded)") + } + try backend.performPress(node: find("query.toggle.query-owner")!) + try await wait("Expanding executes and displays results") { find("query.result.query-owner.1") != nil } + guard try await visibleText("expanded").contains("2 results"), effects.expansions == [true] else { + throw failure("Expanded query must display the updated result count") + } + try backend.performPress(node: find("query.toggle.query-owner")!) + try await wait("Folding removes results") { find("query.result.query-owner.0") == nil } + guard try await visibleText("refolded").contains("Results"), effects.expansions == [true, false] else { + throw failure("Folded query must restore its Results label") + } + try backend.performPress(node: find("query.toggle.query-owner")!) + try await wait("Reopening executes again") { effects.expansions == [true, false, true] && find("query.result.query-owner.1") != nil } + } + print("PASS: deferred queries show native Results labels, expand, update counts, fold and rerun in pages and zoomed blocks") + } + private static func failure(_ message: String) -> NSError { + NSError(domain: "QueryDisclosureLifecycleCheck", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} diff --git a/docs/agent-guide/016-mobile-interaction-regressions.md b/docs/agent-guide/016-mobile-interaction-regressions.md index d204235e..b601a0fa 100644 --- a/docs/agent-guide/016-mobile-interaction-regressions.md +++ b/docs/agent-guide/016-mobile-interaction-regressions.md @@ -287,3 +287,11 @@ The dedicated physical warm control completed ten row/navigation updates while b The old build was also exercised with stable row identities changing between bold text, quotes, formulas, highlighted code, local images and node references during six real native pushes and six pops. Screenshot inspection caught an invalid image-type fixture and then an unfinished image decode; the final fixture uses `png` and waits for the rendered orange image before starting navigation. The saved screenshot confirms actual image, formula, code and quote content. All 42 content submissions occur while a native transition coordinator exists. The final merged stdout/stderr run completes at 8.9337 seconds without the matching fault or a cycle warning (`/tmp/logseq-lifecycle-mixed-verified-image-console.log`). A physical mixed-content attempt was rejected before launch because the phone was locked; no mixed-content device pass is claimed. The follow-up simulator sources, verified source-hash manifest, screenshot and sanitized controls are archived at `/tmp/logseq-lifecycle-mixed-followup-sources.zip` (266,883 bytes; SHA-256 `e91119bb9c7f411624a3b738b5caf25934e83fd1d603aa778a629909ced72bfe`). This is separate from the earlier lifecycle archive; its current simulator manifest must not be used to identify the earlier physical binary. No production code or app installation changed in this crash-audit follow-up. The exact crash has not been reproduced and its causal repair remains open. + +### Invisible collapsed query disclosure regression + +The user's S1 screenshot was reproduced with the original demo graph's mobile-session response: the query and its 44-point Results button exist, but its native label and icon are absent. Commit `72ae7c2` changed the disclosure to button content children for an independently sized icon. The Apple renderer still rendered only the button's text/icon properties and ignored content children, leaving the default collapsed query's sole entry point blank. Earlier native checks verified the retained icon model and expanded results without asserting that the initial Results label was actually drawn. + +LUI `71964a68` renders Button/ToggleButton label children through the existing retained-node renderer, excludes context-menu metadata, and keeps the normal text/icon fallback. Its native runtime check fails on the old renderer with `Fallback Plain`, then passes custom labels, child text updates, metadata exclusion and fallback restoration (`/tmp/lui-button-children-native-{red,green}.log`). The consumer pins the same revision in SwiftPM and opam. + +The new `apple/Tests/UI/query-disclosure-lifecycle.swift` first fails because Results is absent from the screenshot. After the fix, it passes actual rendered Results text and 10-point glyph frames, lazy expansion, updated counts, folding and re-execution on ordinary pages and zoomed query blocks (`/tmp/logseq-query-disclosure-lifecycle-{red,green}.log`). A separate real-session core round trip covers simple/advanced queries on both hosts, including zoom-in; it confirms that execution itself was intact. The full core gate passes 1048 tests in 66.724 seconds (`/tmp/logseq-query-disclosure-core-final.log`). The rebuilt complete iOS query workflow also passes lists/selection, saved views, horizontal tables, cell editing, filter/sort sheets, gallery, search and local errors (`/tmp/logseq-query-fixed-workflow.log`). This fixes the missing query entry point; it does not establish a repair of the unrelated TestFlight crash. diff --git a/logseq.opam b/logseq.opam index 61258471..cc270992 100644 --- a/logseq.opam +++ b/logseq.opam @@ -34,7 +34,7 @@ pin-depends: [ ["melange-transit-core.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["melange-transit-native.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["melange-transit-melange.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] - ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#4281a5160ca9833d3b71901071dcb40228dcc36d"] + ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#71964a686a8fb1d0878e2fe122f28bd155e657ef"] ["drive.dev" "git+https://github.com/logseq/drive.git#dd735a65ed525d1c36db38ac8e8114aa81d314a1"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#02e18cdc5e0ad672aa223f638eaa3ba52fc9421e"] ["mldoc.dev" "git+https://github.com/logseq/mldoc.git#f11230c367964af2b575524d00779cfb1ffc7147"] diff --git a/shared/test/logseq/query_block_test.ml b/shared/test/logseq/query_block_test.ml index 7cd876eb..fd87a8dd 100644 --- a/shared/test/logseq/query_block_test.ml +++ b/shared/test/logseq/query_block_test.ml @@ -464,7 +464,50 @@ let collapsed_queries_do_not_evaluate_until_opened () = member "action" (Json.from_string payload) = `String "setExpanded" | _ -> false) (App.model app).pending_effects))) [App_test.ios_backend; App_test.kotlin_backend] +let results_disclosure_executes_real_session () = + List.iter (fun backend -> + List.iter (fun source -> + let session, _ = make_session (fixture source) in + ignore (Rpc_test.configure_plain_session session); + List.iter (fun zoom -> + ignore (dispatch session "resultView" (Json.to_string (`Assoc ["ownerUuid", `String owner; + "featureType", `String "query-result"; "action", `String "setExpanded"; "expanded", `Bool false]))); + if zoom then ignore (dispatch session "openNode" (Json.to_string (`Assoc ["uuid", `String owner]))); + with_ui backend (Session.snapshot_visible session) (fun app -> + let runtime = Lui_app.runtime app in + let find id = App_test.descendant_with_identifier runtime (Lui_app.root_node app) id in + let toggle () = + let button = find ("query.toggle." ^ owner) in + check ~msg:"the query disclosure remains reachable" (button <> -1); + App_test.dispatch app (Lui_protocol.Press button); + App_test.flush app; + let eff = List.find (function Model.ResultViewEffect _ -> true | _ -> false) + (App.model app).pending_effects in + let id = Model.effect_id eff in + App_test.send app (Model.DequeueEffect id); + let response = Core_effect_test.execute session "result-view" + ["text", (match eff with Model.ResultViewEffect (_, _, payload) -> `String payload | _ -> assert false)] in + check_eq ~msg:(Json.to_string response) (member "ok" response) (`Bool true); + App_test.send app (Model.ResolveEffect (id, true, Json.to_string response)); + (match Response_snapshot.decode_response (Json.to_string response) with + | Ok projection -> App_test.send app (Model.ApplyCoreSnapshot projection) + | Error message -> fail message); + App_test.flush app in + check_eq (find ("query.result." ^ owner ^ ".0")) (-1); + toggle (); + check ~msg:("real query results must mount after expansion: " ^ source) + (find ("query.result." ^ owner ^ ".0") <> -1); + toggle (); + check_eq ~msg:"folded results unmount after a real core response" + (find ("query.result." ^ owner ^ ".0")) (-1); + toggle (); + check ~msg:"queries can run again after folding" + (find ("query.result." ^ owner ^ ".0") <> -1))) [false; true]) + ["#Project"; "{:query [:find (pull ?b [*]) :where [?b :user.property/score ?s] [(> ?s 1)]]}"]) + [App_test.ios_backend; App_test.kotlin_backend] + let cases = [ + case "results disclosure executes and folds real simple and advanced queries" results_disclosure_executes_real_session; case "queries stay unevaluated until expanded and refresh when reopened" collapsed_queries_do_not_evaluate_until_opened; case "result lists render breadcrumbs and independently folded children" result_list_breadcrumbs_and_local_children; case "cross-page results retain editing and select bar actions" cross_page_result_actions;