diff --git a/app/application.ml b/app/application.ml index 006cf2c1..ef1573ca 100644 --- a/app/application.ml +++ b/app/application.ml @@ -2093,62 +2093,32 @@ module Favorites_list = struct let rows = List.map2 (fun key label -> - let label = - if Keys.mem key block_keys - then - V.Navigation_link.create - ~key:(Ui.Key.string ("favorite-open:" ^ key)) - ~activation_id:key - ~enabled:actions_enabled - ~on_activate: - (Ui.Event.Handler.create (fun _ -> - Ui.Event.Handler.Private.invoke on_open (Ui.Event.Payload.Text key))) - ~label - () - else label - in - let context_menu = - if Keys.mem key block_keys - then - Some - (V.Context_menu.create - ~actions: - [ V.Context_menu.action - ~key:(Ui.Key.string "copy") - ~enabled:actions_enabled - ~title:"Copy" - ~symbol:"doc.on.doc" - ~on_press: - (Ui.Event.Handler.create (fun _ -> - Ui.Event.Handler.Private.invoke - on_copy - (Ui.Event.Payload.Text key))) - () - ] - ()) - else None - in - V.Native_list.row - ~key:(Ui.Key.string key) - ~separator:Hidden - ?context_menu - label) - keys - children + let menu = if Keys.mem key block_keys then + [Lui_elements.context_menu + [Lui_elements.menu_item ~text:"Copy" ~icon:(`app "doc-on-doc") + ~disabled:(not actions_enabled) + ~on_press:(fun _ -> Ui.Event.Handler.Private.invoke on_copy + (Ui.Event.Payload.Text key)) []]] + else [] in + Lui_elements.list_item ~key ~separator:`hidden + ~accessibility_identifier:("favorite-open:" ^ key) + ~disabled:(not actions_enabled) + ~on_press:(fun _ -> Ui.Event.Handler.Private.invoke on_open + (Ui.Event.Payload.Text key)) + (Ui.mount label :: menu)) + keys children in - V.Native_list.vertical - ~key:(Ui.Key.string "favorites-native-list") - ~style:Plain - ~on_visible_range - [ V.Native_list.section - ~key:(Ui.Key.string "favorites") - ~separator:Hidden - ~footer - rows - ] - |> V.Viewport.Vertical.with_test_id (Ui.Test_id.string "favorites-native-list") - |> V.Body.Vertical.fill - |> fun content -> V.Body.Vertical.create [ content ] + V.of_lui (Lui_elements.list ~key:"favorites-native-list" ~style:`plain ~grow:1. + ~accessibility_identifier:"favorites-native-list" ~track_visible_range:true + ~on_visible_range:(function + | Lui_protocol.VisibleRange (_,first,last) -> + Ui.Event.Handler.Private.invoke on_visible_range + (Ui.Event.Payload.Visible_range {first_index=Int64.of_int first; + last_exclusive=Int64.of_int last}) + | _ -> ()) + [Lui_elements.list_section ~key:"favorites" ~separator:`hidden + ~footer:(Ui.mount footer) rows]) + |> V.Body.static ;; end @@ -2162,8 +2132,7 @@ let render_title state = ;; let favorites_view - ~render_source - ~render_media + ~render_title ~state ~actions_enabled ~on_visible_range @@ -2213,32 +2182,19 @@ let favorites_view (fun value -> let favorite = Journal_graph_projection.favorite value in let key = favorite.membership_id in - let text = - V.text - (match favorite.target with - | Page _ -> favorite.title - | Block _ -> render_source favorite.title) - in - let label = - if favorite.task_state = Journal_model.No_status - then text - else - V.column - [ text; V.text (Journal_model.status_name favorite.task_state) ] - in - let row = - match favorite.target with - | Page _ -> V.label ~title:label ~icon:(V.symbol ~name:"doc.text" ()) () - | Block _ -> label - in - let root = - match favorite.target with - | Page id | Block id -> id - in - ( key - , V.column - ~key:(Ui.Key.string key) - [ render_media ~title:favorite.title ~root row ] )) + let title = match favorite.target with + | Page _ -> Journal_model.Title.render favorite.title + | Block _ -> render_title favorite.title in + let icon = match favorite.target with + | Page _ -> V.symbol ~name:"doc.text" () + | Block _ -> + V.stack [ Journal_symbols.create ~size:6. Dot ] + |> V.frame ~width:18. ~height:18. in + let label = V.label + ~title:(V.of_lui (Lui_elements.text ~grow:1. ~text_alignment:`start + ~style_class:"single-line" ~value:(Journal_model.Title.plain title) [])) + ~icon () in + key, label) rows) in let footer = @@ -2487,7 +2443,6 @@ let timeline_page ~on_region ~render_source ~render_title - ~render_media ~render_row_media ~platform ~graph_generation @@ -2565,8 +2520,7 @@ let timeline_page then ( on_region "favorites"; favorites_view - ~render_source - ~render_media + ~render_title ~state:favorites ~actions_enabled:interaction_enabled ~on_visible_range:on_favorites_visible_range @@ -2930,7 +2884,7 @@ module Detail_list = struct let context_menu = V.Context_menu.create ~actions: - ([ V.Context_menu.action + ((if id = Journal_model.page_id block then [] else [ V.Context_menu.action ~key:(Ui.Key.string "open") ~title:"Open block" ~symbol:"arrow.up.right.square" @@ -2944,7 +2898,7 @@ module Detail_list = struct ~symbol:"trash" ~on_press:delete () - ; V.Context_menu.action + ]) @ [ V.Context_menu.action ~key:(Ui.Key.string "copy") ~enabled ~title:"Copy" @@ -3055,10 +3009,18 @@ let detail_page | Any_value (Internal_uuid uuid) -> Some (Graph.Uuid.to_string uuid) | _ -> None in + let property_parent_id block = + match Journal_model.parent_id block with + | Some _ as parent -> parent + | None when Journal_model.id block <> Journal_model.page_id block -> + Some (Journal_model.page_id block) + | None -> None + in let owned_value property_uuid value = Option.fold ~none:false ~some:(fun id -> match Journal_detail.find_block detail ~block_id:id with - | Some child -> Journal_model.parent_id child = Some (Journal_model.id block) + | Some child -> property_parent_id child = Some (Journal_model.id block) + && Journal_model.page_id child = Journal_model.page_id block && Option.fold ~none:false ~some:(Graph.Uuid.equal property_uuid) (created_from child) | None -> false) (value_id value) in @@ -3070,7 +3032,7 @@ let detail_page in let property_name = Option.bind (created_from block) (fun property_uuid -> - Option.bind (Journal_model.parent_id block) (fun parent_id -> + Option.bind (property_parent_id block) (fun parent_id -> Option.bind (Journal_detail.find_block detail ~block_id:parent_id) (fun parent -> List.find_map (fun (p : Graph.property_summary) -> if Graph.Uuid.equal p.uuid property_uuid @@ -5749,18 +5711,8 @@ let start in let first_index = bounded first_index in let last_exclusive = max first_index (bounded last_exclusive) in - let roots = - Rrbvec.subvec snapshot.favorites_media_roots first_index last_exclusive - |> Option.get - |> Rrbvec.to_list - in - Journal_media_runtime.retain_visible_roots - ~owner:(media_presentation_scope snapshot ~detail:false) - media_runtime - roots; - Effect.bind (flush_media set_state) ~f:(fun () -> - update (fun state -> - favorites_event state (Visible { first_index; last_exclusive }))) + update (fun state -> + favorites_event state (Visible { first_index; last_exclusive })) | Native_payload (Ui.Event.Payload.Visible_range _) when Journal_routes.destination snapshot.routes = Journal_routes.Favorites -> Effect.ignore @@ -6913,8 +6865,6 @@ let start ~on_region:on_view_region ~render_source:(render_source state) ~render_title:(render_title state) - ~render_media: - (media_label ~store:media_store ~on_region:on_view_region state dispatch) ~render_row_media:(fun ~title ~root ~image_children child -> on_view_region "timeline-row"; row_media_label @@ -7622,11 +7572,6 @@ module For_testing = struct ~on_region:(fun _ -> ()) ~render_source:(render_source initial_state) ~render_title:(render_title initial_state) - ~render_media: - (media_label - ~store:(Journal_media_view.Store.create ()) - initial_state - (ignore_dispatch handler)) ~render_row_media: (row_media_label ~store:(Journal_media_view.Store.create ()) diff --git a/app/journal_detail.ml b/app/journal_detail.ml index 005798ff..63f96ff0 100644 --- a/app/journal_detail.ml +++ b/app/journal_detail.ml @@ -105,7 +105,11 @@ let matching_child pending ~root_id ~child ~parent = command.block_id = Journal_model.id child && command.parent_block_id = Journal_model.id parent && command.parent_block_id = root_id - && Journal_model.parent_id child = Some root_id + && Journal_model.page_id child = Journal_model.page_id parent + && (Journal_model.parent_id child = Some root_id + || (Journal_model.id parent = Journal_model.page_id parent + && Journal_model.page_id child = root_id + && Journal_model.parent_id child = None)) | _ -> false ;; diff --git a/app/journal_graph_runtime.ml b/app/journal_graph_runtime.ml index 1bee8e04..71c938c3 100644 --- a/app/journal_graph_runtime.ml +++ b/app/journal_graph_runtime.ml @@ -1025,6 +1025,18 @@ let scope_output t owner output = { output with responses } ;; +(* A page is a detail root, never an outline mutation target. Its identity + (uuid = page) survives the existing model projection and child ownership. *) +let page_root_member (page : Graph.page) revision : Projection.block_member = + { revision + ; block = + { uuid = page.uuid; page = page.uuid; parent = page.uuid; order = "page" + ; title = page.title; created_at_ms = page.created_at_ms + ; updated_at_ms = page.updated_at_ms; refs = []; tags = page.tags + ; properties = page.properties } + } +;; + let submit_unowned t (request : Journal_graph_request.t) = match request with | Load_favorites request -> @@ -1103,7 +1115,11 @@ let submit_unowned t (request : Journal_graph_request.t) = ; after = Option.bind after (fun cursor -> cursor.Projection.protocol_cursor) }) - (Protocol.V2_get_outline_block { block; revision = None }) + (if Hashtbl.mem t.graph_pages block_id + || (match Hashtbl.find_opt t.favorite_targets block_id with + | Some (V2_favorite_page _) -> true | _ -> false) + then Protocol.V2_get_page { page = block; revision = None } + else Protocol.V2_get_outline_block { block; revision = None }) ]) | Find_block block_id -> (match parse_uuid "block UUID" block_id with @@ -1237,7 +1253,10 @@ let submit_unowned t (request : Journal_graph_request.t) = [ { uuid = child; title = command.source; children = [] } ] ; preconditions = preconditions - ~blocks:[ parent, command.expected_parent_revision ] + ~blocks:(if Graph.Uuid.equal parent page.uuid then [] + else [ parent, command.expected_parent_revision ]) + ~pages:(if Graph.Uuid.equal parent page.uuid + then [ page.uuid, command.expected_parent_revision ] else []) ~scopes:[ scope_revision ] () }) @@ -2018,11 +2037,16 @@ let hydration_for_changes t ~request_generation windows = Hashtbl.to_seq_values t.children_interests |> Seq.exists (fun interest -> Graph.Uuid.equal interest.page.uuid page) in + let refreshed_pages = Hashtbl.create 16 in let point_reads = List.filter_map (fun block -> let block_id = Graph.Uuid.to_string block in - match page_by_block t block_id with + if Hashtbl.mem t.graph_pages block_id && owns_detail_page block then ( + Hashtbl.replace refreshed_pages block_id (); + Some (read t (Changed_detail_page block) + (Protocol.V2_get_page {page=block;revision=None}))) + else match page_by_block t block_id with | Some page -> Some (read @@ -2078,6 +2102,7 @@ let hydration_for_changes t ~request_generation windows = List.filter_map (fun page -> if owns_detail_page page + && not (Hashtbl.mem refreshed_pages (Graph.Uuid.to_string page)) then Some (read @@ -2208,6 +2233,8 @@ let rehydrate_current_interests t ~request_generation ~generation = in let detail_roots = Hashtbl.to_seq_values t.children_interests + |> Seq.filter (fun (interest : children_interest) -> + not (Graph.Uuid.equal interest.root.block.uuid interest.page.uuid)) |> Seq.map (fun (interest : children_interest) -> let block_id = Graph.Uuid.to_string interest.root.block.uuid in read @@ -2303,16 +2330,41 @@ let read_child t command page = ;; let read_child_parent t command page child = + let parent = Graph.Uuid.of_string command.Projection.parent_block_id |> Result.get_ok in requests - [ read - t - (Child_parent (command, page, child)) - (Protocol.V2_get_outline_block - { block = - Graph.Uuid.of_string command.Projection.parent_block_id |> Result.get_ok - ; revision = None - }) - ] + [ read t (Child_parent (command, page, child)) + (if Graph.Uuid.equal parent page.Graph.uuid + then Protocol.V2_get_page { page = parent; revision = None } + else Protocol.V2_get_outline_block { block = parent; revision = None }) ] +;; + +let complete_child t (command : Projection.create_child) (page : Graph.page) + (child : Projection.block_member) (parent : Projection.block_member) = + if Graph.Uuid.to_string parent.block.uuid <> command.parent_block_id + || Graph.Uuid.to_string child.block.uuid <> command.block_id + || not (Graph.Uuid.equal child.block.parent parent.block.uuid) + || not (Graph.Uuid.equal child.block.page page.uuid) + || not (Graph.Uuid.equal parent.block.page page.uuid) + then reject "The append completion does not match the selected parent." + else match projection_time_context t with + | Error message -> reject message + | Ok time_context -> + let old_count = Hashtbl.find_opt t.projected_blocks command.parent_block_id + |> Option.map Journal_model.child_count |> Option.value ~default:0 in + match + (Projection.block_on_page ~page ~revision:parent.revision + ~child_count:(old_count + 1) ~time_context parent.block, + Projection.block_on_page ~page ~revision:child.revision ~child_count:0 + ~time_context child.block) with + | Ok parent, Ok child -> + Hashtbl.replace t.projected_blocks command.parent_block_id parent; + let completion = response (Child_created {child;parent;timeline_entry_update=None}) in + let refresh = match page_by_uuid t page.uuid with + | None -> empty + | Some page -> match page_tree t (Changed_page_tree page) page Protocol.maximum_page_size with + | Ok request -> requests [request] | Error _ -> empty in + {refresh with responses=completion::refresh.responses} + | Error message,_ | _,Error message -> reject message ;; let operation_name = function @@ -2812,6 +2864,19 @@ let receive_response t (protocol_response : Protocol.response) = else complete_reference_read t ~source_epoch ~block_id ~depth (Some page.title)) + | V2_present_page {page;revision}, Detail_block {generation;block_id;limit;after} + when Graph.Uuid.to_string page.uuid = block_id && not page.recycled -> + remember_page_revision t page.uuid revision; + Hashtbl.replace t.graph_pages block_id page; + detail_children t ~generation ~page ~root:(page_root_member page revision) ~limit ~after + | (V2_missing_page _ | V2_present_page _), Detail_block {generation;block_id;_} -> + responses [response (Detail_failed {request_generation=generation;block_id; + missing=true;stale_cursor=false;failure=Projection_failure "This page is unavailable."})] + | V2_present_page {page;revision}, Child_parent (command,_,child) + when Graph.Uuid.to_string page.uuid = command.parent_block_id && not page.recycled -> + remember_page_revision t page.uuid revision; + Hashtbl.replace t.graph_pages command.parent_block_id page; + complete_child t command page child (page_root_member page revision) | _, Favorite_dependency { epoch; _ } when epoch <> t.favorite_epoch -> empty | _, Favorite_dependency { uuid; seen; page; _ } when Hashtbl.mem t.favorite_dirty (Graph.Uuid.to_string uuid) -> @@ -2846,7 +2911,7 @@ let receive_response t (protocol_response : Protocol.response) = then responses [ response Favorites_invalidated ] else empty | V2_missing_page _, Favorite_page_candidate _ -> empty - | V2_present_page { page; revision }, Changed_detail_page _ -> + | V2_present_page { page; revision }, Changed_detail_page _ when not page.recycled -> Hashtbl.replace t.graph_pages (Graph.Uuid.to_string page.uuid) page; remember_page_revision t page.uuid revision; let reconciled = ref [] in @@ -2854,7 +2919,9 @@ let receive_response t (protocol_response : Protocol.response) = (fun key (interest : children_interest) -> if Graph.Uuid.equal interest.page.uuid page.uuid then ( - let interest = { interest with page } in + let root = if Graph.Uuid.equal interest.root.block.uuid page.uuid + then page_root_member page revision else interest.root in + let interest = { interest with page; root } in Option.iter (fun children -> match projection_time_context t with @@ -2881,7 +2948,8 @@ let receive_response t (protocol_response : Protocol.response) = Some (if Graph.Uuid.equal old.Graph.uuid page.uuid then page else old)) t.detail_pages; responses (List.rev !reconciled) - | V2_missing_page { uuid; _ }, Changed_detail_page _ -> + | (V2_missing_page { uuid; _ } + | V2_present_page {page={uuid;recycled=true;_};_}), Changed_detail_page _ -> responses (Hashtbl.to_seq_values t.children_interests |> Seq.filter_map (fun (interest : children_interest) -> @@ -3168,17 +3236,10 @@ let receive_response t (protocol_response : Protocol.response) = (Detail_page { generation; root; limit; after }) (Protocol.V2_get_page { page = value.block.page; revision = None }) ]) - | V2_missing_block _, Detail_block { generation; block_id; _ } -> - responses - [ response - (Detail_failed - { request_generation = generation - ; block_id - ; missing = true - ; stale_cursor = false - ; failure = Projection_failure "This block is unavailable." - }) - ] + | V2_missing_block {uuid;_}, Detail_block { generation; block_id; limit; after } + when Graph.Uuid.to_string uuid = block_id -> + requests [read t (Detail_block {generation;block_id;limit;after}) + (Protocol.V2_get_page {page=uuid;revision=None})] | V2_present_block { value; revision }, Child_read (command, page) -> remember_block_revision t value.block.uuid revision; let child = Projection.{ block = value.block; revision } in @@ -3201,49 +3262,7 @@ let receive_response t (protocol_response : Protocol.response) = ] | V2_present_block { value; revision }, Child_parent (command, page, child) -> remember_block_revision t value.block.uuid revision; - (match projection_time_context t with - | Error message -> reject message - | Ok time_context -> - let old_count = - Hashtbl.find_opt t.projected_blocks command.parent_block_id - |> Option.map Journal_model.child_count - |> Option.value ~default:0 - in - (match - ( Projection.block_on_page - ~page - ~revision - ~child_count:(old_count + 1) - ~time_context - value.block - , Projection.block_on_page - ~page - ~revision:child.revision - ~child_count:0 - ~time_context - child.block ) - with - | Ok parent, Ok child -> - Hashtbl.replace t.projected_blocks command.parent_block_id parent; - let completion = - response (Child_created { child; parent; timeline_entry_update = None }) - in - let refresh = - match page_by_uuid t page.uuid with - | None -> empty - | Some page -> - (match - page_tree - t - (Changed_page_tree page) - page - Protocol.maximum_page_size - with - | Ok request -> requests [ request ] - | Error _ -> empty) - in - { refresh with responses = completion :: refresh.responses } - | Error message, _ | _, Error message -> reject message)) + complete_child t command page child Projection.{block=value.block;revision} | (V2_present_block _ | V2_missing_block _), Reference_read { block_id; depth; lookup = Outline_lookup } -> let source = @@ -3780,6 +3799,10 @@ let receive t (protocol_response : Protocol.response) = | ( (Changed_detail_block { block_id; _ } | Changed_block { block_id; _ }) , V2_block_outcome (V2_present_block { value; _ }) ) when Hashtbl.mem t.detail_pages block_id -> Seq.return value.block + | Changed_detail_page _, V2_page_outcome (V2_present_page {page;revision}) + when not page.recycled + && Hashtbl.mem t.detail_pages (Graph.Uuid.to_string page.uuid) -> + Seq.return (page_root_member page revision).block | _ -> Seq.empty in let known_pages = ref Rrbvec.empty in @@ -3926,6 +3949,7 @@ module Copy = struct type phase = | Before | Root + | Page_root | Children | After @@ -4057,6 +4081,15 @@ module Copy = struct ; frames = [ current ] } current) + | Root, V2_block_outcome (V2_missing_block {uuid;_}) when Graph.Uuid.equal uuid job.root -> + read t {job with phase=Page_root} (Protocol.V2_get_page {page=job.root;revision=None}) + | Page_root, V2_page_outcome (V2_present_page {page;_}) + when Graph.Uuid.equal page.uuid job.root && not page.recycled -> + (match add_text job page.title with + | Error message -> fail t message + | Ok job -> let current=frame job.root 1 in + read_children t {job with page=Some page.uuid;phase=Children;frames=[current]} current) + | Page_root, V2_page_outcome _ -> fail t "The selected page is unavailable." | Children, V2_children_outcome result -> (match job.frames with | current :: parents diff --git a/app/journal_header.ml b/app/journal_header.ml index 9c4eb189..f6ef8cb4 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -453,7 +453,9 @@ let view_impl bottom_controls ~key:"journal-bottom-controls" ~controls: - (V.row ~spacing:16. [ destinations; V.spacer (); capture ] + (V.row ~spacing:16. + ([ destinations; V.spacer () ] + @ (match context with Context.Journals -> [ capture ] | Favorites -> [])) |> test_id "journal-bottom-controls") body else body diff --git a/app/journal_routes.ml b/app/journal_routes.ml index 160a77f9..31616251 100644 --- a/app/journal_routes.ml +++ b/app/journal_routes.ml @@ -400,8 +400,7 @@ let open_favorite (item : Logseq_db_worker.Protocol.v2_favorite_item) = match item.target with - | V2_favorite_page _ -> t, None - | V2_favorite_block { uuid; _ } -> + | V2_favorite_page { uuid; _ } | V2_favorite_block { uuid; _ } -> let block_id = Logseq_db_types.Graph_types.Uuid.to_string uuid in ( open_detail t ~block_id ~request_generation , Some diff --git a/docs/agent-guide/implemented/feature/2026-10-16-favorites-page-detail.md b/docs/agent-guide/implemented/feature/2026-10-16-favorites-page-detail.md new file mode 100644 index 00000000..a5ed8ca6 --- /dev/null +++ b/docs/agent-guide/implemented/feature/2026-10-16-favorites-page-detail.md @@ -0,0 +1,56 @@ +# Favorites page and outline detail + +## Problem + +Favorites page rows cannot open detail, and favorite outline titles do not use the shared mldoc/reference presentation. Detail root reads and append/copy confirmation currently assume outline blocks. + +## Proposal + +Keep the public Load_detail request and existing route, staging, grace timer, traversal and asset owners. Resolve an outline root through its existing read, falling back to get_page only on a real outline miss; reuse known page identity for subsequent loads. Adapt page roots to the existing display model with id equal to page_id, page properties and a page revision. Children retain their existing bounded reads. Page append uses page and children-scope preconditions and confirms the exact page parent; outline append keeps block preconditions. Page root menus omit outline-only deletion. Extend the existing Copy reducer root lookup to accept pages without introducing another traversal. + +The Favorites root omits the bottom-right Capture opener; Journals keeps its root opener and page/outline detail keeps its own Capture. This is a context-based rendering choice in the existing OCaml header, without another state or interface. No public pure reducer owns whether a header control mounts; test that missing boundary through the public header/Application rendering interface. + +Favorites use one native list with the existing page document symbol and a small solid bullet for outline blocks, whole-row LUI list items, no trailing chevron, no status/media/summary, and single-line tail truncation. Favorites titles use the shared Title.render lookup and Title.plain output in ordinary LUI text; UUID references remain non-clickable, and ordinary URLs retain the prior Favorites text behavior. The list uses only Lui_elements.list/list_item, label, text and icons; no Journal Swift extension implements the list. Keep the existing page-first UUID reference reader, cache, deduplication, epochs and graph reset. Preserve ordinary URL semantics and deprecated double-parenthesis behavior. + +## Decision + +Use the shared detail flow for both Favorite target kinds, with one internal page-root adapter and exact page-aware mutation confirmation. Use the existing LUI list and single-line text policy, reuse mldoc/reference caches, and omit Capture only for the Favorites root context. Outline rows use the existing Dot symbol at 6pt inside an 18pt icon slot, preserving alignment with unchanged page icons and avoiding a task-control appearance. + +## Alternatives considered + +### Separate page-detail state machine + +Rejected because navigation, initial publication, loading grace, pagination and draft ownership already have one canonical owner. + +### Query both page and outline for every root + +Rejected because known root identity and a single fallback avoid duplicate reads. + +## Acceptance criteria + +- Page and outline Favorites enter the same detail flow and return to Favorites; empty page roots render naturally. +- Existing 100ms grace, complete initial body publication, independent asset states, ten-level cap, cycle protection and 128-item pages remain intact. +- Page append and copy use the selected page identity, including empty pages and journal pages; stale navigation/graph completions cannot publish elsewhere. +- Favorites references resolve journal page date titles and outline titles through the shared resolver; repeated targets share reads, absent targets and obsolete results retain existing fences. +- Run the public pure owner tests first, preserving red evidence before implementation; verify Simulator with independent synthetic data/device and report its compilation and coverage boundaries. +- No protected spec, Dune or lock edits; no production install or real graph/cloud writes. Publication follows the user's later explicit PR authorization. + +## Risks + +- Page roots must never be mistaken for outline mutation targets. Existing model children on a page have parent_id=None, so append completion matching must also verify page_id. Native build inputs need coordinated reuse and retain experimental ABI limitations. + +## Validation + +The pre-bullet commit 10a8d59 passed 139 runtime, 104 mounted Application and 45 route/detail cases, with native source hashes bound to isolated run-2_x07chr. The later icon-only change passed the narrow public render regression (RED then GREEN) and a single final-source native list screenshot case using run-ipiiemd4; the full interaction matrix was intentionally not repeated. The dedicated ECFF synthetic Simulator passed A/B (list, both entries/Back, empty page, ten-level body and 128 continuation) and C/D (slow body with usable header/Back, switching while the slow request is pending, exact page/outline text append and clipboard subtree). The list omits Capture while page/outline detail retains it. The controlled fixture validates page versus block preconditions and logs exact parent/page identities. Repeated UUID references use one page-first lookup per launch. Earlier fixture inconsistencies and test-selector failures were corrected; final accepted runs have zero failures. + +After PR81 merged at e87eb28, the independent branch rebased without conflicts and passed all 139 runtime, 104 Application and 45 route/detail cases again. Production runtime source bytes match the accepted pre-rebase native source; upstream changed build/CI paths. The original native manifests remain intact as pre-rebase evidence, without claiming a new Apple build or GUI run. + +The independent PR review identified two page-property edges. Public renderer and runtime regressions reproduced both before the fix: top-level page property values lost their name/duplicated the owning field, and a page-only property refresh did not discover new reference demands. Presentation now derives a top-level value's page parent from its existing page identity; page refresh reuses the existing reference observer only when that page is an owned detail root. The regressions include outline behavior, hidden properties, repeated references and cache reuse. Final related suites pass 105 Application, 140 runtime and 45 route/detail cases, plus the source boundary check. The original native screenshots predate these property fixes; their final behavior is verified at the public renderer/runtime boundaries without claiming a new final-source native build. + +## Consequences + +The implementation reuses the existing state owners, bounded traversal and cache. Page roots are display adapters identified by id equal to page_id, with page revision/scoped append confirmation; outline-only actions are omitted for them. No protected spec, Dune, lock or Swift source changes are needed. Native evidence uses in-memory protocol responses, not persisted graph data or cloud writes. Text Capture/Copy was exercised; native picker/assets and complete iOS ABI are outside this probe. The exact loading threshold and negative traversal/ownership paths remain covered by public owner tests. Host ARM64 restamping remains experimental with cross_abi_validated=false and production_source_validated=false. + +## Questions + +None. The user selected single-line truncation, replacing the two-line proposal, and explicitly removed the Favorites root Capture opener. The user subsequently requested a small solid bullet instead of the hollow outline icon at 2026-10-16 07:28:59 UTC. The user also authorized updating only the dedicated ECFF synthetic Simulator on 2026-10-16 at 07:07:23 UTC. That installation retry was approved; final current-source acceptance is complete. At 07:43:14 UTC the user authorized a formal PR after PR81 merged, with an independent review session and CI follow-up. Merge, phone/production install and real graph/cloud writes remain outside this task. diff --git a/test/application_view_test.ml b/test/application_view_test.ml index 2d6f6526..035b1d10 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -390,6 +390,7 @@ let run_favorites_native_visibility ?(check_detail_wait = false) ?(check_copy = false) ?(check_navigation = false) + ?(check_favorite_targets = false) ?(check_detail_row_action = false) ?(check_covered_update = false) ?calendar_change @@ -1286,21 +1287,8 @@ let run_favorites_native_visibility wait label (fun () -> Option.is_some (find "text" "Fixture 48")); if Option.is_none (find "text" "Fixture 64") then ( - let rec list_ancestor id = - if - List.assoc_opt - "_extension" - (Option.value (Hashtbl.find_opt props id) ~default:[]) - = Some (`String "journal-list") - then id - else list_ancestor (Hashtbl.find parents id) - in - let list_node = list_ancestor (Option.get (find "text" "Fixture 48")) in - hooks.extension_event - list_node - "event" - {|{"id":1,"payload":"{\"type\":\"visible_range\",\"first\":38,\"last\":50}"}|} - |> consume); + let list_node = Option.get (find "accessibility-identifier" "favorites-native-list") in + dispatch (Lui_protocol.VisibleRange (list_node,38,50))); wait "second Favorite page loaded" (fun () -> Option.is_some (find "text" "Fixture 64")) in @@ -2226,17 +2214,18 @@ let run_favorites_native_visibility dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Favorites"))); load_all_favorites "copy Favorites loaded"; - let list = Option.get (find "_extension" "journal-list") in - check_copy_text list (G.Uuid.to_string (uuid 1001)) "Timeline fixture 1"; clipboard_requests := []; - send_copy list (G.Uuid.to_string (uuid 1000)); - for _ = 1 to 5 do - hooks.pump () |> consume - done; - Alcotest.(check int) - "page favorite has no Copy" - 0 - (List.length !clipboard_requests); + let favorite_row_node=Option.get (find "accessibility-identifier" + ("favorite-open:" ^ G.Uuid.to_string (uuid 1001))) in + let rec below id node = id=node || match Hashtbl.find_opt parents node with + | Some parent->below id parent | None->false in + let copy=Option.get (find ~accept:(below favorite_row_node) "text" "Copy") in + dispatch (Lui_protocol.Press copy); + wait "LUI Favorites menu reaches clipboard" (fun () -> !clipboard_requests<>[]); + let page_row=Option.get (find "accessibility-identifier" + ("favorite-open:" ^ G.Uuid.to_string (uuid 1000))) in + Alcotest.(check bool) "page favorite has no Copy menu" false + (Option.is_some (find ~accept:(below page_row) "text" "Copy")); dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Journals"))); let root_list = Option.get (find "_extension" "journal-list") in @@ -3523,6 +3512,13 @@ let run_favorites_native_visibility | Some current -> current <> previous && Option.is_some (find "text" "Timeline fixture 2") | None -> false)); + if check_favorite_targets then + Alcotest.(check bool) "Timeline root retains Capture" true + (Option.is_some (find "accessibility-identifier" "journal-capture-open")); + (* A retained draft starts through the Journals opener; Favorites no longer + has a Capture opener of its own. *) + if check_draft then + dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Capture"))); let favorites = Option.get (find "accessibility-label" "Favorites") in if check_visible then ( @@ -3541,6 +3537,34 @@ let run_favorites_native_visibility dispatch (Lui_protocol.Press favorites); load_all_favorites "Favorites loaded"; settle_worker (); + if check_favorite_targets then ( + Alcotest.(check bool) "Favorites root omits Capture" true + (Option.is_none (find "accessibility-identifier" "journal-capture-open")); + let original=Option.get (find "accessibility-identifier" "favorites-native-list") in + List.iter (fun n -> + let row=Option.get (find "accessibility-identifier" + ("favorite-open:" ^ G.Uuid.to_string (uuid (1000+n)))) in + dispatch (Lui_protocol.Press row); + wait "Favorites opens exact page or outline target" (fun () -> + Option.is_some (find "accessibility-identifier" + ("detail-block:" ^ G.Uuid.to_string (favorite_target_uuid n)))); + Alcotest.(check bool) "Favorites page and outline detail retain Capture" true + (Option.is_some (find "accessibility-identifier" "journal-detail-capture-open")); + let navigator=Option.get (find "_extension" "navigation-stack") in + List.iter (fun event -> + let revision=List.assoc "revision" (Hashtbl.find props navigator) + |> Yojson.Safe.Util.to_int in + hooks.extension_event navigator event (Yojson.Safe.to_string + (`Assoc ["revision",`Int revision;"length",`Int 0])) |> consume) + ["path-changed";"settled"]; + wait "native Back returns to Favorites" (fun () -> + find "accessibility-identifier" "favorites-native-list" = Some original); + Alcotest.(check bool) "Back to Favorites still omits root Capture" true + (Option.is_none (find "accessibility-identifier" "journal-capture-open"))) [0;1]; + dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Journals"))); + wait "Timeline Capture returns after native Back" (fun () -> + Option.is_some (find "accessibility-identifier" "journal-capture-open")); + dispatch (Lui_protocol.Press (Option.get (find "accessibility-label" "Favorites")))); if check_chrome then ( Hashtbl.clear regions; @@ -3558,8 +3582,6 @@ let run_favorites_native_visibility (Option.value (Hashtbl.find_opt regions "timeline") ~default:0); if check_draft then ( - let capture = Option.get (find "accessibility-label" "Capture") in - dispatch (Lui_protocol.Press capture); let editor = Option.get (find "style-class" "composer-input") in Hashtbl.clear regions; dispatch (Lui_protocol.TextChanged (editor, "first")); @@ -3632,56 +3654,15 @@ let run_favorites_native_visibility (Option.value (Hashtbl.find_opt regions "detail") ~default:0)); if (not check_detail) && not check_generation then ( - let show n = - let text = Option.get (find "text" (favorite_label n)) in - dispatch (Lui_protocol.Appear (ancestor_property text "appear-enabled")) - in - for n = 0 to 63 do - show n; - wait (Printf.sprintf "root %d query completed" n) (fun () -> - List.mem (favorite_target_uuid n) (Atomic.get queried)); - (* Drain the response as well, so the next root is not query-concurrency limited. *) - for _ = 1 to 3 do - hooks.pump () |> consume; - Unix.sleepf 0.001 - done - done; - let list_node = Option.get (find "_extension" "journal-list") in - (* The native list extension callback converts Visible_range to Int64_pair. *) - hooks.extension_event - list_node - "event" - (Yojson.Safe.to_string - (`Assoc - [ "id", `Int 1 - ; "payload", `String {|{"type":"visible_range","first":64,"last":65}|} - ])) - |> consume; - show 64; - wait "65th root admitted after real Favorites Int64_pair event" (fun () -> - List.mem (favorite_target_uuid 64) (Atomic.get queried)); - let list_node = Option.get (find "_extension" "journal-list") in - hooks.extension_event - list_node - "event" - (Yojson.Safe.to_string - (`Assoc - [ "id", `Int 1 - ; "payload", `String {|{"type":"visible_range","first":64,"last":65}|} - ])) - |> consume; - show 64; - for _ = 1 to 3 do - hooks.pump () |> consume; - Unix.sleepf 0.001 - done; - Alcotest.(check int) - "same visible page root does not refetch" - 1 - (List.length - (List.filter - (G.Uuid.equal (favorite_target_uuid 64)) - (Atomic.get queried)))))))) + let before=Atomic.get queried in + let list_node=Option.get (find "accessibility-identifier" "favorites-native-list") in + dispatch (Lui_protocol.VisibleRange (list_node,64,65)); + for _=1 to 5 do hooks.pump () |> consume done; + Alcotest.(check int) "Favorites has no media body or lease acquisition" 0 + (if Option.is_some (find "path" "/tmp/targeted-media.png") then 1 else 0); + Alcotest.(check bool) "Favorites visible range does not hydrate media roots" true + (Atomic.get queried=before)))))) + ;; (* The root reducer has no Worker accepted-ID/terminal-event boundary. This @@ -4262,6 +4243,153 @@ let with_chrome_payloads initial build run = run app payloads) ;; +(* Property naming/deduplication is owned by the renderer, with no public pure + reducer presentation boundary. Mount that renderer using public route/model + values; page children intentionally retain their canonical parent_id=None. *) +let test_detail_page_property_value_presentation () = + let module G = Logseq_db_types.Graph_types in + let id n = Printf.sprintf "86000000-0000-4000-8000-%012d" n in + let uuid n = G.Uuid.of_string (id n) |> Result.get_ok in + let property : G.property_summary = + { ident="user.property/summary"; uuid=uuid 4; title="Summary" + ; schema={property_type=Node;cardinality=One;hidden=false;public=false} + ; values=[Entity_value (uuid 3)]; values_truncated=false } in + let marker : G.property_summary = + { property with ident="logseq.property/created-from-property" + ; schema={property.schema with hidden=true}; values=[Entity_value (uuid 4)] } in + let make n ~parent_id ~source ~properties ~child_count = + Journal_model.create_on_page ~id:(id n) ~page_id:(id 1) ~journal_day:None + ~parent_id ~sibling_order:"a" ~source ~task_state:No_status ~child_count + ~revision:"r1" ~last_mutation_id:(id 9) + ~creation_time:(Journal_time.create ~instant_unix_ms:0L ~local_day:20260908 + ~local_minute_of_day:0 |> Result.get_ok) + |> Result.get_ok |> fun block -> Journal_model.with_properties block ~properties in + List.iter (fun page_root -> + let root_id = if page_root then 1 else 2 in + let root=make root_id ~parent_id:None ~source:"Selected root" + ~properties:[property] ~child_count:1 in + let child=make 3 ~parent_id:(if page_root then None else Some (id root_id)) + ~source:"Property value body" ~properties:[marker] ~child_count:0 in + let routes=Journal_routes.open_detail (Journal_routes.create ()) + ~block_id:(id root_id) ~request_generation:1L + |> fun routes -> Journal_routes.apply_detail_response routes ~request_generation:1L + {root;children={blocks=[child];continuation=None}} in + let routes, reads=Journal_routes.load_initial routes in + let routes=List.fold_left (fun routes -> function + | Journal_graph_request.Load_detail request -> + Alcotest.(check string) "prepare the property's complete leaf body" + (id 3) request.block_id; + Journal_routes.apply_detail_response routes ~request_generation:request.request_generation + {root=child;children={blocks=[];continuation=None}} + | _ -> Alcotest.fail "unexpected initial detail request") routes reads in + Alcotest.(check bool) "initial body is prepared before rendering" true + (Option.is_some (Journal_routes.detail routes)); + let texts=Hashtbl.create 16 in + let track=track_wire_teardown (Hashtbl.remove texts) in + let backend : Lui_protocol.backend = + {backend_profile=Lui_protocol.profile IOS SwiftUIHost + ;apply_batch=(fun batch -> List.iter (fun op -> track op; match op with + | Lui_protocol.SetProp(id,TextValue,StringValue text) -> Hashtbl.replace texts id text + | Lui_protocol.SetExtensionProp(id,"payload",StringValue json) -> + (match Yojson.Safe.Util.member "text" (Yojson.Safe.from_string json) with + | `String text -> Hashtbl.replace texts id text | _ -> ()) + | _ -> ()) batch.Lui_protocol.ops; true)} in + let app=Lui_app.create_with_extensions backend Journal_lui_native.registry () + (fun () () -> ()) (fun _ _ _ -> Application.For_testing.detail_page + ~routes ~write_enabled:false (fun _ -> ()) + |> Journal_view.View.Body.Private.to_widget |> Journal_view.mount) in + Fun.protect ~finally:(fun () -> ignore (Lui_app.dispose app)) (fun () -> + Alcotest.(check bool) "property detail mounts" true (Lui_app.start app); + ignore (Lui_app.flush app); + let count text=Hashtbl.fold (fun _ value n -> if text=value then n+1 else n) texts 0 in + Alcotest.(check int) "owned value keeps its property name" 0 (count "Property"); + Alcotest.(check int) "owned value appears under one named field" 1 (count "Summary"); + Alcotest.(check int) "property value body appears once" 1 (count "Property value body"))) + [true;false] +;; + +let test_favorites_compact_shared_titles () = + let module G=Logseq_db_types.Graph_types in + let module P=Logseq_db_worker.Protocol in + let id n=G.Uuid.of_string (Printf.sprintf "85000000-0000-4000-8000-%012d" n) |> Result.get_ok in + let items=List.init 2 (fun n -> + {P.membership_uuid=id (n+1);membership_order=string_of_int n;membership_revision="m"; + target=(if n=0 then V2_favorite_page {uuid=id 10;title="Page";revision="p"} + else V2_favorite_block {uuid=id 11;title="**Favorite** [[Named page]]";revision="b";task_status=Some V2_todo})}) in + let ops=ref [] in + let backend : Lui_protocol.backend = + {backend_profile=Lui_protocol.profile IOS SwiftUIHost; + apply_batch=(fun batch->ops:=List.rev_append batch.Lui_protocol.ops !ops;true)} in + let app=Lui_app.create_with_extensions backend Journal_lui_native.registry () (fun () ()->()) + (fun _ _ _ -> Journal_view.mount (Application.For_testing.favorites_page items)) in + Fun.protect ~finally:(fun ()->ignore(Lui_app.dispose app)) (fun () -> + Alcotest.(check bool) "Favorites mounts" true (Lui_app.start app); + ignore(Lui_app.flush app); + let has prop value=List.exists (function Lui_protocol.SetProp(_,p,v)->p=prop && v=value|_->false) !ops in + Alcotest.(check bool) "shared mldoc title is plain text" true + (has TextValue (StringValue "Favorite [[Named page]]")); + Alcotest.(check int) "both title nodes use existing single-line policy" 2 + (List.length (List.filter (function + | Lui_protocol.SetProp (_,StyleClass,StringValue "single-line") -> true + | _ -> false) !ops)); + Alcotest.(check bool) "status is omitted" false (has TextValue (StringValue "Todo")); + Alcotest.(check bool) "page icon" true (has IconName (StringValue "app:doc-text")); + let bullet = List.find_map (function + | Lui_protocol.SetProp (id,IconName,StringValue "app:circle-fill") -> Some id + | _ -> None) !ops in + Alcotest.(check bool) "block uses a solid text bullet" true (Option.is_some bullet); + let bullet = Option.get bullet in + List.iter (fun key -> + let size = List.find_map (function + | Lui_protocol.SetProp (id,p,Lui_protocol.IntValue value) + when id=bullet && p=key -> Some value + | _ -> None) !ops in + Alcotest.(check bool) "bullet is smaller than a task control" true + (Option.fold ~none:false ~some:(fun value -> value>0 && value<=8) size)) + [Lui_protocol.WidthValue;HeightValue]; + Alcotest.(check bool) "no hollow task-like circle" false (has IconName (StringValue "app:circle")); + Alcotest.(check bool) "no trailing arrow" false (has IconName (StringValue "app:chevron-right")); + Alcotest.(check int) "uses LUI list" 1 (List.length(List.filter (function Lui_protocol.CreateNode(_,ListContainer)->true|_->false) !ops)); + Alcotest.(check bool) "no Journal list extension" false + (List.exists (function Lui_protocol.CreateExtension(_,"journal-list",_)->true|_->false) !ops)) +;; + +(* Header control placement has no public reducer event/state; exercise the + public OCaml header mount, including disabled and enabled capture gates. *) +let test_root_capture_placement () = + List.iter (fun platform -> + List.iter (fun favorites -> + List.iter (fun capture_enabled -> + let ops = ref [] in + let backend : Lui_protocol.backend = + { backend_profile = Lui_protocol.profile IOS SwiftUIHost + ; apply_batch = (fun batch -> ops := batch.Lui_protocol.ops @ !ops; true) } in + let noop = Journal_view.Event.Handler.create (fun _ -> ()) in + let app = Lui_app.create_with_extensions backend Journal_lui_native.registry () + (fun () () -> ()) (fun _ _ _ -> + Journal_view.mount (Journal_header.view + ~key:(Journal_view.Key.string "capture-placement") ~platform + ~context:(if favorites then Journal_header.Context.favorites else Journal_header.Context.journals) + ~sync_phase:None ~sync_error:None ~on_error_info:None ~on_account_action:None + ~local_deletion_available:false ~on_journals:noop ~on_favorites:noop + ~on_capture:noop ~capture_enabled ~capture_expanded:None + ~body:(Journal_view.View.text "Root body"))) in + Fun.protect ~finally:(fun () -> ignore (Lui_app.dispose app)) (fun () -> + Alcotest.(check bool) "root header mounts" true (Lui_app.start app); + ignore (Lui_app.flush app); + let has key value = List.exists (function + | Lui_protocol.SetProp (_,p,Lui_protocol.StringValue text) -> p=key && text=value + | _ -> false) !ops in + Alcotest.(check bool) "Capture is only mounted in Journals root" (not favorites) + (has AccessibilityIdentifier "journal-capture-open"); + Alcotest.(check bool) "Favorites has no Capture action/semantics" (not favorites) + (has AccessibilityLabel "Capture"); + List.iter (fun destination -> + Alcotest.(check bool) "destination navigation remains mounted" true + (has AccessibilityLabel destination)) ["Journals";"Favorites"])) [false;true]) + [false;true]) ["ios";"macos"] +;; + let test_timeline_relative_date_headers () = let noop = Journal_view.Event.Handler.create (fun _ -> ()) in let check ~today ~yesterday ~other = @@ -5294,6 +5422,7 @@ let () = [ ( "status list", List.map (fun scenario -> Alcotest.test_case scenario `Quick (fun () -> run_favorites_native_visibility ~status_scenario:scenario ())) ["expanded"; "none"; "now"; "waiting"; "later"; "select"; "clear"; "dismiss"; "failure"; "duplicate"]) + ; ("Favorites compact title",[Alcotest.test_case "page property values keep name without duplication" `Quick test_detail_page_property_value_presentation; Alcotest.test_case "root Capture only in Journals" `Quick test_root_capture_placement; Alcotest.test_case "LUI titles icons and no arrow" `Quick test_favorites_compact_shared_titles; Alcotest.test_case "page outline click and native Back" `Quick (fun () -> run_favorites_native_visibility ~check_favorite_targets:true ())]) ; ( "graph picker", [ Alcotest.test_case "refresh failure replaces old presence failure" `Quick (test_graph_picker_controls `Refresh_after_presence_failure); Alcotest.test_case "catalog refresh failure is visible" `Quick (test_graph_picker_controls `Refresh_failure); diff --git a/test/journal_adaptive_test.ml b/test/journal_adaptive_test.ml index 1c1a36f7..ab296676 100644 --- a/test/journal_adaptive_test.ml +++ b/test/journal_adaptive_test.ml @@ -164,6 +164,7 @@ let test_ios_capsules_mount_outside_toolbars () = let module V = Ui.View in List.iter (fun context -> + let has_capture = context = Journal_header.Context.journals in List.iter (fun (capture_enabled, capture_expanded, account_available, error_available) -> let batches = ref [] in @@ -241,7 +242,8 @@ let test_ios_capsules_mount_outside_toolbars () = in require (List.length surfaces - = (if Option.is_none capture_expanded then 2 else 0) + = (if Option.is_none capture_expanded + then (if has_capture then 2 else 1) else 0) + if account_available || error_available then 1 else 0) "Each buttons composite must supply exactly one glass surface"; let rec in_toolbar node = @@ -263,9 +265,11 @@ let test_ios_capsules_mount_outside_toolbars () = ops |> List.sort_uniq Int.compare in - match capture_expanded, nodes with - | Some _, [] -> () - | None, [ node ] -> + let visible = Option.is_none capture_expanded + && (label <> "Capture" || has_capture) in + match visible, nodes with + | false, [] -> () + | true, [ node ] -> require (not (in_toolbar node)) "%s must render outside toolbar glass" @@ -294,7 +298,7 @@ let test_ios_capsules_mount_outside_toolbars () = label | _ -> failwith - (label ^ " must occur exactly once while Capture is closed")) + (label ^ " has incorrect visibility for this header context")) [ "Journals"; "Favorites"; "Capture" ]; if error_available then ( diff --git a/test/journal_graph_runtime_locality_test.ml b/test/journal_graph_runtime_locality_test.ml index da17ad47..14b2f765 100644 --- a/test/journal_graph_runtime_locality_test.ml +++ b/test/journal_graph_runtime_locality_test.ml @@ -3077,11 +3077,10 @@ let copy_expect_failure state effects outcome = let test_copy_rejects_missing_failed_or_changed_graph () = let state, effects = copy_start block_uuid in - copy_expect_failure - state - effects - (Protocol.V2_block_outcome - (V2_missing_block { uuid = block_uuid; revision = "missing" })); + let missing_state, missing_effects = copy_reply state effects + (Protocol.V2_block_outcome (V2_missing_block {uuid=block_uuid;revision="missing"})) in + copy_expect_failure missing_state missing_effects + (Protocol.V2_page_outcome (V2_missing_page {uuid=block_uuid;revision="missing"})); let state, effects = copy_root state effects "Root" in copy_expect_failure state @@ -5385,10 +5384,213 @@ let test_dirty_abandon_keeps_reference_retryable () = ignore (reference_feed runtime reference |> fun output->expect_reference_page output target_uuid)) [false;true] ;; +(* Root dispatch and mutation confirmation are owned by Runtime; no public pure + reducer emits these protocol reads. Test only its public submit/receive boundary. *) +let page_detail_seed runtime children = + set_calendar runtime; + let out = Runtime.submit runtime (Journal_graph_request.Load_detail + {block_id=Graph.Uuid.to_string page_uuid; after=None; limit=128; request_generation=801L}) in + let root = only "page root lookup" out.requests in + let root = match root.command with + | V2_get_page {page=id;_} when Graph.Uuid.equal id page_uuid -> root + | V2_get_outline_block _ -> + Runtime.receive runtime (response root (Protocol.V2_block_outcome + (V2_missing_block {uuid=page_uuid; revision="absent-outline"}))) + |> fun out -> only "outline miss must resolve page" out.requests + | _ -> Alcotest.fail "unexpected root lookup" in + Alcotest.(check bool) "page root is resolved as a page" true + (root.command = V2_get_page {page=page_uuid; revision=None}); + let child_read = Runtime.receive runtime (response root (Protocol.V2_page_outcome + (V2_present_page {page; revision="page-1"}))) + |> fun out -> only "page root children" out.requests in + (match child_read.command with + | V2_get_children {parent;limit=128;_} when Graph.Uuid.equal parent page_uuid -> () + | _ -> Alcotest.fail "page children escaped selected page"); + let out = Runtime.receive runtime (response child_read (copy_children page_uuid children)) in + out.responses |> List.find_map (fun r -> match r.Runtime.payload with + | Detail_loaded {detail;_} -> Some detail | _ -> None) |> Option.get +;; + +let test_page_detail_root_and_append () = + List.iter (fun children -> + let runtime=Runtime.create () in + let detail=page_detail_seed runtime children in + Alcotest.(check string) "page root title" page.title (Journal_model.source detail.root); + Alcotest.(check string) "page root page identity" (Graph.Uuid.to_string page_uuid) + (Journal_model.page_id detail.root); + Alcotest.(check int) "page children" (List.length children) (List.length detail.children.blocks); + let command : Journal_graph_projection.create_child = + {mutation_id=Graph.Uuid.to_string unrelated_uuid; calendar_generation=1L; + block_id=Graph.Uuid.to_string unrelated_uuid; parent_block_id=Graph.Uuid.to_string page_uuid; + expected_parent_revision=Journal_model.revision detail.root; sibling_order="z"; + source="Append only here";task_state=No_status;creation_time=Journal_model.creation_time detail.root} in + let request=Runtime.submit runtime (Create_child command) |> fun out->only "page append" out.requests in + (match request.command with + | V2_insert_blocks {parent;preconditions;_} -> + Alcotest.(check bool) "exact page parent" true (Graph.Uuid.equal parent page_uuid); + Alcotest.(check bool) "page revision, never outline revision" true + (preconditions = {Protocol.blocks=[];pages=[page_uuid,"page-1"]; + scopes=[V2_children_scope page_uuid,"children-1"]}) + | _->Alcotest.fail "page append failed to issue scoped insert"); + let child_read=Runtime.receive runtime (response request (Protocol.V2_mutation_committed + {mutation_id=unrelated_uuid;status=V2_applied;generation="graph-1"; + before_projection_revision="projection-1";after_projection_revision="projection-2"})) + |> fun out->only "read inserted page child" out.requests in + let child=copy_child unrelated_uuid page_uuid command.source in + let parent_read=Runtime.receive runtime (response child_read (Protocol.V2_block_outcome + (V2_present_block {value=child.value;revision=child.revision}))) + |> fun out->only "confirm selected page parent" out.requests in + Alcotest.(check bool) "confirm page through page API" true + (parent_read.command=V2_get_page {page=page_uuid;revision=None}); + let done_=Runtime.receive runtime (response parent_read (Protocol.V2_page_outcome + (V2_present_page {page;revision="page-2"}))) in + let child,parent=done_.responses |> List.find_map (fun r -> match r.Runtime.payload with + | Child_created {child;parent;_}->Some(child,parent) | _->None) |> Option.get in + Alcotest.(check string) "completed child page" (Graph.Uuid.to_string page_uuid) (Journal_model.page_id child); + Alcotest.(check string) "completed parent is page" (Graph.Uuid.to_string page_uuid) (Journal_model.id parent)) + [[]; [copy_child block_uuid page_uuid "First child"]] +;; + +let test_recycled_page_root_is_removed () = + let runtime=Runtime.create () in + ignore (page_detail_seed runtime []); + let request=changed runtime (window ~pages:[page_uuid] ()) + |> fun out->only "page recycle refresh" (hydration_requests out) in + let out=Runtime.receive runtime (response request (Protocol.V2_page_outcome + (V2_present_page {page={page with recycled=true};revision="recycled"}))) in + Alcotest.(check bool) "recycled page root becomes unavailable" true + (List.exists (fun r->match r.Runtime.payload with + | Block_removed {block_id}->block_id=Graph.Uuid.to_string page_uuid | _->false) out.responses) +;; + +let test_page_root_block_change_uses_page_identity () = + let runtime=Runtime.create () in + ignore (page_detail_seed runtime []); + let out=changed runtime (window ~blocks:[page_uuid] ()) in + Alcotest.(check bool) "page root point change is not an outline lookup" false + (List.exists (fun (r:Protocol.request)->r.command=V2_get_outline_block {block=page_uuid;revision=None}) out.requests); + Alcotest.(check bool) "page root point change refreshes page" true + (List.exists (fun (r:Protocol.request)->r.command=V2_get_page {page=page_uuid;revision=None}) out.requests) +;; + +let test_page_root_refresh_and_resync () = + let runtime=Runtime.create () in + ignore (page_detail_seed runtime []); + let fresh=changed runtime (window ~pages:[page_uuid] ()) + |> fun out->only "one owned page refresh" (hydration_requests out) in + let result=Runtime.receive runtime (response fresh (Protocol.V2_page_outcome + (V2_present_page {page={page with title="Renamed page"};revision="page-2"}))) in + let detail=result.responses |> List.find_map (fun r-> match r.Runtime.payload with + | Children_reconciled detail -> Some detail | _->None) |> Option.get in + Alcotest.(check string) "renamed page root reconciles" "Renamed page" (Journal_model.source detail.root); + Alcotest.(check string) "page revision reconciles" "page-2" (Journal_model.revision detail.root); + let out=Runtime.reconcile_push runtime ~request_generation:12L + (Protocol.V2_resync_required_push {api_version=Protocol.api_version;generation="generation-2";reason="overflow"}) in + Alcotest.(check bool) "page root is never rehydrated as outline" false + (List.exists (fun (r:Protocol.request)->r.command=V2_get_outline_block {block=page_uuid;revision=None}) out.requests) +;; + +let test_page_property_refresh_discovers_references () = + List.iter (fun page_root -> + let runtime=Runtime.create () in + if page_root then ignore (page_detail_seed runtime []) + else seed_children_interest runtime; + let property : Graph.property_summary = + {ident="user.property/page";uuid=block_uuid;title="Linked page" + ;schema={property_type=Page;cardinality=Many;hidden=false;public=false} + ;values=[Page_value target_uuid;Page_value target_uuid];values_truncated=false} in + let hidden={property with ident="user.property/hidden";uuid=unrelated_uuid; + schema={property.schema with hidden=true};values=[Page_value unrelated_uuid]} in + let refresh () = changed runtime (window ~pages:[page_uuid] ()) + |> fun out -> hydration_requests out + |> List.filter (fun (request:Protocol.request) -> + request.command = V2_get_page {page=page_uuid;revision=None}) + |> only "one owning page refresh" in + let complete request = Runtime.receive runtime (response request (Protocol.V2_page_outcome + (V2_present_page {page={page with properties=[property;hidden]};revision="page-2"}))) in + let out=complete (refresh ()) in + if page_root then ( + let reference=expect_reference_page out target_uuid in + Alcotest.(check int) "duplicate values share one read; hidden properties have no demand" + 1 (List.length out.requests); + let resolved=Runtime.receive runtime (reference_page_response reference Ordinary_page "Linked title") in + Alcotest.(check (option string)) "new page property reference resolves" + (Some "Linked title") (List.assoc_opt target_id (reference_updates resolved) |> Option.join); + Alcotest.(check int) "unchanged property reuses shared cache" 0 + (List.length (complete (refresh ())).requests)) + else Alcotest.(check int) "outline detail does not demand undisplayed page properties" + 0 (List.length out.requests)) [true;false] +;; + +let favorites_reference_source runtime title = + let request : Journal_graph_request.favorites_request = + {graph_generation=1;request_generation=802L;limit=50;cursor=None} in + let read=Runtime.submit runtime (Load_favorites request) |> fun out->only "favorite list" out.requests in + Runtime.receive runtime (response read (Protocol.V2_favorites_outcome + {favorites_page=None;generation="g";projection_revision="p";next_cursor=None; + items=[{membership_uuid;membership_order="a";membership_revision="m"; + target=V2_favorite_block {uuid=block_uuid;title;task_status=None;revision="b"}}]})) +;; + +let test_favorites_share_reference_cache () = + let runtime=Runtime.create () in + set_calendar runtime; + let out=favorites_reference_source runtime (reference ^ " " ^ reference) in + let page_read=out.requests |> List.filter (fun (r:Protocol.request)-> + r.command=V2_get_page {page=target_uuid;revision=None}) |> only "duplicate favorite reference uses one page read" in + let out=Runtime.receive runtime (reference_page_response page_read + (Graph.Journal_page {journal_day=20260901}) "Tuesday, September 1st, 2026") in + Alcotest.(check (option string)) "favorite journal page keeps actual date title" + (Some "Tuesday, September 1st, 2026") + (List.assoc_opt target_id (reference_updates out) |> Option.join); + let duplicate=favorites_reference_source runtime reference in + Alcotest.(check int) "favorite reload shares positive reference cache" 0 + (List.length (List.filter (fun (r:Protocol.request)->r.command=V2_get_page {page=target_uuid;revision=None}) duplicate.requests)); + let late_runtime=Runtime.create () in + let old=favorites_reference_source late_runtime reference in + let old_read=old.requests |> List.find (fun (r:Protocol.request)->r.command=V2_get_page {page=target_uuid;revision=None}) in + Runtime.reset late_runtime; + let late=Runtime.receive late_runtime (reference_page_response old_read Ordinary_page "Old graph") in + Alcotest.(check int) "late Favorites reference is fenced by graph reset" 0 (List.length late.responses) +;; + +let test_favorites_outline_and_missing_reference () = + List.iter (fun missing -> + let runtime=Runtime.create () in + let out=favorites_reference_source runtime (reference ^ reference) in + let page_read=out.requests |> List.find (fun (r:Protocol.request)-> + r.command=V2_get_page {page=target_uuid;revision=None}) in + let fallback=missing_reference_page runtime page_read target_uuid in + let outline=expect_reference_outline fallback target_uuid in + let out=Runtime.receive runtime (if missing then response outline (Protocol.V2_block_outcome + (V2_missing_block {uuid=target_uuid;revision="absent"})) else target_response outline "Outline title") in + Alcotest.(check (option string)) "Favorites shares outline and missing source results" + (if missing then None else Some "Outline title") + (List.assoc_opt target_id (reference_updates out) |> Option.join)) [false;true] +;; + +let test_copy_page_root () = + List.iter (fun populated -> + let state,effects=copy_start page_uuid in + let state,effects=copy_reply state effects (Protocol.V2_block_outcome + (V2_missing_block {uuid=page_uuid;revision="no-outline"})) in + Alcotest.(check bool) "copy resolves exact page" true + (snd(copy_read effects)=V2_get_page {page=page_uuid;revision=None}); + let state,effects=copy_reply state effects (Protocol.V2_page_outcome + (V2_present_page {page;revision="page-1"})) in + copy_parent effects page_uuid; + let state,effects=copy_reply state effects (copy_children page_uuid + (if populated then [copy_child block_uuid page_uuid "Child"] else [])) in + let state,effects=if populated then copy_reply state effects (copy_children block_uuid []) else state,effects in + let _,effects=copy_reply state effects (graph_info_outcome ~journal_title_format:None) in + Alcotest.(check bool) "page title and exact descendants copied" true + (effects=[Copy.Copied (page.title ^ if populated then "\n - Child" else "")])) [false;true] +;; + let () = Alcotest.run "journal graph runtime locality" - [ ( "review reference ownership", [ + [ ("Favorites page detail", [Alcotest.test_case "page property refresh discovers references" `Quick test_page_property_refresh_discovers_references; Alcotest.test_case "page root and scoped append" `Quick test_page_detail_root_and_append; Alcotest.test_case "copy page root and empty page" `Quick test_copy_page_root; Alcotest.test_case "recycled page is unavailable" `Quick test_recycled_page_root_is_removed; Alcotest.test_case "page point-change identity" `Quick test_page_root_block_change_uses_page_identity; Alcotest.test_case "page root refresh and resync" `Quick test_page_root_refresh_and_resync; Alcotest.test_case "Favorites share page-first reference cache" `Quick test_favorites_share_reference_cache; Alcotest.test_case "Favorites outline and absent references" `Quick test_favorites_outline_and_missing_reference]); ( "review reference ownership", [ Alcotest.test_case "typed metadata retains newer fence" `Quick test_typed_metadata_preserves_newer_reference_fence; Alcotest.test_case "dirty abandon remains retryable" `Quick test_dirty_abandon_keeps_reference_retryable; Alcotest.test_case "failed lookup retains epoch fence" `Quick test_reference_failed_read_retains_epoch_fence; diff --git a/test/journal_routes_test.ml b/test/journal_routes_test.ml index 8ccb1aae..4feeb365 100644 --- a/test/journal_routes_test.ml +++ b/test/journal_routes_test.ml @@ -1113,7 +1113,10 @@ let test_favorite_target_origin_and_undo () = let untouched, request = Journal_routes.open_favorite origin ~request_generation:91L page_item in - require (untouched = origin && request = None) "page favorite gained block navigation"; + require (Journal_routes.route untouched = Detail_loading && Option.is_some request) + "page favorite did not enter shared detail"; + require (Journal_routes.destination (Journal_routes.back untouched) = Favorites) + "page favorite Back lost origin"; let selected, requests = F.step (F.create ~graph_generation:1) (Select true) in let request = match requests with @@ -3858,8 +3861,34 @@ let test_initial_staging_refresh_and_delete () = (String.concat ", " (List.rev !failures)) ;; +let test_page_detail_append_matches_exact_page () = + let page_id = "75000000-0000-4000-8000-000000000002" in + let make id page_id source = + Journal_model.create_on_page ~id ~page_id ~journal_day:None ~parent_id:None + ~sibling_order:"a" ~source ~task_state:No_status ~child_count:0 + ~creation_time:(creation_time 540) ~revision:"page-1" + ~last_mutation_id:"00000000-0000-0000-0000-000000000000" |> Result.get_ok in + let root = make page_id page_id "Empty page" in + let child_id = "75000000-0000-4000-8000-000000000004" in + let owner = Journal_detail.create ~session_number:80L (detail ~root ()) + |> fun t -> Journal_detail.update_child_source t "New page child" in + let saving, request = Journal_detail.admit_child owner ~mutation_id:"mutation" + ~calendar_generation:1L ~block_id:child_id ~sibling_order:"z" + ~creation_time:(creation_time 550) in + require (Option.is_some request) "page cannot admit child"; + let child = make child_id page_id "New page child" in + let wrong = make child_id "75000000-0000-4000-8000-000000000099" "Wrong page" in + require (Journal_detail.apply_child_created saving ~child:wrong ~parent:root == saving) + "append completion accepted another page"; + let completed = Journal_detail.apply_child_created saving ~child ~parent:root in + require (Journal_detail.mode completed = Reading && Journal_detail.child_capture completed = None) + "page append did not complete its exact owner"; + require (List.map Journal_model.id (Journal_detail.children completed) = [child_id]) + "page append went to another root" +;; + let tests = - [ "Initial staging refresh and delete review regressions", test_initial_staging_refresh_and_delete ] @ + [ "Page append exact page ownership", test_page_detail_append_matches_exact_page; "Initial staging refresh and delete review regressions", test_initial_staging_refresh_and_delete ] @ [ "Detail bounded initial nested body staging", test_detail_initial_nested_body_staging ] @ [ "Detail initial loading delay and ownership", test_detail_initial_loading_delay_and_ownership ] @ [ "Unicode blank Capture admission", test_unicode_blank_capture_admission