From 1b541ff144da36fe8892ad18ecaefbec60a96fa7 Mon Sep 17 00:00:00 2001 From: Cort Fritz Date: Tue, 24 Dec 2024 16:23:42 -0800 Subject: [PATCH] merge CodeRabbit-authored tests --- bench/doc_stress.exs | 254 ++++++++++++++++++++++++++++++++++ bench/observer_stress.exs | 238 +++++++++++++++++++++++++++++++ bench/undo_manager_bench.exs | 61 ++++++++ bench/undo_manager_stress.exs | 177 +++++++++++++++++++++++ test/undo_manager_test.exs | 43 +++--- 5 files changed, 753 insertions(+), 20 deletions(-) create mode 100644 bench/doc_stress.exs create mode 100644 bench/observer_stress.exs create mode 100644 bench/undo_manager_bench.exs create mode 100644 bench/undo_manager_stress.exs diff --git a/bench/doc_stress.exs b/bench/doc_stress.exs new file mode 100644 index 0000000..9b8885a --- /dev/null +++ b/bench/doc_stress.exs @@ -0,0 +1,254 @@ +# Run with: mix run bench/doc_stress.exs + +defmodule DocStress do + @num_concurrent_actors 50 # How many actors to keep active at once + @total_actors 1000 # Total number of actors to create over the test + @actor_lifetime_ms 5000 # How long each actor lives + @test_duration_ms 30_000 # Total test duration + + def run do + # Create a shared document + doc = Yex.Doc.new() + text = Yex.Doc.get_text(doc, "text") + map = Yex.Doc.get_map(doc, "map") + array = Yex.Doc.get_array(doc, "array") + xml = Yex.Doc.get_xml_fragment(doc, "xml") + + # Initialize with some content + Yex.Text.insert(text, 0, String.duplicate("x", 200_000)) + + IO.puts("\nStarting doc stress test:") + IO.puts("- #{@num_concurrent_actors} concurrent actors") + IO.puts("- #{@total_actors} total actors") + IO.puts("- #{@actor_lifetime_ms}ms actor lifetime") + IO.puts("- #{@test_duration_ms}ms test duration") + + # Start progress indicator + spawn_link(fn -> progress_indicator(@test_duration_ms) end) + + # Start actor spawner + spawn_link(fn -> actor_spawner(doc, text, map, array, xml) end) + + # Run for specified duration + Process.sleep(@test_duration_ms) + + # Print results + IO.puts("\nStress test completed") + + # Give time for final metrics collection + Process.sleep(1000) + end + + defp actor_spawner(doc, text, map, array, xml) do + actor_spawner(doc, text, map, array, xml, 0, MapSet.new()) + end + + defp actor_spawner(doc, text, map, array, xml, count, active_pids) when count < @total_actors do + # Remove any finished actors + active_pids = + active_pids + |> Enum.filter(&Process.alive?/1) + |> MapSet.new() + + # Spawn new actors if we're below the concurrent limit + active_pids = + if MapSet.size(active_pids) < @num_concurrent_actors do + pid = spawn_actor(doc, text, map, array, xml, count) + MapSet.put(active_pids, pid) + else + active_pids + end + + Process.sleep(100) # Control spawn rate + actor_spawner(doc, text, map, array, xml, count + 1, active_pids) + end + + defp actor_spawner(_doc, _text, _map, _array, _xml, count, _active_pids) do + MemoryMetrics.record_final_actors(count) + end + + defp spawn_actor(doc, text, map, array, xml, id) do + spawn_link(fn -> + start_time = System.monotonic_time(:millisecond) + + try do + # Keep doc reference alive while working with shared types + _doc_ref = doc + + # Do some work with all types + Enum.each(1..10, fn _ -> + operation = random_operation() + + case operation do + :text -> + pos = :rand.uniform(200_000) - 1 + content = random_string(1..10) + Yex.Text.insert(text, pos, content) + + :map -> + key = "key_#{:rand.uniform(1000)}" + value = "value_#{:rand.uniform(1000)}" + Yex.Map.set(map, key, value) + + :array -> + value = "item_#{:rand.uniform(1000)}" + Yex.Array.push(array, value) + + :xml -> + tag_num = :rand.uniform(100) + content = "content" + Yex.XmlFragment.push(xml, Yex.XmlTextPrelim.from(content)) + end + end) + + # Record successful operations + MemoryMetrics.record_operations_completed() + + # Live for specified duration + remaining_time = @actor_lifetime_ms - (System.monotonic_time(:millisecond) - start_time) + if remaining_time > 0, do: Process.sleep(remaining_time) + + rescue + e -> + IO.puts("\nActor #{id} error: #{inspect(e)}") + MemoryMetrics.record_error() + end + end) + end + + defp random_operation do + case :rand.uniform(100) do + x when x <= 40 -> :text # 40% chance + x when x <= 70 -> :map # 30% chance + x when x <= 90 -> :array # 20% chance + _ -> :xml # 10% chance + end + end + + defp random_string(range) do + length = :rand.uniform(Enum.max(range)) + :crypto.strong_rand_bytes(length) + |> Base.encode64() + |> binary_part(0, length) + end + + defp progress_indicator(total_ms) do + interval = 1000 # Update every second + segments = trunc(total_ms / interval) + + Enum.reduce(1..segments, 0, fn i, _ -> + percent = Float.round(i / segments * 100, 1) + memory = :erlang.memory() + + clear_line() + IO.write("\rProgress: [#{String.duplicate("=", trunc(i/segments * 40))}#{String.duplicate(" ", 40 - trunc(i/segments * 40))}] #{percent}%") + IO.write(" | Memory: #{format_bytes(memory[:total])}") + + MemoryMetrics.record_memory_point(memory) + Process.sleep(interval) + i + end) + + clear_line() + IO.write("\rProgress: [#{String.duplicate("=", 40)}] 100%\n") + end + + defp clear_line, do: IO.write("\r#{String.duplicate(" ", 120)}") + + defp format_bytes(bytes) when bytes < 1024, do: "#{bytes}B" + defp format_bytes(bytes) when bytes < 1024 * 1024, do: "#{Float.round(bytes / 1024, 2)}KB" + defp format_bytes(bytes), do: "#{Float.round(bytes / 1024 / 1024, 2)}MB" +end + +defmodule MemoryMetrics do + use GenServer + + def start_link do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + def init(_) do + :ets.new(:memory_metrics, [:named_table, :public]) + {:ok, %{ + start_time: System.monotonic_time(:millisecond), + operations_completed: 0, + errors: 0, + final_actors: 0 + }} + end + + def record_memory_point(memory) do + time = System.monotonic_time(:millisecond) + :ets.insert(:memory_metrics, {{:memory, time}, memory}) + end + + def record_operations_completed do + GenServer.cast(__MODULE__, :operation_completed) + end + + def record_error do + GenServer.cast(__MODULE__, :error) + end + + def record_final_actors(count) do + GenServer.cast(__MODULE__, {:final_actors, count}) + end + + def print_metrics do + state = GenServer.call(__MODULE__, :get_state) + memory_points = :ets.match_object(:memory_metrics, {{:memory, :_}, :_}) + + IO.puts("\nOperation Metrics:") + IO.puts("================") + IO.puts("Total actors created: #{state.final_actors}") + IO.puts("Operations completed: #{state.operations_completed}") + IO.puts("Errors: #{state.errors}") + + case memory_points do + [] -> + IO.puts("No memory data collected") + + points -> + {min_memory, max_memory, avg_memory} = analyze_memory(points) + IO.puts("\nMemory Usage:") + IO.puts("Min: #{format_bytes(min_memory)}") + IO.puts("Max: #{format_bytes(max_memory)}") + IO.puts("Avg: #{format_bytes(trunc(avg_memory))}") + end + end + + defp analyze_memory(points) do + memories = Enum.map(points, fn {{:memory, _}, memory} -> memory[:total] end) + { + Enum.min(memories), + Enum.max(memories), + Enum.sum(memories) / length(memories) + } + end + + defp format_bytes(bytes) when bytes < 1024, do: "#{bytes}B" + defp format_bytes(bytes) when bytes < 1024 * 1024, do: "#{Float.round(bytes / 1024, 2)}KB" + defp format_bytes(bytes), do: "#{Float.round(bytes / 1024 / 1024, 2)}MB" + + # Server callbacks + def handle_cast(:operation_completed, state) do + {:noreply, %{state | operations_completed: state.operations_completed + 1}} + end + + def handle_cast(:error, state) do + {:noreply, %{state | errors: state.errors + 1}} + end + + def handle_cast({:final_actors, count}, state) do + {:noreply, Map.put(state, :final_actors, count)} + end + + def handle_call(:get_state, _from, state) do + {:reply, state, state} + end +end + +# Run the stress test +{:ok, _pid} = MemoryMetrics.start_link() +DocStress.run() +MemoryMetrics.print_metrics() diff --git a/bench/observer_stress.exs b/bench/observer_stress.exs new file mode 100644 index 0000000..f224ca8 --- /dev/null +++ b/bench/observer_stress.exs @@ -0,0 +1,238 @@ +# Run with: mix run bench/observer_stress.exs + +defmodule ObserverStress do + @num_concurrent_actors 50 # How many actors to keep active at once + @total_actors 1000 # Total number of actors to create over the test + @actor_lifetime_ms 5000 # How long each actor lives + @test_duration_ms 30_000 # Total test duration + + def run do + # Create a shared document + doc = Yex.Doc.new() + text = Yex.Doc.get_text(doc, "text") + + IO.puts("\nStarting observer stress test:") + IO.puts("- #{@num_concurrent_actors} concurrent actors") + IO.puts("- #{@total_actors} total actors") + IO.puts("- #{@actor_lifetime_ms}ms actor lifetime") + IO.puts("- #{@test_duration_ms}ms test duration") + + # Start progress indicator + spawn_link(fn -> progress_indicator(@test_duration_ms) end) + + # Start actor spawner + spawn_link(fn -> actor_spawner(doc, text) end) + + # Run for specified duration + Process.sleep(@test_duration_ms) + + # Print results + IO.puts("\nStress test completed") + + # Give time for final metrics collection + Process.sleep(1000) + end + + defp actor_spawner(doc, text) do + actor_spawner(doc, text, 0, MapSet.new()) + end + + defp actor_spawner(doc, text, count, active_pids) when count < @total_actors do + # Remove any finished actors + active_pids = + active_pids + |> Enum.filter(&Process.alive?/1) + |> MapSet.new() + + # Spawn new actors if we're below the concurrent limit + active_pids = + if MapSet.size(active_pids) < @num_concurrent_actors do + pid = spawn_actor(doc, text, count) + MapSet.put(active_pids, pid) + else + active_pids + end + + Process.sleep(100) # Control spawn rate + actor_spawner(doc, text, count + 1, active_pids) + end + + defp actor_spawner(_doc, _text, count, _active_pids) do + MemoryMetrics.record_final_actors(count) + end + + defp spawn_actor(doc, text, id) do + spawn_link(fn -> + start_time = System.monotonic_time(:millisecond) + + try do + {:ok, manager} = Yex.UndoManager.new(doc, text) + + # Add multiple observers + {:ok, manager} = Yex.UndoManager.on_item_added(manager, fn _event -> + %{actor_id: id, type: :added} + end) + + {:ok, manager} = Yex.UndoManager.on_item_updated(manager, fn _event -> + %{actor_id: id, type: :updated} + end) + + {:ok, manager} = Yex.UndoManager.on_item_popped(manager, fn _id, _event -> + %{actor_id: id, type: :popped} + end) + + # Record successful observer creation + MemoryMetrics.record_observer_created() + + # Do some work + Enum.each(1..5, fn _ -> + Yex.Text.insert(text, 0, "test") + Yex.UndoManager.undo(manager) + Process.sleep(:rand.uniform(500)) + end) + + # Live for specified duration + remaining_time = @actor_lifetime_ms - (System.monotonic_time(:millisecond) - start_time) + if remaining_time > 0, do: Process.sleep(remaining_time) + + # Record successful cleanup + MemoryMetrics.record_observer_cleaned() + + rescue + e -> + IO.puts("\nActor #{id} error: #{inspect(e)}") + MemoryMetrics.record_error() + end + end) + end + + defp progress_indicator(total_ms) do + interval = 1000 # Update every second + segments = trunc(total_ms / interval) + + Enum.reduce(1..segments, 0, fn i, _ -> + percent = Float.round(i / segments * 100, 1) + memory = :erlang.memory() + + clear_line() + IO.write("\rProgress: [#{String.duplicate("=", trunc(i/segments * 40))}#{String.duplicate(" ", 40 - trunc(i/segments * 40))}] #{percent}%") + IO.write(" | Memory: #{format_bytes(memory[:total])}") + + MemoryMetrics.record_memory_point(memory) + Process.sleep(interval) + i + end) + + clear_line() + IO.write("\rProgress: [#{String.duplicate("=", 40)}] 100%\n") + end + + defp clear_line, do: IO.write("\r#{String.duplicate(" ", 120)}") + + defp format_bytes(bytes) when bytes < 1024, do: "#{bytes}B" + defp format_bytes(bytes) when bytes < 1024 * 1024, do: "#{Float.round(bytes / 1024, 2)}KB" + defp format_bytes(bytes), do: "#{Float.round(bytes / 1024 / 1024, 2)}MB" +end + +defmodule MemoryMetrics do + use GenServer + + def start_link do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + def init(_) do + :ets.new(:memory_metrics, [:named_table, :public]) + {:ok, %{ + start_time: System.monotonic_time(:millisecond), + observers_created: 0, + observers_cleaned: 0, + errors: 0, + final_actors: 0 + }} + end + + def record_memory_point(memory) do + time = System.monotonic_time(:millisecond) + :ets.insert(:memory_metrics, {{:memory, time}, memory}) + end + + def record_observer_created do + GenServer.cast(__MODULE__, :observer_created) + end + + def record_observer_cleaned do + GenServer.cast(__MODULE__, :observer_cleaned) + end + + def record_error do + GenServer.cast(__MODULE__, :error) + end + + def record_final_actors(count) do + GenServer.cast(__MODULE__, {:final_actors, count}) + end + + def print_metrics do + state = GenServer.call(__MODULE__, :get_state) + memory_points = :ets.match_object(:memory_metrics, {{:memory, :_}, :_}) + + IO.puts("\nMemory Metrics:") + IO.puts("==============") + IO.puts("Total actors created: #{state.final_actors || 0}") + IO.puts("Observers created: #{state.observers_created}") + IO.puts("Observers cleaned: #{state.observers_cleaned}") + IO.puts("Errors: #{state.errors}") + + case memory_points do + [] -> + IO.puts("No memory data collected") + + points -> + {min_memory, max_memory, avg_memory} = analyze_memory(points) + IO.puts("\nMemory Usage:") + IO.puts("Min: #{format_bytes(min_memory)}") + IO.puts("Max: #{format_bytes(max_memory)}") + IO.puts("Avg: #{format_bytes(trunc(avg_memory))}") + end + end + + defp analyze_memory(points) do + memories = Enum.map(points, fn {{:memory, _}, memory} -> memory[:total] end) + { + Enum.min(memories), + Enum.max(memories), + Enum.sum(memories) / length(memories) + } + end + + defp format_bytes(bytes) when bytes < 1024, do: "#{bytes}B" + defp format_bytes(bytes) when bytes < 1024 * 1024, do: "#{Float.round(bytes / 1024, 2)}KB" + defp format_bytes(bytes), do: "#{Float.round(bytes / 1024 / 1024, 2)}MB" + + # Server callbacks + def handle_cast(:observer_created, state) do + {:noreply, %{state | observers_created: state.observers_created + 1}} + end + + def handle_cast(:observer_cleaned, state) do + {:noreply, %{state | observers_cleaned: state.observers_cleaned + 1}} + end + + def handle_cast(:error, state) do + {:noreply, %{state | errors: state.errors + 1}} + end + + def handle_cast({:final_actors, count}, state) do + {:noreply, Map.put(state, :final_actors, count)} + end + + def handle_call(:get_state, _from, state) do + {:reply, state, state} + end +end + +# Run the stress test +{:ok, _pid} = MemoryMetrics.start_link() +ObserverStress.run() +MemoryMetrics.print_metrics() diff --git a/bench/undo_manager_bench.exs b/bench/undo_manager_bench.exs new file mode 100644 index 0000000..1647b53 --- /dev/null +++ b/bench/undo_manager_bench.exs @@ -0,0 +1,61 @@ +# Run with: mix run bench/undo_manager_bench.exs + +doc_setup = fn _ -> + doc = Yex.Doc.new() + text = Yex.Doc.get_text(doc, "mytext") + {:ok, manager} = Yex.UndoManager.new(doc, text) + {doc, text, manager} +end + +Benchee.run( + %{ + "single operation undo/redo" => fn input -> + {_doc, text, manager} = input + Yex.Text.insert(text, 0, "Hello") + Yex.UndoManager.undo(manager) + Yex.UndoManager.redo(manager) + end, + + "multiple operations batch" => fn input -> + {_doc, text, manager} = input + Yex.Text.insert(text, 0, "Hello") + Yex.Text.insert(text, 5, " World") + Yex.Text.insert(text, 11, "!") + Yex.UndoManager.undo(manager) + end, + + "multiple operations separate" => fn input -> + {_doc, text, manager} = input + Yex.Text.insert(text, 0, "Hello") + Yex.UndoManager.stop_capturing(manager) + Yex.Text.insert(text, 5, " World") + Yex.UndoManager.stop_capturing(manager) + Yex.Text.insert(text, 11, "!") + Yex.UndoManager.undo(manager) + Yex.UndoManager.undo(manager) + Yex.UndoManager.undo(manager) + end, + + "observer callbacks" => fn input -> + {_doc, text, manager} = input + {:ok, manager} = Yex.UndoManager.on_item_added(manager, fn _event -> %{} end) + {:ok, manager} = Yex.UndoManager.on_item_updated(manager, fn _event -> nil end) + {:ok, manager} = Yex.UndoManager.on_item_popped(manager, fn _id, _event -> nil end) + Yex.Text.insert(text, 0, "Test") + Yex.UndoManager.undo(manager) + end, + + "scope expansion" => fn input -> + {doc, text, manager} = input + array = Yex.Doc.get_array(doc, "myarray") + Yex.UndoManager.expand_scope(manager, array) + Yex.Text.insert(text, 0, "Hello") + Yex.Array.push(array, "World") + Yex.UndoManager.undo(manager) + end + }, + before_each: doc_setup, + time: 5, + memory_time: 2, + formatters: [Benchee.Formatters.Console] +) diff --git a/bench/undo_manager_stress.exs b/bench/undo_manager_stress.exs new file mode 100644 index 0000000..a6f7656 --- /dev/null +++ b/bench/undo_manager_stress.exs @@ -0,0 +1,177 @@ +# Run with: mix run bench/undo_manager_stress.exs + +defmodule UndoManagerStress do + @doc_size 200_000 + @num_actors 10 + @test_duration_ms 30_000 # 30 seconds + + def run do + # Create a shared document + doc = Yex.Doc.new() + text = Yex.Doc.get_text(doc, "text") + map = Yex.Doc.get_map(doc, "map") + array = Yex.Doc.get_array(doc, "array") + xml = Yex.Doc.get_xml_fragment(doc, "xml") + + # Initialize with some content + Yex.Text.insert(text, 0, String.duplicate("x", @doc_size)) + + IO.puts("\nStarting stress test with #{@num_actors} actors for #{@test_duration_ms/1000} seconds...") + + # Start progress indicator + spawn_link(fn -> progress_indicator(@test_duration_ms) end) + + # Start actors + actors = for i <- 1..@num_actors do + {:ok, manager} = Yex.UndoManager.new(doc, text) + # Expand scope to include all types + Yex.UndoManager.expand_scope(manager, map) + Yex.UndoManager.expand_scope(manager, array) + Yex.UndoManager.expand_scope(manager, xml) + + spawn_link(fn -> actor_loop(i, doc, text, map, array, xml, manager) end) + end + + # Run for specified duration + Process.sleep(@test_duration_ms) + + # Stop actors + Enum.each(actors, &Process.exit(&1, :normal)) + + # Print results + IO.puts("\nStress test completed") + end + + defp progress_indicator(total_ms) do + interval = 1000 # Update every second + segments = trunc(total_ms / interval) + + Enum.reduce(1..segments, 0, fn i, _ -> + percent = Float.round(i / segments * 100, 1) + clear_line() + IO.write("\rProgress: [#{String.duplicate("=", trunc(i/segments * 40))}#{String.duplicate(" ", 40 - trunc(i/segments * 40))}] #{percent}%") + Process.sleep(interval) + i + end) + + clear_line() + IO.write("\rProgress: [#{String.duplicate("=", 40)}] 100%\n") + end + + defp clear_line, do: IO.write("\r#{String.duplicate(" ", 80)}") + + defp actor_loop(id, doc, text, map, array, xml, manager) do + operation = random_operation() + + try do + start_time = System.monotonic_time(:millisecond) + + result = case operation do + :text -> + pos = :rand.uniform(@doc_size) - 1 + content = random_string(1..10) + Yex.Text.insert(text, pos, content) + + :map -> + key = "key_#{:rand.uniform(1000)}" + value = "value_#{:rand.uniform(1000)}" + Yex.Map.set(map, key, value) + + :array -> + value = "item_#{:rand.uniform(1000)}" + Yex.Array.push(array, value) + + :xml -> + tag_num = :rand.uniform(100) + content = "content" + Yex.XmlFragment.push(xml, Yex.XmlTextPrelim.from(content)) + + :undo -> + Yex.UndoManager.undo(manager) + + :redo -> + Yex.UndoManager.redo(manager) + end + + end_time = System.monotonic_time(:millisecond) + duration = end_time - start_time + + # Record metrics for successful operations + Metrics.record_operation(operation, duration) + result + + rescue + e -> + IO.puts("\nActor #{id} error on #{operation}: #{inspect(e)}") + Metrics.record_error(operation) + end + + # Random pause between operations (100-500ms) + Process.sleep(:rand.uniform(400) + 100) + actor_loop(id, doc, text, map, array, xml, manager) + end + + defp random_operation do + case :rand.uniform(100) do + x when x <= 7 -> :undo # 7% chance + x when x <= 10 -> :redo # 3% chance + x when x <= 50 -> :text # 40% chance + x when x <= 70 -> :map # 20% chance + x when x <= 90 -> :array # 20% chance + _ -> :xml # 10% chance + end + end + + defp random_string(range) do + length = :rand.uniform(Enum.max(range)) + :crypto.strong_rand_bytes(length) + |> Base.encode64() + |> binary_part(0, length) + end +end + +# Add some basic metrics collection +defmodule Metrics do + use GenServer + + def start_link do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + def init(_) do + :ets.new(:operation_metrics, [:named_table, :public]) + :ets.new(:error_metrics, [:named_table, :public]) + {:ok, %{start_time: System.monotonic_time(:millisecond)}} + end + + def record_operation(operation, duration_ms) do + :ets.update_counter(:operation_metrics, operation, {2, 1}, {operation, 0, 0, 0}) + :ets.update_counter(:operation_metrics, operation, {3, duration_ms}, {operation, 0, 0, 0}) + :ets.update_counter(:operation_metrics, operation, {4, 1}, {operation, 0, 0, duration_ms}) + end + + def record_error(operation) do + :ets.update_counter(:error_metrics, operation, {2, 1}, {operation, 0}) + end + + def print_metrics do + IO.puts("\nOperation Metrics:") + IO.puts("================") + + :ets.tab2list(:operation_metrics) + |> Enum.sort() + |> Enum.each(fn {op, count, total_ms, _} -> + avg_ms = if count > 0, do: Float.round(total_ms / count, 2), else: 0 + errors = case :ets.lookup(:error_metrics, op) do + [{_, error_count}] -> error_count + [] -> 0 + end + IO.puts("#{op}: #{count} operations, avg #{avg_ms}ms per operation, #{errors} errors") + end) + end +end + +# Run the stress test +{:ok, _pid} = Metrics.start_link() +UndoManagerStress.run() +Metrics.print_metrics() diff --git a/test/undo_manager_test.exs b/test/undo_manager_test.exs index 0d0798a..f47e33d 100644 --- a/test/undo_manager_test.exs +++ b/test/undo_manager_test.exs @@ -2064,6 +2064,13 @@ defmodule Yex.UndoManagerTest do end end + defp measure_time(fun) do + start_time = System.monotonic_time(:millisecond) + result = fun.() + end_time = System.monotonic_time(:millisecond) + {result, end_time - start_time} + end + test "capture_timeout option works", %{doc: doc, text: text} do # Use a longer timeout and larger margins for system load variations options = %UndoManager.Options{ @@ -2071,22 +2078,17 @@ defmodule Yex.UndoManagerTest do } {:ok, undo_manager} = UndoManager.new_with_options(doc, text, options) - + # Helper function to measure time - measure_time = fn fun -> - start_time = System.monotonic_time(:millisecond) - result = fun.() - end_time = System.monotonic_time(:millisecond) - {result, end_time - start_time} - end # Make changes - {_, duration} = measure_time(fn -> - Text.insert(text, 0, "Hello") - # Wait for 1/4 of timeout - Process.sleep(div(options.capture_timeout, 4)) - Text.insert(text, 5, " World") - end) + {_, duration} = + measure_time(fn -> + Text.insert(text, 0, "Hello") + # Wait for 1/4 of timeout + Process.sleep(div(options.capture_timeout, 4)) + Text.insert(text, 5, " World") + end) # Verify duration was less than timeout assert duration < options.capture_timeout @@ -2097,12 +2099,13 @@ defmodule Yex.UndoManagerTest do assert Text.to_string(text) == "" # Make changes with pause longer than capture_timeout - {_, duration} = measure_time(fn -> - Text.insert(text, 0, "Hello") - # Wait for 2x timeout - Process.sleep(options.capture_timeout * 2) - Text.insert(text, 5, " World") - end) + {_, duration} = + measure_time(fn -> + Text.insert(text, 0, "Hello") + # Wait for 2x timeout + Process.sleep(options.capture_timeout * 2) + Text.insert(text, 5, " World") + end) # Verify duration exceeded timeout assert duration > options.capture_timeout @@ -2160,7 +2163,7 @@ defmodule Yex.UndoManagerTest do # Set up counter for tracking retry attempts :ets.new(:retry_counter, [:set, :public, :named_table]) :ets.insert(:retry_counter, {:attempts, 0}) - + on_exit(fn -> if :ets.whereis(:retry_counter) != :undefined do :ets.delete(:retry_counter)