337 lines
9.9 KiB
Elixir
337 lines
9.9 KiB
Elixir
defmodule BDS.Rendering.RenderContextTest do
|
|
use ExUnit.Case, async: false
|
|
|
|
alias BDS.Generation
|
|
alias BDS.Rendering
|
|
alias BDS.Rendering.RenderContext
|
|
|
|
setup do
|
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
|
|
|
|
temp_dir =
|
|
Path.join(System.tmp_dir!(), "bds-render-context-#{System.unique_integer([:positive])}")
|
|
|
|
File.mkdir_p!(temp_dir)
|
|
on_exit(fn -> File.rm_rf(temp_dir) end)
|
|
|
|
{:ok, project} = BDS.Projects.create_project(%{name: "RenderContext", data_path: temp_dir})
|
|
|
|
{:ok, _metadata} =
|
|
BDS.Metadata.update_project_metadata(project.id, %{
|
|
main_language: "en",
|
|
blog_languages: ["en", "de"]
|
|
})
|
|
|
|
%{project: project, temp_dir: temp_dir}
|
|
end
|
|
|
|
defp create_published_post(project, attrs) do
|
|
{:ok, post} =
|
|
BDS.Posts.create_post(
|
|
Map.merge(
|
|
%{project_id: project.id, language: "en", content: "Body"},
|
|
attrs
|
|
)
|
|
)
|
|
|
|
{:ok, published} = BDS.Posts.publish_post(post.id)
|
|
published
|
|
end
|
|
|
|
test "build/1 preloads canonical paths, menu, tag colors, and link maps", %{project: project} do
|
|
target = create_published_post(project, %{title: "Ctx Target", tags: ["alpha"]})
|
|
|
|
_source =
|
|
create_published_post(project, %{
|
|
title: "Ctx Source",
|
|
content: "See [Target](/#{Enum.join(post_date_parts(target), "/")}/#{target.slug}/)"
|
|
})
|
|
|
|
ctx = RenderContext.build(project.id)
|
|
|
|
assert ctx.project.id == project.id
|
|
assert ctx.main_language == "en"
|
|
assert Map.has_key?(ctx.canonical_post_paths, target.slug)
|
|
assert is_map(ctx.canonical_media_paths)
|
|
assert is_map(ctx.tag_color_by_name)
|
|
assert is_list(ctx.menu_items)
|
|
|
|
incoming = Map.get(ctx.incoming_links_by_post, target.id, [])
|
|
assert Enum.any?(incoming, fn link -> link.title == "Ctx Source" end)
|
|
end
|
|
|
|
test "memoize/3 executes the computation only once per key", %{project: project} do
|
|
ctx = RenderContext.build(project.id)
|
|
{:ok, counter} = Agent.start_link(fn -> 0 end)
|
|
|
|
compute = fn ->
|
|
Agent.update(counter, &(&1 + 1))
|
|
:computed
|
|
end
|
|
|
|
assert RenderContext.memoize(ctx, {:test, "key"}, compute) == :computed
|
|
assert RenderContext.memoize(ctx, {:test, "key"}, compute) == :computed
|
|
assert Agent.get(counter, & &1) == 1
|
|
end
|
|
|
|
test "memoize/3 computes once even under concurrent first calls", %{project: project} do
|
|
ctx = RenderContext.build(project.id)
|
|
{:ok, counter} = Agent.start_link(fn -> 0 end)
|
|
|
|
slow_compute = fn ->
|
|
Agent.update(counter, &(&1 + 1))
|
|
Process.sleep(30)
|
|
:computed
|
|
end
|
|
|
|
results =
|
|
1..8
|
|
|> Task.async_stream(
|
|
fn _index -> RenderContext.memoize(ctx, {:test, "concurrent"}, slow_compute) end,
|
|
max_concurrency: 8,
|
|
timeout: :infinity
|
|
)
|
|
|> Enum.map(fn {:ok, value} -> value end)
|
|
|
|
assert Enum.all?(results, &(&1 == :computed))
|
|
assert Agent.get(counter, & &1) == 1
|
|
end
|
|
|
|
test "render_post_page with a context matches the project_id render", %{project: project} do
|
|
{:ok, template} =
|
|
BDS.Templates.create_template(%{
|
|
project_id: project.id,
|
|
title: "Ctx Parity",
|
|
kind: :post,
|
|
content:
|
|
"{{ page_title }}|{{ post.author }}|{{ post.tags.size }}|{% for lang in blog_languages %}[{{ lang.code }}]{% endfor %}|{{ content }}"
|
|
})
|
|
|
|
{:ok, published_template} = BDS.Templates.publish_template(template.id)
|
|
|
|
post =
|
|
create_published_post(project, %{
|
|
title: "Parity Post",
|
|
author: "Writer",
|
|
tags: ["alpha", "beta"],
|
|
content: "Hello **world**",
|
|
template_slug: published_template.slug
|
|
})
|
|
|
|
assigns = %{
|
|
id: post.id,
|
|
title: post.title,
|
|
content: "Hello **world**",
|
|
slug: post.slug,
|
|
language: "en",
|
|
excerpt: post.excerpt,
|
|
template_slug: post.template_slug
|
|
}
|
|
|
|
assert {:ok, from_project_id} =
|
|
Rendering.render_post_page(project.id, published_template.slug, assigns)
|
|
|
|
ctx = RenderContext.build(project.id)
|
|
|
|
assert {:ok, from_ctx} =
|
|
Rendering.render_post_page(ctx, published_template.slug, assigns)
|
|
|
|
assert from_ctx == from_project_id
|
|
end
|
|
|
|
test "render_list_page with a context matches the project_id render", %{project: project} do
|
|
post = create_published_post(project, %{title: "List Parity", content: "List body"})
|
|
|
|
assigns = %{
|
|
language: "en",
|
|
page_title: "Home",
|
|
posts: [
|
|
%{
|
|
id: post.id,
|
|
slug: post.slug,
|
|
title: post.title,
|
|
href: "/#{post.slug}/",
|
|
excerpt: post.excerpt,
|
|
content: "List body"
|
|
}
|
|
],
|
|
archive_context: %{kind: "core"},
|
|
pagination: %{
|
|
current_page: 1,
|
|
total_pages: 1,
|
|
total_items: 1,
|
|
items_per_page: 10,
|
|
has_prev_page: false,
|
|
prev_page_href: "",
|
|
has_next_page: false,
|
|
next_page_href: ""
|
|
}
|
|
}
|
|
|
|
assert {:ok, from_project_id} = Rendering.render_list_page(project.id, assigns)
|
|
|
|
ctx = RenderContext.build(project.id)
|
|
assert {:ok, from_ctx} = Rendering.render_list_page(ctx, assigns)
|
|
|
|
assert from_ctx == from_project_id
|
|
end
|
|
|
|
test "render_template caches the parsed template AST in the context", %{project: project} do
|
|
ctx = RenderContext.build(project.id)
|
|
source = "Hello {{ name }}"
|
|
|
|
assert {:ok, "Hello A"} =
|
|
BDS.Rendering.TemplateSelection.render_template(ctx, source, %{"name" => "A"})
|
|
|
|
assert {:ok, "Hello B"} =
|
|
BDS.Rendering.TemplateSelection.render_template(ctx, source, %{"name" => "B"})
|
|
|
|
ast_entries =
|
|
:ets.tab2list(ctx.cache)
|
|
|> Enum.filter(fn {key, _value} -> match?({:template_ast, _hash}, key) end)
|
|
|
|
assert length(ast_entries) == 1
|
|
end
|
|
|
|
test "load_body caches post bodies for the lifetime of the context", %{
|
|
project: project,
|
|
temp_dir: temp_dir
|
|
} do
|
|
relative_path = "posts/cached-body.md"
|
|
full_path = Path.join(temp_dir, relative_path)
|
|
File.mkdir_p!(Path.dirname(full_path))
|
|
File.write!(full_path, "---\ntitle: Cached\n---\nCached body content\n")
|
|
|
|
ctx = RenderContext.build(project.id)
|
|
|
|
assert BDS.Generation.Renderers.load_body(ctx, relative_path, nil) ==
|
|
"Cached body content"
|
|
|
|
File.rm!(full_path)
|
|
|
|
assert BDS.Generation.Renderers.load_body(ctx, relative_path, nil) ==
|
|
"Cached body content"
|
|
end
|
|
|
|
test "generate_site batches hash bookkeeping and skips unchanged outputs", %{
|
|
project: project,
|
|
temp_dir: temp_dir
|
|
} do
|
|
_post = create_published_post(project, %{title: "Site Post", content: "Site body"})
|
|
|
|
assert {:ok, %{generated_files: files}} =
|
|
Generation.generate_site(project.id, [:core, :single])
|
|
|
|
assert files != []
|
|
|
|
Enum.each(files, fn file ->
|
|
disk_path = Path.join([temp_dir, "html", file.relative_path])
|
|
assert File.exists?(disk_path)
|
|
|
|
disk_hash =
|
|
:crypto.hash(:sha256, File.read!(disk_path)) |> Base.encode16(case: :lower)
|
|
|
|
assert disk_hash == file.content_hash
|
|
end)
|
|
|
|
first_by_path = Map.new(files, &{&1.relative_path, &1})
|
|
|
|
Process.sleep(5)
|
|
|
|
assert {:ok, %{generated_files: second_files}} =
|
|
Generation.generate_site(project.id, [:core, :single])
|
|
|
|
Enum.each(second_files, fn file ->
|
|
first = Map.fetch!(first_by_path, file.relative_path)
|
|
assert file.content_hash == first.content_hash
|
|
|
|
assert file.updated_at == first.updated_at,
|
|
"expected unchanged output #{file.relative_path} to keep its updated_at"
|
|
end)
|
|
end
|
|
|
|
test "force render rewrites outputs whose stored hash still matches", %{
|
|
project: project,
|
|
temp_dir: temp_dir
|
|
} do
|
|
post = create_published_post(project, %{title: "Force Post", content: "Force body"})
|
|
|
|
assert {:ok, _} = Generation.render_site_section(project.id, :single)
|
|
|
|
post_file = Path.join([temp_dir, "html", Generation.post_output_path(post)])
|
|
original_html = File.read!(post_file)
|
|
File.write!(post_file, "TAMPERED")
|
|
|
|
# A normal render trusts the stored hash and leaves the drifted file alone.
|
|
assert {:ok, _} = Generation.render_site_section(project.id, :single)
|
|
assert File.read!(post_file) == "TAMPERED"
|
|
|
|
# A forced render ignores the stored hashes and rewrites everything.
|
|
assert {:ok, _} = Generation.render_site_section(project.id, :single, force: true)
|
|
assert File.read!(post_file) == original_html
|
|
end
|
|
|
|
test "full section render issues a bounded number of queries regardless of post count", %{
|
|
project: project
|
|
} do
|
|
for index <- 1..12 do
|
|
create_published_post(project, %{
|
|
title: "Query Bound #{index}",
|
|
content: "Body #{index}",
|
|
tags: ["tag-#{rem(index, 3)}"],
|
|
categories: ["article"]
|
|
})
|
|
end
|
|
|
|
query_count =
|
|
count_queries(fn ->
|
|
assert {:ok, %{rendered_url_count: rendered}} =
|
|
Generation.render_site_section(project.id, :single)
|
|
|
|
assert rendered >= 12
|
|
end)
|
|
|
|
# Before the render-context rework this was ~15 queries *per page*; the
|
|
# bound only holds if all per-page lookups stay preloaded.
|
|
assert query_count <= 40,
|
|
"expected a bounded query count for the section render, got #{query_count}"
|
|
end
|
|
|
|
defp count_queries(func) do
|
|
test_pid = self()
|
|
ref = make_ref()
|
|
handler_id = "render-context-query-counter-#{inspect(ref)}"
|
|
|
|
:telemetry.attach(
|
|
handler_id,
|
|
[:bds, :repo, :query],
|
|
fn _event, _measurements, _metadata, _config ->
|
|
send(test_pid, {:query_executed, ref})
|
|
end,
|
|
nil
|
|
)
|
|
|
|
func.()
|
|
:telemetry.detach(handler_id)
|
|
count_query_messages(ref, 0)
|
|
end
|
|
|
|
defp count_query_messages(ref, acc) do
|
|
receive do
|
|
{:query_executed, ^ref} -> count_query_messages(ref, acc + 1)
|
|
after
|
|
0 -> acc
|
|
end
|
|
end
|
|
|
|
defp post_date_parts(post) do
|
|
datetime = BDS.Persistence.from_unix_ms!(post.created_at)
|
|
|
|
[
|
|
Integer.to_string(datetime.year),
|
|
String.pad_leading(Integer.to_string(datetime.month), 2, "0"),
|
|
String.pad_leading(Integer.to_string(datetime.day), 2, "0")
|
|
]
|
|
end
|
|
end
|