From 4b52ac6156c51415227828f414d6764909fd9e18 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 2 Jul 2026 21:52:20 +0200 Subject: [PATCH] fix: better progress tracking for full site render --- lib/bds/desktop/shell_commands.ex | 99 ++++++-- lib/bds/generation.ex | 310 +++++++++++++++++++---- lib/bds/generation/outputs.ex | 7 +- specs/generation.allium | 21 +- test/bds/desktop/shell_commands_test.exs | 61 +++++ test/bds/generation_test.exs | 119 +++++++++ 6 files changed, 544 insertions(+), 73 deletions(-) diff --git a/lib/bds/desktop/shell_commands.ex b/lib/bds/desktop/shell_commands.ex index 19716c5..95b6dd2 100644 --- a/lib/bds/desktop/shell_commands.ex +++ b/lib/bds/desktop/shell_commands.ex @@ -239,18 +239,33 @@ defmodule BDS.Desktop.ShellCommands do end defp dispatch("generate_sitemap", project, _params) do - queue_task(project, "generate_sitemap", "Generate Site", "Generation", fn report -> - {:ok, generation} = - Generation.generate_site(project.id, @site_sections, on_progress: report) + group_id = task_group_id("generate_sitemap") + attrs = %{group_id: group_id, group_name: "Render Site"} - report.(1.0, "Generated site output") + {:ok, first_task} = + Tasks.submit_task( + section_task_name(:core), + fn report -> + {:ok, _result} = Generation.render_site_section(project.id, :core, on_progress: report) + report.(1.0, section_complete_message(:core)) + :ok + end, + attrs + ) - %{ - project_id: project.id, - sections: generation.sections, - generated_count: length(generation.generated_files) - } - end) + Task.start(fn -> run_render_site_sequence(project, group_id, attrs) end) + + {:ok, + %{ + kind: "task_queued", + action: "generate_sitemap", + title: "Render Site", + message: "Render Site tasks queued", + project_id: project.id, + task_id: first_task.id, + task_group_id: group_id, + panel_tab: "tasks" + }} end defp dispatch("validate_site", project, _params) do @@ -475,6 +490,44 @@ defmodule BDS.Desktop.ShellCommands do end end + defp run_render_site_sequence(project, group_id, attrs) do + remaining_sections = [:single, :category, :tag, :date] + + Enum.each(remaining_sections, fn section -> + {:ok, _task} = + Tasks.submit_task( + section_task_name(section), + fn report -> + {:ok, _result} = + Generation.render_site_section(project.id, section, on_progress: report) + + report.(1.0, section_complete_message(section)) + :ok + end, + attrs + ) + end) + + section_names = Enum.map([:core | remaining_sections], §ion_task_name/1) + + with :ok <- wait_for_group_phase(group_id, section_names, @rebuild_phase_timeout) do + {:ok, _task} = + Tasks.submit_task( + "Build Search Index", + fn report -> + {:ok, _result} = Generation.build_site_search_index(project.id, on_progress: report) + report.(1.0, "Build Search Index complete") + :ok + end, + attrs + ) + + wait_for_group_phase(group_id, ["Build Search Index"], @rebuild_phase_timeout) + end + + :ok + end + defp run_apply_validation_sequence(project, validation_report, group_id, attrs, prepare_task_id) do with :ok <- wait_for_group_phase(group_id, ["Prepare Validation Apply"], @rebuild_phase_timeout), @@ -499,10 +552,10 @@ defmodule BDS.Desktop.ShellCommands do defp run_apply_validation_render_tasks(project, validation_report, group_id, attrs, preparation) do sections = Map.get(preparation, :sections_to_render, []) - task_names = Enum.map(sections, &apply_validation_section_task_name/1) + task_names = Enum.map(sections, §ion_task_name/1) Enum.each(sections, fn section -> - task_name = apply_validation_section_task_name(section) + task_name = section_task_name(section) {:ok, _task} = Tasks.submit_task( @@ -513,7 +566,7 @@ defmodule BDS.Desktop.ShellCommands do on_progress: report ) - report.(1.0, apply_validation_section_complete_message(section)) + report.(1.0, section_complete_message(section)) :ok end, attrs @@ -574,20 +627,20 @@ defmodule BDS.Desktop.ShellCommands do Map.get(preparation, :deleted_url_count, 0) > 0 end - defp apply_validation_section_task_name(:core), do: "Render Site Core" - defp apply_validation_section_task_name(:single), do: "Render Single Posts" - defp apply_validation_section_task_name(:category), do: "Render Category Archives" - defp apply_validation_section_task_name(:tag), do: "Render Tag Archives" - defp apply_validation_section_task_name(:date), do: "Render Date Archives" + defp section_task_name(:core), do: "Render Site Core" + defp section_task_name(:single), do: "Render Single Posts" + defp section_task_name(:category), do: "Render Category Archives" + defp section_task_name(:tag), do: "Render Tag Archives" + defp section_task_name(:date), do: "Render Date Archives" - defp apply_validation_section_complete_message(:core), do: "Render Site Core complete" - defp apply_validation_section_complete_message(:single), do: "Render Single Posts complete" + defp section_complete_message(:core), do: "Render Site Core complete" + defp section_complete_message(:single), do: "Render Single Posts complete" - defp apply_validation_section_complete_message(:category), + defp section_complete_message(:category), do: "Render Category Archives complete" - defp apply_validation_section_complete_message(:tag), do: "Render Tag Archives complete" - defp apply_validation_section_complete_message(:date), do: "Render Date Archives complete" + defp section_complete_message(:tag), do: "Render Tag Archives complete" + defp section_complete_message(:date), do: "Render Date Archives complete" defp apply_validation_refresh_task_name(:calendar), do: "Regenerate Calendar" diff --git a/lib/bds/generation.ex b/lib/bds/generation.ex index ccdbb30..760294c 100644 --- a/lib/bds/generation.ex +++ b/lib/bds/generation.ex @@ -79,22 +79,201 @@ defmodule BDS.Generation do with {:ok, plan} <- plan_generation(project_id, sections) do outputs = build_outputs(plan) on_progress = callback(opts) - total_outputs = length(outputs) - :ok = report_generation_started(on_progress, total_outputs, "generated files") - - outputs - |> Enum.with_index(1) - |> Enum.each(fn {{relative_path, content}, index} -> - {:ok, _write} = write_generated_file(project_id, relative_path, content) - :ok = report_generation_progress(on_progress, index, total_outputs, "generated files") - end) + _generated_url_count = + stream_write_outputs(project_id, outputs, on_progress, + verb: "Generated", + gerund: "Generating", + refresh_route_timestamps: false + ) {:ok, generated_files} = list_generated_files(project_id) {:ok, %{sections: plan.sections, generated_files: generated_files}} end end + @doc """ + Render one section of the full site, streaming one progress message per URL + (exactly like validation apply, only rendering ALL URLs of the section rather + than just the affected ones). The `:core` section also writes the complete + sitemap, feeds, calendar and preview assets. The Pagefind search index is + built separately by `build_site_search_index/2`. + """ + @spec render_site_section(String.t(), section(), generation_opts()) :: + {:ok, map()} | {:error, term()} + def render_site_section(project_id, section, opts \\ []) + + def render_site_section(project_id, section, opts) + when is_binary(project_id) and section in @core_sections and is_list(opts) do + on_progress = callback(opts) + + with {:ok, plan} <- plan_generation(project_id, [section]) do + render_data = validation_render_data(plan) + route_total = section_route_total(plan, render_data) + + rendered_url_count = + stream_render(project_id, on_progress, route_total, + [verb: "Generated", gerund: "Generating", refresh_route_timestamps: false], + fn on_output -> full_section_outputs(plan, render_data, section, on_output) end + ) + + {:ok, %{section: section, rendered_url_count: rendered_url_count}} + end + end + + @doc """ + Build the Pagefind search index for the whole site from the HTML already on + disk (matching the old app's separate `Build Search Index` step that runs + after every section has been rendered). + """ + @spec build_site_search_index(String.t(), generation_opts()) :: + {:ok, map()} | {:error, term()} + def build_site_search_index(project_id, opts \\ []) + + def build_site_search_index(project_id, opts) + when is_binary(project_id) and is_list(opts) do + on_progress = callback(opts) + :ok = report_validation_progress(on_progress, 0.0, "Building search index...") + + with {:ok, plan} <- plan_generation(project_id, @core_sections) do + project = Projects.get_project!(project_id) + html_outputs = read_disk_html_outputs(output_path(project, "")) + pagefind_outputs = BDS.Generation.Pagefind.build_outputs(plan, html_outputs) + total = max(length(pagefind_outputs), 1) + + pagefind_outputs + |> Enum.with_index(1) + |> Enum.each(fn {{relative_path, content}, index} -> + _ = write_generated_file(project_id, relative_path, content) + :ok = report_validation_progress(on_progress, index / total, "Built #{relative_path}") + end) + + {:ok, %{written_count: length(pagefind_outputs)}} + end + end + + defp full_section_outputs(plan, render_data, :core, on_output) do + data = render_data.data + + build_core_outputs( + plan, + data.published_list_posts, + render_data.localized_list_posts_by_language, + on_output + ) ++ + build_page_outputs( + plan.project_id, + plan.language, + data.published_posts, + render_data.translations_by_post_language, + render_data.localized_posts_by_language, + on_output + ) ++ + report_outputs(core_sitemap_outputs(plan, data) ++ PreviewAssets.generated_outputs(), on_output) + end + + defp full_section_outputs(plan, render_data, :single, on_output) do + data = render_data.data + + build_single_outputs( + plan.project_id, + plan.language, + data.published_posts, + render_data.translations_by_post_language, + render_data.localized_posts_by_language, + on_output + ) + end + + defp full_section_outputs(plan, render_data, :category, on_output) do + full_localized_archive_outputs(plan, render_data, fn plan_arg, index, languages -> + build_category_outputs(plan_arg, index.posts_by_category, languages, on_output) + end) + end + + defp full_section_outputs(plan, render_data, :tag, on_output) do + full_localized_archive_outputs(plan, render_data, fn plan_arg, index, languages -> + build_tag_outputs(plan_arg, index.posts_by_tag, languages, on_output) + end) + end + + defp full_section_outputs(plan, render_data, :date, on_output) do + full_localized_archive_outputs(plan, render_data, fn plan_arg, index, languages -> + build_date_outputs(plan_arg, index, languages, on_output) + end) + end + + # Total number of route (HTML) pages the section will render, computed from the + # post index without rendering any content, so streaming progress can report an + # accurate `n/total` from the very first page. + defp section_route_total(plan, render_data) do + data = render_data.data + + main = + length( + build_validation_route_paths( + plan, + data.published_posts, + data.published_list_posts, + data.post_index, + nil + ) + ) + + additional = + additional_languages(plan) + |> Enum.map(fn language -> + length( + build_validation_route_paths( + plan, + Map.get(render_data.localized_posts_by_language, language, []), + Map.get(render_data.localized_list_posts_by_language, language, []), + Map.get(render_data.localized_post_indexes, language, empty_generation_post_index()), + language + ) + ) + end) + |> Enum.sum() + + main + additional + end + + defp report_outputs(outputs, nil), do: outputs + + defp report_outputs(outputs, on_output) do + Enum.each(outputs, on_output) + outputs + end + + defp full_localized_archive_outputs(plan, render_data, builder) do + main_outputs = builder.(plan, render_data.data.post_index, [plan.language]) + + language_outputs = + Enum.flat_map(additional_languages(plan), fn language -> + index = Map.get(render_data.localized_post_indexes, language, empty_generation_post_index()) + builder.(plan, index, [language]) + end) + + main_outputs ++ language_outputs + end + + defp core_sitemap_outputs(plan, data) do + published_route_posts = + suppress_subtree_translation_variants(data.published_route_posts, additional_languages(plan)) + + {_sitemap_content, sitemap_to_write, _expected_paths, _timestamp_checks} = + build_validation_sitemap_artifacts(plan, data, published_route_posts, %{}, nil) + + [{"sitemap.xml", sitemap_to_write}] + end + + defp read_disk_html_outputs(html_root) do + Path.join(html_root, "**/*.html") + |> Path.wildcard() + |> Enum.map(fn full_path -> {Path.relative_to(full_path, html_root), File.read!(full_path)} end) + |> Enum.sort_by(&elem(&1, 0)) + end + @spec validate_site(String.t(), [section()], generation_opts()) :: {:ok, validation_report()} | {:error, term()} def validate_site(project_id, sections \\ @core_sections, opts \\ []) @@ -706,45 +885,86 @@ defmodule BDS.Generation do end) end end - - # Write the targeted outputs, streaming one progress message per rewritten URL - # (exactly like the old app's `Generated /url` reporting) so the task shows the - # actual URLs and advances by the number of URLs rewritten. + # Write the targeted validation outputs, streaming one progress message per + # rewritten URL so the task shows the actual URLs and advances by the number + # of URLs rewritten. defp write_validation_outputs(project_id, outputs, on_progress) do - route_total = Enum.count(outputs, fn {relative_path, _content} -> route_html_path?(relative_path) end) - - :ok = report_validation_progress(on_progress, 0.0, validation_rewrite_started_message(route_total)) - - {rendered, _total} = - Enum.reduce(outputs, {0, route_total}, fn {relative_path, content}, {rendered, total} -> - _ = - write_generated_file(project_id, relative_path, content, - refresh_timestamp_on_unchanged: route_html_path?(relative_path) - ) - - if route_html_path?(relative_path) do - rewritten = rendered + 1 - progress = if total > 0, do: rewritten / total, else: 1.0 - - :ok = - report_validation_progress( - on_progress, - progress, - "Rewrote #{relative_path_to_url_path(relative_path)} (#{rewritten}/#{total})" - ) - - {rewritten, total} - else - {rendered, total} - end - end) - - rendered + stream_write_outputs(project_id, outputs, on_progress, + verb: "Rewrote", + gerund: "Rewriting", + refresh_route_timestamps: true + ) end - defp validation_rewrite_started_message(0), do: "No URLs to rewrite" - defp validation_rewrite_started_message(1), do: "Rewriting 1 URL..." - defp validation_rewrite_started_message(total), do: "Rewriting #{total} URLs..." + # Shared per-URL streaming writer used by both full site generation and + # validation apply. Emits one ` /url (n/total)` progress message per + # rendered route page (matching the old app's `Generated /url` reporting) and + # advances the task by the number of URLs written. Ancillary files + # (sitemap.xml, feeds, calendar, pagefind, assets) are written without a URL + # message. Returns the number of route pages written. + # + # `stream_write_outputs` drives a pre-built list of outputs (used by validation + # apply, whose targeted render is cheap); `stream_render` drives the render + # itself via the builders' `on_output` callback so a full-site render reports + # progress as each page is produced rather than only after every page is built. + defp stream_write_outputs(project_id, outputs, on_progress, opts) do + route_total = + Enum.count(outputs, fn {relative_path, _content} -> route_html_path?(relative_path) end) + + ctx = stream_context(project_id, on_progress, route_total, opts) + Enum.each(outputs, fn output -> emit_output(output, ctx) end) + :counters.get(ctx.counter, 1) + end + + defp stream_render(project_id, on_progress, route_total, opts, build_fn) do + ctx = stream_context(project_id, on_progress, route_total, opts) + _ = build_fn.(fn output -> emit_output(output, ctx) end) + :counters.get(ctx.counter, 1) + end + + defp stream_context(project_id, on_progress, route_total, opts) do + gerund = Keyword.fetch!(opts, :gerund) + + :ok = + report_validation_progress(on_progress, 0.0, url_progress_started_message(gerund, route_total)) + + %{ + project_id: project_id, + on_progress: on_progress, + route_total: route_total, + verb: Keyword.fetch!(opts, :verb), + refresh_routes?: Keyword.get(opts, :refresh_route_timestamps, false), + counter: :counters.new(1, []) + } + end + + defp emit_output({relative_path, content} = output, ctx) do + route? = route_html_path?(relative_path) + + _ = + write_generated_file(ctx.project_id, relative_path, content, + refresh_timestamp_on_unchanged: ctx.refresh_routes? and route? + ) + + if route? do + :counters.add(ctx.counter, 1, 1) + count = :counters.get(ctx.counter, 1) + progress = if ctx.route_total > 0, do: min(count / ctx.route_total, 1.0), else: 1.0 + + :ok = + report_validation_progress( + ctx.on_progress, + progress, + "#{ctx.verb} #{relative_path_to_url_path(relative_path)} (#{count}/#{ctx.route_total})" + ) + end + + output + end + + defp url_progress_started_message(gerund, 0), do: "No URLs to #{String.downcase(gerund)}" + defp url_progress_started_message(gerund, 1), do: "#{gerund} 1 URL..." + defp url_progress_started_message(gerund, total), do: "#{gerund} #{total} URLs..." defp validation_render_data(plan) do data = generation_data(plan) diff --git a/lib/bds/generation/outputs.ex b/lib/bds/generation/outputs.ex index dcdf963..25d1e8a 100644 --- a/lib/bds/generation/outputs.ex +++ b/lib/bds/generation/outputs.ex @@ -386,7 +386,12 @@ defmodule BDS.Generation.Outputs do localized_posts = build_list_posts(plan.base_url, localized_source_posts, localized_prefix) - (build_root_outputs(plan, localized_language, localized_posts, on_output) ++ + # `build_root_outputs` is called without `on_output` here because the + # combined list (roots + static files) is reported exactly once by the + # `Enum.map(&report_output/2)` below. Passing `on_output` to both would + # report (and, for streaming callers, write and count) each localized + # root page twice. + (build_root_outputs(plan, localized_language, localized_posts) ++ [ {Path.join(localized_language, "404.html"), render_not_found_output(plan, localized_language)}, diff --git a/specs/generation.allium b/specs/generation.allium index 5cb86c6..6dd1335 100644 --- a/specs/generation.allium +++ b/specs/generation.allium @@ -29,10 +29,23 @@ surface GenerationRuntimeSurface { @guarantee ProgressReporting -- Generation, reindex, and site-validation run as background tasks and - -- emit count-based progress (current/total + label, e.g. "Processing - -- N of M posts") via the task progress channel (see task.allium - -- ReportProgress / ProgressThrottled). Multi-phase work (validation) - -- maps each phase onto a fixed fraction of the 0.0..1.0 bar. + -- emit progress via the task progress channel (see task.allium + -- ReportProgress / ProgressThrottled). + -- + -- Rendering (both full generation and validation apply) streams one + -- message per written page — " /url (n/total)" where verb is + -- "Generated" for a full render and "Rewrote" for a validation apply — + -- and advances the bar by the number of URLs written. + -- + -- Full generation and validation apply share the same structuring: a + -- task group containing one task per section (Render Site Core, Render + -- Single Posts, Render Category Archives, Render Tag Archives, Render + -- Date Archives) followed by a final Build Search Index task. The only + -- difference is that full generation renders every URL while apply + -- renders only the affected URLs. + -- + -- Multi-phase work (validation) maps each phase onto a fixed fraction + -- of the 0.0..1.0 bar. } value GenerationSection { diff --git a/test/bds/desktop/shell_commands_test.exs b/test/bds/desktop/shell_commands_test.exs index 12f0411..73cc567 100644 --- a/test/bds/desktop/shell_commands_test.exs +++ b/test/bds/desktop/shell_commands_test.exs @@ -438,6 +438,67 @@ defmodule BDS.Desktop.ShellCommandsTest do assert validation_task.result.payload.updated_post_url_paths == [] end + test "generate_sitemap renders the site as per-section tasks under a Render Site group", %{ + project: project, + temp_dir: temp_dir + } do + assert {:ok, _metadata} = + Metadata.update_project_metadata(project.id, %{ + public_url: "https://example.com/blog", + main_language: "en", + blog_languages: ["en"] + }) + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Sitemap Post", + content: "Sitemap body", + language: "en", + categories: ["notes"], + tags: ["elixir"] + }) + + assert {:ok, published} = Posts.publish_post(post.id) + + assert {:ok, result} = ShellCommands.execute("generate_sitemap") + + assert result.kind == "task_queued" + assert result.action == "generate_sitemap" + assert result.title == "Render Site" + assert is_binary(result.task_group_id) + + tasks = + wait_for_tasks_by_name( + [ + "Render Site Core", + "Render Single Posts", + "Render Category Archives", + "Render Tag Archives", + "Render Date Archives", + "Build Search Index" + ], + &(&1.status == :completed), + 5_000 + ) + + assert Enum.all?(tasks, &(&1.group_id == result.task_group_id)) + assert Enum.all?(tasks, &(&1.group_name == "Render Site")) + + # The full site was rendered: the post page, sitemap and search index exist. + post_path = BDS.Generation.post_output_path(published) + assert File.exists?(Path.join([temp_dir, "html", post_path])) + assert File.exists?(Path.join([temp_dir, "html", "sitemap.xml"])) + assert File.exists?(Path.join([temp_dir, "html", "pagefind", "index.json"])) + + # The generated site is internally consistent. + assert {:ok, report} = + BDS.Generation.validate_site(project.id, [:core, :single, :category, :tag, :date]) + + assert report.missing_url_paths == [] + assert report.extra_url_paths == [] + end + test "metadata_diff queues a tracked maintenance task and returns the report as an editor payload" do assert {:ok, result} = ShellCommands.execute("metadata_diff") diff --git a/test/bds/generation_test.exs b/test/bds/generation_test.exs index c1b86b6..a61091e 100644 --- a/test/bds/generation_test.exs +++ b/test/bds/generation_test.exs @@ -1099,6 +1099,125 @@ defmodule BDS.GenerationTest do assert fractions != [] end + test "render_site_section per section plus build_site_search_index produce a complete, searchable site", + %{project: project, temp_dir: temp_dir} do + assert {:ok, _metadata} = + Metadata.update_project_metadata(project.id, %{ + public_url: "https://example.com/blog", + main_language: "en", + blog_languages: ["en"] + }) + + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Searchable Alpha", + content: "Alpha searchable body", + language: "en", + categories: ["notes"], + tags: ["elixir"] + }) + + assert {:ok, published} = Posts.publish_post(post.id) + + assert {:ok, page} = + Posts.create_post(%{ + project_id: project.id, + title: "About", + content: "About body", + language: "en", + categories: ["page"] + }) + + assert {:ok, _published_page} = Posts.publish_post(page.id) + + for section <- [:core, :single, :category, :tag, :date] do + assert {:ok, _result} = BDS.Generation.render_site_section(project.id, section) + end + + assert {:ok, _search} = BDS.Generation.build_site_search_index(project.id) + + # Rendering section-by-section yields an internally consistent site: the + # sitemap written by the core task matches every rendered HTML page. + assert {:ok, report} = BDS.Generation.validate_site(project.id) + assert report.missing_url_paths == [] + assert report.extra_url_paths == [] + assert report.updated_post_url_paths == [] + + # Ancillary artifacts are all present. + assert File.exists?(Path.join([temp_dir, "html", "sitemap.xml"])) + assert File.exists?(Path.join([temp_dir, "html", "feed.xml"])) + assert File.exists?(Path.join([temp_dir, "html", "calendar.json"])) + assert File.exists?(Path.join([temp_dir, "html", "pagefind", "index.json"])) + + # The disk-based search index picked up the rendered single post. + index = Jason.decode!(File.read!(Path.join([temp_dir, "html", "pagefind", "index.json"]))) + assert Enum.any?(index["pages"], fn page -> String.contains?(page["url"], published.slug) end) + end + + test "render_site_section streams one progress message per generated URL with a running count", + %{project: project} do + assert {:ok, _metadata} = + Metadata.update_project_metadata(project.id, %{ + public_url: "https://example.com/blog", + main_language: "en", + blog_languages: ["en", "de"] + }) + + # Several posts across categories/tags so the single and archive sections + # render multiple pages (proving progress streams incrementally, not 0 -> 100). + for index <- 1..4 do + assert {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Streamed Post #{index}", + content: "Streamed body #{index}", + language: "en", + categories: ["notes"], + tags: ["elixir"] + }) + + assert {:ok, _published} = Posts.publish_post(post.id) + end + + for section <- [:core, :single, :category, :tag, :date] do + test_pid = self() + + assert {:ok, result} = + BDS.Generation.render_site_section(project.id, section, + on_progress: fn progress, message -> + send(test_pid, {:progress, progress, message}) + end + ) + + messages = collect_progress_messages([]) + + url_messages = + Enum.filter(messages, fn {_progress, message} -> message =~ ~r/\(\d+\/\d+\)/ end) + + # Every rendered URL produced a "(n/total)" progress message. + assert length(url_messages) == result.rendered_url_count + + # The precomputed total is accurate: the final message reaches n == total + # and progress reaches exactly 1.0 (never over- or under-shoots). + {last_progress, last_message} = List.last(url_messages) + [_, n, total] = Regex.run(~r/\((\d+)\/(\d+)\)/, last_message) + assert n == total, "section #{section}: last message #{inspect(last_message)}" + assert String.to_integer(n) == result.rendered_url_count + assert last_progress == 1.0 + + # Progress advanced incrementally rather than jumping straight to the end. + if result.rendered_url_count > 1 do + fractions = + url_messages + |> Enum.map(fn {progress, _message} -> progress end) + |> Enum.filter(fn progress -> progress > 0.0 and progress < 1.0 end) + + assert fractions != [] + end + end + end + test "prepare_validation_apply classifies page and day archive routes without section fallback", %{project: project} do assert {:ok, _metadata} =