├── .bumpversion.cfg ├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── LICENSE ├── README.md ├── USAGE.md ├── lib ├── klotho.ex └── klotho │ ├── application.ex │ ├── mock.ex │ └── real.ex ├── mix.exs ├── mix.lock └── test ├── klotho_test.exs ├── klotho_timeout_cache_test.exs ├── support └── timeout_cache.ex └── test_helper.exs /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.2 3 | commit = True 4 | commit_args = --no-verify 5 | tag = True 6 | tag_name = v{new_version} 7 | message = Version bumpup: {current_version} → {new_version} 8 | 9 | [bumpversion:file:mix.exs] 10 | search = version: "{current_version}" 11 | replace = version: "{new_version}" 12 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | name: Build and test 12 | runs-on: ubuntu-22.04 13 | 14 | # https://github.com/elixir-lang/elixir/blob/master/lib/elixir/pages/compatibility-and-deprecations.md 15 | strategy: 16 | matrix: 17 | include: 18 | 19 | # Elixir 1.12 20 | 21 | - elixir: 1.12.3 22 | otp_release: 24.3 23 | 24 | # Elixir 1.13 25 | 26 | - elixir: 1.13.4 27 | otp_release: 24.3 28 | 29 | - elixir: 1.13.4 30 | otp_release: 25.3 31 | 32 | # Elixir 1.14 33 | 34 | - elixir: 1.14.5 35 | otp_release: 24.2.1 36 | 37 | - elixir: 1.14.5 38 | otp_release: 25.3 39 | 40 | # Elixir 1.15 41 | 42 | - elixir: 1.15.2 43 | otp_release: 24.3 44 | 45 | - elixir: 1.15.2 46 | otp_release: 25.3 47 | 48 | - elixir: 1.15.2 49 | otp_release: 26.0 50 | 51 | steps: 52 | - uses: actions/checkout@v2 53 | - name: Set up Elixir 54 | uses: erlef/setup-beam@v1 55 | with: 56 | elixir-version: ${{ matrix.elixir }} 57 | otp-version: ${{ matrix.otp_release }} 58 | - name: Restore dependencies cache 59 | uses: actions/cache@v2 60 | with: 61 | path: deps 62 | key: ${{ matrix.elixir }}-${{ matrix.otp_release }}-${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 63 | - name: Install dependencies 64 | run: mix deps.get 65 | - name: Run tests 66 | run: mix test 67 | 68 | code_analysis: 69 | name: Run code analysis 70 | runs-on: ubuntu-22.04 71 | env: 72 | MIX_ENV: test 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | steps: 75 | - uses: actions/checkout@v2 76 | - name: Set up Elixir 77 | uses: erlef/setup-beam@v1 78 | with: 79 | elixir-version: '1.14.3' # Define the elixir version [required] 80 | otp-version: '25.3' # Define the OTP version [required] 81 | - name: Install dependencies 82 | run: mix deps.get 83 | - name: Check formatting 84 | run: mix format --check-formatted 85 | - name: Send coveralls 86 | run: mix coveralls.github 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | klotho-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2017-2023 Ilya Averyanov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Elixir CI](https://github.com/savonarola/klotho/actions/workflows/elixir.yml/badge.svg)](https://github.com/savonarola/klotho/actions/workflows/elixir.yml) 2 | [![Coverage Status](https://coveralls.io/repos/github/savonarola/klotho/badge.svg?branch=main)](https://coveralls.io/github/savonarola/klotho?branch=main) 3 | 4 | # Klotho 5 | 6 | Opinionated library for testing timer-based code. 7 | 8 | ## Usage 9 | 10 | See [USAGE](USAGE.md) and [online documentation](https://hexdocs.pm/klotho). 11 | 12 | ## Installation 13 | 14 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 15 | by adding `klotho` to your list of dependencies in `mix.exs`: 16 | 17 | ```elixir 18 | def deps do 19 | [ 20 | {:klotho, "~> 0.1.0"} 21 | ] 22 | end 23 | ``` 24 | 25 | 26 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | # `Klotho` usage 2 | 3 | ## General cosiderations 4 | 5 | Testing code that deals with time, timeouts, and timers is hard. The main reason is that 6 | getting time and setting timers is often not treated as a public contract, but it actually is. 7 | 8 | Often, to test such code, one provides custom significantly reduced timeouts and uses `:timer.sleep/1`. 9 | This approach has several drawbacks: 10 | * it makes the tests slow 11 | * if we use lagre timeouts, it makes the tests even slower 12 | * if we use small timeouts, it makes the tests flaky 13 | 14 | One of the approaches to testing without sleeps is injecting time-related functions directly, 15 | thus making the contract explicitly public. However, this makes the code much more 16 | complex and harder to read. 17 | 18 | `Klotho` takes a different approach. It injects timer-based functions globally. 19 | With `Klotho` you do not use time-related functions directly, but instead, you use `Klotho` functions that wrap the original ones in production code. 20 | 21 | In tests, these functions are replaced with a mock implementation that allows controlling time "flow". 22 | See `Klotho.Mock` for details. 23 | 24 | ## Example 25 | 26 | Assume we have a module implementing a simple "timeout map" functionality. It allows setting 27 | a timeout for a key and records are automatically removed from the map when the timeout expires. 28 | 29 | 30 | A possible (and a bit naive) implementation could look like this: 31 | 32 | ```elixir 33 | defmodule TimeoutCache do 34 | @moduledoc false 35 | 36 | use GenServer 37 | 38 | # API 39 | 40 | def start_link() do 41 | GenServer.start_link(__MODULE__, []) 42 | end 43 | 44 | def set(pid, key, value, timeout) do 45 | GenServer.call(pid, {:set, key, value, timeout}) 46 | end 47 | 48 | def get(pid, key) do 49 | GenServer.call(pid, {:get, key}) 50 | end 51 | 52 | # gen_server 53 | 54 | def init([]) do 55 | {:ok, %{}} 56 | end 57 | 58 | def handle_call({:set, key, value, timeout}, _from, state) do 59 | new_st = 60 | state 61 | |> maybe_delete(key) 62 | |> put_new(key, value, timeout) 63 | 64 | {:reply, :ok, new_st} 65 | end 66 | 67 | def handle_call({:get, key}, _from, state) do 68 | {:reply, get_value(state, key), state} 69 | end 70 | 71 | def handle_info({:timeout, ref, key}, state) do 72 | new_st = maybe_delete_timeout(state, key, ref) 73 | {:noreply, new_st} 74 | end 75 | 76 | # private 77 | 78 | defp maybe_delete(state, key) do 79 | case state do 80 | %{^key => {_value, ref}} -> 81 | :erlang.cancel_timer(ref) 82 | Map.delete(state, key) 83 | 84 | _ -> 85 | state 86 | end 87 | end 88 | 89 | defp put_new(state, key, value, timeout) do 90 | ref = :erlang.start_timer(timeout, self(), key) 91 | Map.put(state, key, {value, ref}) 92 | end 93 | 94 | defp maybe_delete_timeout(state, key, ref) do 95 | case state do 96 | %{^key => {_value, ^ref}} -> 97 | Map.delete(state, key) 98 | 99 | _ -> 100 | state 101 | end 102 | end 103 | 104 | defp get_value(state, key) do 105 | case state do 106 | %{^key => {value, _ref}} -> 107 | {:ok, value} 108 | 109 | _ -> 110 | :not_found 111 | end 112 | end 113 | end 114 | ``` 115 | 116 | How do we test that keys are actually removed from the map after the timeout expires? 117 | A possible test suite could look like this: 118 | 119 | ```elixir 120 | defmodule TimeoutCacheTest do 121 | use ExUnit.Case 122 | 123 | setup do 124 | {:ok, pid} = TimeoutCache.start_link() 125 | {:ok, pid: pid} 126 | end 127 | 128 | test "set and get", %{pid: pid} do 129 | TimeoutCache.set(pid, :foo, :bar, 100) 130 | assert {:ok, :bar} == TimeoutCache.get(pid, :foo) 131 | end 132 | 133 | test "get after timeout", %{pid: pid} do 134 | TimeoutCache.set(pid, :foo, :bar, 100) 135 | :timer.sleep(200) 136 | assert :not_found == TimeoutCache.get(pid, :foo) 137 | end 138 | 139 | test "renew lifetime", %{pid: pid} do 140 | TimeoutCache.set(pid, :foo, :bar1, 100) 141 | :timer.sleep(50) 142 | TimeoutCache.set(pid, :foo, :bar2, 100) 143 | :timer.sleep(70) 144 | assert {:ok, :bar2} == TimeoutCache.get(pid, :foo) 145 | end 146 | end 147 | ``` 148 | 149 | This test suite is slow and flaky. It is slow because of `:timer.sleep/1` calls. 150 | Also, if the test machine is under heavy load, the timeouts may expire later than expected, thus making the tests flaky. 151 | On the other hand, if we increase the timeouts, the tests will become even slower. 152 | 153 | With `Klotho` we may rewrite the implementation as follows: 154 | 155 | ```elixir 156 | defmodule TimeoutCache do 157 | ... 158 | 159 | defp maybe_delete(state, key) do 160 | case state do 161 | %{^key => {_value, ref}} -> 162 | Klotho.cancel_timer(ref) 163 | Map.delete(state, key) 164 | 165 | _ -> 166 | state 167 | end 168 | end 169 | 170 | defp put_new(state, key, value, timeout) do 171 | ref = Klotho.start_timer(timeout, self(), key) 172 | Map.put(state, key, {value, ref}) 173 | end 174 | 175 | ... 176 | end 177 | ``` 178 | 179 | We just replaced `:erlang.cancel_timer/1` with `Klotho.cancel_timer/1` and `:erlang.start_timer/3` 180 | with `Klotho.start_timer/3`. See [`timeout_cache.erl`](./test/support/timeout_cache.ex). 181 | Now we can rewrite the test suite as follows: 182 | 183 | ```elixir 184 | defmodule TimeoutCacheTest do 185 | use ExUnit.Case 186 | 187 | setup do 188 | {:ok, pid} = TimeoutCache.start_link() 189 | Klotho.Mock.reset() 190 | Klotho.Mock.freeze() 191 | {:ok, pid: pid} 192 | end 193 | 194 | test "set and get", %{pid: pid} do 195 | TimeoutCache.set(pid, :foo, :bar, 1000) 196 | assert {:ok, :bar} == TimeoutCache.get(pid, :foo) 197 | end 198 | 199 | test "get after timeout", %{pid: pid} do 200 | TimeoutCache.set(pid, :foo, :bar, 1000) 201 | ## Timers whose time has passed are triggered in the end of warp 202 | Klotho.Mock.warp_by(2000) 203 | assert :not_found == TimeoutCache.get(pid, :foo) 204 | end 205 | 206 | test "renew lifetime", %{pid: pid} do 207 | TimeoutCache.set(pid, :foo, :bar1, 1000) 208 | Klotho.Mock.warp_by(500) 209 | TimeoutCache.set(pid, :foo, :bar2, 1000) 210 | Klotho.Mock.warp_by(700) 211 | assert {:ok, :bar2} == TimeoutCache.get(pid, :foo) 212 | end 213 | end 214 | ``` 215 | 216 | We may use arbitrary timeouts in tests, just warping the time by the required amount. The code does not 217 | use sleeps and runs fast. Also, the tests are not flaky anymore. 218 | 219 | See the difference in the test execution time: 220 | 221 | ``` 222 | $ mix test test/klotho_timeout_cache_test.exs --trace 223 | 224 | Klotho.TimeoutCacheTest.Klotho [test/klotho_timeout_cache_test.exs] 225 | * test set and get (1.7ms) [L#44] 226 | * test renew lifetime (0.05ms) [L#55] 227 | * test get after timeout (0.07ms) [L#49] 228 | 229 | Klotho.TimeoutCacheTest.Timer [test/klotho_timeout_cache_test.exs] 230 | * test set and get (0.05ms) [L#12] 231 | * test renew lifetime (121.8ms) [L#23] 232 | * test get after timeout (200.9ms) [L#17] 233 | 234 | Finished in 0.3 seconds (0.00s async, 0.3s sync) 235 | 6 tests, 0 failures 236 | ``` 237 | 238 | ## Limitations 239 | 240 | `Klotho` does not intend to be suitable for any case and provide beam-wide time injection. It is 241 | designed to be used in a wide but still limited scope of cases when the code deals with some 242 | medium-intensive self-contained time-related logic. 243 | 244 | * See `Klotho` for the full list of supported functions. 245 | * The library may not be suitable for testing logic with some high-frequency events or events with 246 | an order of millisecond latency because the time is managed by a `GenServer` process. 247 | * The library may not work well if code actively uses other modules with their own and 248 | intensive time-related logic. 249 | -------------------------------------------------------------------------------- /lib/klotho.ex: -------------------------------------------------------------------------------- 1 | defmodule Klotho do 2 | @moduledoc """ 3 | A module that provides a interface to Erlang's time functions. 4 | 5 | In production, all functions are proxied to the `:erlang` module directly. 6 | 7 | In tests, the functions are proxied to `Klotho.Mock`, and the time "flow" 8 | can be controlled by calling `Klotho.Mock` functions. 9 | """ 10 | 11 | if Mix.env() == :test do 12 | @backend Klotho.Mock 13 | else 14 | @backend Klotho.Real 15 | end 16 | 17 | def monotonic_time(unit) do 18 | backend().monotonic_time(unit) 19 | end 20 | 21 | def monotonic_time() do 22 | backend().monotonic_time() 23 | end 24 | 25 | def send_after(time, pid, message) do 26 | backend().send_after(time, pid, message) 27 | end 28 | 29 | def send_after(time, pid, message, opts) do 30 | backend().send_after(time, pid, message, opts) 31 | end 32 | 33 | def start_timer(time, pid, message) do 34 | backend().start_timer(time, pid, message) 35 | end 36 | 37 | def start_timer(time, pid, message, opts) do 38 | backend().start_timer(time, pid, message, opts) 39 | end 40 | 41 | def read_timer(ref) do 42 | backend().read_timer(ref) 43 | end 44 | 45 | def cancel_timer(ref) do 46 | backend().cancel_timer(ref) 47 | end 48 | 49 | def cancel_timer(ref, opts) do 50 | backend().cancel_timer(ref, opts) 51 | end 52 | 53 | def system_time(unit) do 54 | backend().system_time(unit) 55 | end 56 | 57 | def system_time() do 58 | backend().system_time() 59 | end 60 | 61 | def time_offset(unit) do 62 | backend().time_offset(unit) 63 | end 64 | 65 | def time_offset() do 66 | backend().time_offset() 67 | end 68 | 69 | def set_backend(backend) when backend == Klotho.Mock or backend == Klotho.Real do 70 | :persistent_term.put(:klotho_backend, backend) 71 | end 72 | 73 | defp backend() do 74 | :persistent_term.get(:klotho_backend, @backend) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/klotho/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Klotho.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | @impl true 7 | def start(_type, _args) do 8 | children = [ 9 | {Klotho.Mock, :running} 10 | ] 11 | 12 | opts = [strategy: :one_for_one, name: Klotho.Supervisor] 13 | Supervisor.start_link(children, opts) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/klotho/mock.ex: -------------------------------------------------------------------------------- 1 | defmodule Klotho.Mock do 2 | @moduledoc """ 3 | This is the backend is used in `:test` environment. 4 | 5 | The module gives control of the mocked time and provides inspection 6 | of triggered timers. 7 | """ 8 | 9 | @server __MODULE__ 10 | @behaviour :gen_statem 11 | 12 | @doc false 13 | defguard is_non_negative_integer(x) when is_integer(x) and x >= 0 14 | 15 | @doc false 16 | defguard is_reciever(x) when is_pid(x) or is_atom(x) 17 | 18 | defmodule TimerMsg do 19 | @moduledoc """ 20 | Struct to represent a timer message. 21 | """ 22 | defstruct [:pid, :time, :message, :ref, :type] 23 | 24 | @type t :: %__MODULE__{ 25 | pid: pid(), 26 | time: non_neg_integer(), 27 | message: term(), 28 | ref: reference(), 29 | type: :send_after | :start_timer 30 | } 31 | end 32 | 33 | # to have `%Data{}` for `:gen_statem`'s `Data` 34 | alias __MODULE__, as: Data 35 | 36 | defstruct time: 0, 37 | unfreeze_time: 0, 38 | timer_messages: [], 39 | timer_message_history: [], 40 | start_time: 0, 41 | start_system_time: 0 42 | 43 | # Internal API 44 | 45 | @doc false 46 | def start_link(state) when state == :running or state == :frozen do 47 | :gen_statem.start_link({:local, @server}, __MODULE__, [state], []) 48 | end 49 | 50 | @doc false 51 | def child_spec(opts) do 52 | %{ 53 | id: __MODULE__, 54 | start: {__MODULE__, :start_link, [opts]}, 55 | type: :worker, 56 | restart: :permanent, 57 | shutdown: 500 58 | } 59 | end 60 | 61 | # External API 62 | 63 | @doc """ 64 | Warp time forward by `timer_interval` milliseconds. 65 | Regardless of the current mode (frozen or running), all the timers 66 | that are due to trigger within the next `timer_interval` milliseconds 67 | will be triggered after warp. 68 | """ 69 | 70 | @spec warp_by(non_neg_integer) :: :ok 71 | def warp_by(timer_interval) do 72 | warp_by(timer_interval, :millisecond) 73 | end 74 | 75 | @doc """ 76 | Warp time forward by `timer_interval` in `unit`. 77 | Regardless of the current mode (frozen or running), all the timers 78 | that are due to trigger within the next `timer_interval` milliseconds 79 | will be triggered after warp. 80 | """ 81 | 82 | @spec warp_by(non_neg_integer, :erlang.time_unit()) :: :ok 83 | def warp_by(timer_interval, unit) when timer_interval > 0 do 84 | # We send timer messages from the caller process (usually a test process) 85 | # To garantee that they are delivered to other prcesses before any subsequent calls 86 | # from the test process to them. 87 | msgs = :gen_statem.call(@server, {:warp_by, timer_interval, unit}) 88 | 89 | Enum.each(msgs, fn msg -> 90 | send(msg.pid, make_message(msg)) 91 | end) 92 | end 93 | 94 | @doc """ 95 | Return history of all messages (`%Klotho.Mock.TimerMsg{}`) sent by triggered timers. 96 | Most recent messages are first. 97 | """ 98 | 99 | @spec timer_event_history() :: [TimerMsg.t()] 100 | def timer_event_history() do 101 | :gen_statem.call(@server, :history) 102 | end 103 | 104 | @doc """ 105 | Freeze time. In `frozen` mode, all calls to `monotonic_time` and `system_time` 106 | will return the same value. Timers will be not triggered unless warp is called. 107 | The function is idempotent. 108 | """ 109 | 110 | @spec freeze() :: :ok 111 | def freeze() do 112 | :gen_statem.call(@server, :freeze) 113 | end 114 | 115 | @doc """ 116 | Unreeze time. In `running` mode, all calls to `monotonic_time` and `system_time` will 117 | start produce values increasing with "normal" monotonic time pace. 118 | Timers will be triggered according to their schedule. 119 | """ 120 | 121 | @spec unfreeze() :: :ok 122 | def unfreeze() do 123 | :gen_statem.call(@server, :unfreeze) 124 | end 125 | 126 | @doc """ 127 | Reset the state of the time server. This 128 | * resets the time to the actual current time; 129 | * cleans timer history; 130 | * resets the mode to `running`; 131 | * cancels all timers. 132 | """ 133 | 134 | @spec reset() :: :ok 135 | def reset() do 136 | :ok = Supervisor.terminate_child(Klotho.Supervisor, __MODULE__) 137 | {:ok, _} = Supervisor.restart_child(Klotho.Supervisor, __MODULE__) 138 | :ok 139 | end 140 | 141 | # Mocked Time-related Functions 142 | 143 | @doc false 144 | def monotonic_time(unit) do 145 | :erlang.convert_time_unit(monotonic_time(), :native, unit) 146 | end 147 | 148 | @doc false 149 | def monotonic_time() do 150 | :gen_statem.call(@server, :monotonic_time) 151 | end 152 | 153 | @default_send_after_opts %{abs: false} 154 | 155 | @doc false 156 | def send_after(time, pid, message) when is_non_negative_integer(time) and is_reciever(pid) do 157 | :gen_statem.call( 158 | @server, 159 | {:create_timer, {:send_after, time, pid, message, @default_send_after_opts}} 160 | ) 161 | end 162 | 163 | @doc false 164 | def send_after(time, pid, message, opts) when is_reciever(pid) do 165 | opts = timer_opts(@default_send_after_opts, opts) 166 | 167 | if not opts[:abs] and time <= 0 do 168 | raise ArgumentError 169 | end 170 | 171 | :gen_statem.call(@server, {:create_timer, {:send_after, time, pid, message, opts}}) 172 | end 173 | 174 | @default_start_timer_opts %{abs: false} 175 | 176 | @doc false 177 | def start_timer(time, pid, message) when is_non_negative_integer(time) and is_reciever(pid) do 178 | :gen_statem.call( 179 | @server, 180 | {:create_timer, {:start_timer, time, pid, message, @default_start_timer_opts}} 181 | ) 182 | end 183 | 184 | @doc false 185 | def start_timer(time, pid, message, opts) 186 | when is_reciever(pid) do 187 | opts = timer_opts(@default_start_timer_opts, opts) 188 | 189 | if not opts[:abs] and time <= 0 do 190 | raise ArgumentError 191 | end 192 | 193 | :gen_statem.call(@server, {:create_timer, {:start_timer, time, pid, message, opts}}) 194 | end 195 | 196 | @doc false 197 | def read_timer(ref) when is_reference(ref) do 198 | :gen_statem.call(@server, {:read_timer, ref}) 199 | end 200 | 201 | @default_cancel_timer_opts %{info: true, async: false} 202 | 203 | @doc false 204 | def cancel_timer(ref) when is_reference(ref) do 205 | :gen_statem.call(@server, {:cancel_timer, {self(), ref}, @default_cancel_timer_opts}) 206 | end 207 | 208 | @doc false 209 | def cancel_timer(ref, opts) when is_reference(ref) do 210 | opts = timer_opts(@default_cancel_timer_opts, opts) 211 | :gen_statem.call(@server, {:cancel_timer, {self(), ref}, opts}) 212 | end 213 | 214 | @doc false 215 | def system_time() do 216 | :gen_statem.call(@server, :system_time) 217 | end 218 | 219 | @doc false 220 | def system_time(unit) do 221 | :erlang.convert_time_unit(system_time(), :native, unit) 222 | end 223 | 224 | @doc false 225 | def time_offset() do 226 | :gen_statem.call(@server, :time_offset) 227 | end 228 | 229 | @doc false 230 | def time_offset(unit) do 231 | :erlang.convert_time_unit(time_offset(), :native, unit) 232 | end 233 | 234 | # :gen_statem callbacks 235 | 236 | @doc false 237 | def callback_mode(), do: :state_functions 238 | 239 | @doc false 240 | def init([:frozen]) do 241 | Klotho.set_backend(__MODULE__) 242 | 243 | time = :erlang.monotonic_time() 244 | 245 | {:ok, :frozen, 246 | %Data{ 247 | time: time, 248 | start_time: time, 249 | start_system_time: :erlang.system_time() 250 | }} 251 | end 252 | 253 | @doc false 254 | def init([:running]) do 255 | Klotho.set_backend(__MODULE__) 256 | 257 | time = :erlang.monotonic_time() 258 | 259 | {:ok, :running, 260 | %Data{ 261 | time: time, 262 | unfreeze_time: time, 263 | start_time: time, 264 | start_system_time: :erlang.system_time() 265 | }} 266 | end 267 | 268 | # States: Running, Frozen, Rescheduling 269 | 270 | ## Frozen state 271 | 272 | @doc false 273 | def frozen({:call, from}, {:cancel_timer, {_pid, ref} = args, opts}, data) do 274 | {maybe_msg, new_messages} = take_msg(ref, data.timer_messages) 275 | 276 | new_data = %Data{ 277 | data 278 | | timer_messages: new_messages 279 | } 280 | 281 | {:keep_state, new_data, 282 | [{:reply, from, cancel_response(maybe_msg, :frozen, data, args, opts)}]} 283 | end 284 | 285 | def frozen({:call, from}, {:read_timer, ref}, data) do 286 | maybe_msg = find_msg(ref, data.timer_messages) 287 | 288 | {:keep_state_and_data, [{:reply, from, time_left(maybe_msg, :frozen, data)}]} 289 | end 290 | 291 | def frozen({:call, from}, :monotonic_time, data) do 292 | {:keep_state_and_data, [{:reply, from, mocked_monotonic_time(:frozen, data)}]} 293 | end 294 | 295 | def frozen({:call, from}, :system_time, data) do 296 | system_time = mocked_monotonic_time(:frozen, data) - data.start_time + data.start_system_time 297 | {:keep_state_and_data, [{:reply, from, system_time}]} 298 | end 299 | 300 | def frozen({:call, from}, :time_offset, data) do 301 | time_offset = data.start_system_time - data.start_time 302 | {:keep_state_and_data, [{:reply, from, time_offset}]} 303 | end 304 | 305 | def frozen({:call, from}, :freeze, _data) do 306 | {:keep_state_and_data, [{:reply, from, :ok}]} 307 | end 308 | 309 | def frozen({:call, from}, :unfreeze, data) do 310 | new_data = %Data{ 311 | data 312 | | unfreeze_time: :erlang.monotonic_time() 313 | } 314 | 315 | {:next_state, :rescheduling, new_data, 316 | [{:reply, from, :ok}, {:next_event, :internal, :reschedule}]} 317 | end 318 | 319 | def frozen({:call, from}, {:create_timer, create_args}, data) do 320 | msg = make_timer_msg(:frozen, data, create_args) 321 | 322 | new_data = %Data{ 323 | data 324 | | timer_messages: insert_msg(msg, data.timer_messages) 325 | } 326 | 327 | {:keep_state, new_data, [{:reply, from, msg.ref}]} 328 | end 329 | 330 | def frozen({:call, from}, {:warp_by, timer_interval, unit}, data) do 331 | {msgs, new_data} = warp_by(:frozen, data, timer_interval, unit) 332 | {:keep_state, new_data, [{:reply, from, msgs}]} 333 | end 334 | 335 | def frozen({:call, from}, :history, data) do 336 | {:keep_state_and_data, [{:reply, from, data.timer_message_history}]} 337 | end 338 | 339 | ## Running state 340 | 341 | @doc false 342 | def running({:call, from}, {:cancel_timer, {_pid, ref} = args, opts}, data) do 343 | {maybe_msg, new_messages} = take_msg(ref, data.timer_messages) 344 | 345 | new_data = %Data{ 346 | data 347 | | timer_messages: new_messages 348 | } 349 | 350 | {:next_state, :rescheduling, new_data, 351 | [ 352 | {:reply, from, cancel_response(maybe_msg, :running, data, args, opts)}, 353 | {:next_event, :internal, :reschedule} 354 | ]} 355 | end 356 | 357 | def running({:call, from}, {:read_timer, ref}, data) do 358 | maybe_msg = find_msg(ref, data.timer_messages) 359 | 360 | {:keep_state_and_data, [{:reply, from, time_left(maybe_msg, :running, data)}]} 361 | end 362 | 363 | def running({:call, from}, :monotonic_time, data) do 364 | {:keep_state_and_data, [{:reply, from, mocked_monotonic_time(:running, data)}]} 365 | end 366 | 367 | def running({:call, from}, :system_time, data) do 368 | system_time = mocked_monotonic_time(:running, data) - data.start_time + data.start_system_time 369 | {:keep_state_and_data, [{:reply, from, system_time}]} 370 | end 371 | 372 | def running({:call, from}, :time_offset, data) do 373 | time_offset = data.start_system_time - data.start_time 374 | {:keep_state_and_data, [{:reply, from, time_offset}]} 375 | end 376 | 377 | def running({:call, from}, :freeze, data) do 378 | new_data = %Data{ 379 | data 380 | | time: mocked_monotonic_time(:running, data), 381 | unfreeze_time: nil 382 | } 383 | 384 | {:next_state, :frozen, new_data, [{:reply, from, :ok}]} 385 | end 386 | 387 | def running({:call, from}, :unfreeze, _data) do 388 | {:keep_state_and_data, [{:reply, from, :ok}]} 389 | end 390 | 391 | def running({:call, from}, {:create_timer, create_args}, data) do 392 | msg = make_timer_msg(:running, data, create_args) 393 | 394 | new_data = %Data{ 395 | data 396 | | timer_messages: insert_msg(msg, data.timer_messages) 397 | } 398 | 399 | {:next_state, :rescheduling, new_data, 400 | [{:reply, from, msg.ref}, {:next_event, :internal, :reschedule}]} 401 | end 402 | 403 | def running(:state_timeout, {:send_msg, ref}, data) do 404 | new_data = send_msg(data, ref) 405 | {:next_state, :rescheduling, new_data, [{:next_event, :internal, :reschedule}]} 406 | end 407 | 408 | def running({:call, from}, {:warp_by, timer_interval, unit}, data) do 409 | {msgs, new_data} = warp_by(:running, data, timer_interval, unit) 410 | 411 | {:next_state, :rescheduling, new_data, 412 | [{:reply, from, msgs}, {:next_event, :internal, :reschedule}]} 413 | end 414 | 415 | def running({:call, from}, :history, data) do 416 | {:keep_state_and_data, [{:reply, from, data.timer_message_history}]} 417 | end 418 | 419 | ## Rescheduling state 420 | 421 | @doc false 422 | def rescheduling(:internal, :reschedule, data) do 423 | new_timer_events = next_event_timer(data) 424 | {:next_state, :running, data, new_timer_events} 425 | end 426 | 427 | # Private Functions 428 | 429 | defp warp_by(state, data, timer_interval, unit) do 430 | timer_interval = :erlang.convert_time_unit(timer_interval, unit, :native) 431 | real_monotonic_time = :erlang.monotonic_time() 432 | new_time = mocked_monotonic_time(state, data, real_monotonic_time) + timer_interval 433 | 434 | {msgs, left} = Enum.split_with(data.timer_messages, fn msg -> msg.time <= new_time end) 435 | 436 | {msgs, 437 | %Data{ 438 | data 439 | | time: new_time, 440 | unfreeze_time: real_monotonic_time, 441 | timer_messages: left, 442 | timer_message_history: Enum.reverse(msgs) ++ data.timer_message_history 443 | }} 444 | end 445 | 446 | defp make_message(%TimerMsg{type: :send_after, message: message}) do 447 | message 448 | end 449 | 450 | defp make_message(%TimerMsg{type: :start_timer, message: message, ref: ref}) do 451 | {:timeout, ref, message} 452 | end 453 | 454 | defp make_timer_msg(state, data, {type, interval, pid, message, opts}) do 455 | ref = :erlang.make_ref() 456 | 457 | interval = :erlang.convert_time_unit(interval, :millisecond, :native) 458 | 459 | interval = 460 | if opts[:abs] do 461 | interval - mocked_monotonic_time(state, data) 462 | else 463 | interval 464 | end 465 | 466 | time = mocked_monotonic_time(state, data) + interval 467 | 468 | %TimerMsg{ 469 | pid: pid, 470 | time: time, 471 | message: message, 472 | ref: ref, 473 | type: type 474 | } 475 | end 476 | 477 | defp mocked_monotonic_time(state, data) do 478 | mocked_monotonic_time(state, data, :erlang.monotonic_time()) 479 | end 480 | 481 | defp mocked_monotonic_time(:frozen, data, _real_monotonic_time) do 482 | data.time 483 | end 484 | 485 | defp mocked_monotonic_time(:running, data, real_monotonic_time) do 486 | real_monotonic_time - data.unfreeze_time + data.time 487 | end 488 | 489 | defp next_event_timer(%Data{timer_messages: []}) do 490 | [] 491 | end 492 | 493 | defp next_event_timer(%Data{timer_messages: [msg | _]} = data) do 494 | interval = 495 | (msg.time - mocked_monotonic_time(:running, data)) 496 | |> to_non_negative() 497 | |> :erlang.convert_time_unit(:native, :millisecond) 498 | 499 | [{:state_timeout, interval, {:send_msg, msg.ref}}] 500 | end 501 | 502 | defp insert_msg(new_msg, []) do 503 | [new_msg] 504 | end 505 | 506 | defp insert_msg(new_msg, [msg | rest]) do 507 | if new_msg.time < msg.time do 508 | [new_msg | [msg | rest]] 509 | else 510 | [msg | insert_msg(new_msg, rest)] 511 | end 512 | end 513 | 514 | defp take_msg(ref, msgs) do 515 | case Enum.split_with(msgs, fn msg -> msg.ref == ref end) do 516 | {[], msgs} -> {nil, msgs} 517 | {[msg], msgs} -> {msg, msgs} 518 | end 519 | end 520 | 521 | defp send_msg(data, ref) do 522 | {msg, new_messages} = take_msg(ref, data.timer_messages) 523 | 524 | timer_message_history = 525 | if msg do 526 | send(msg.pid, make_message(msg)) 527 | [msg | data.timer_message_history] 528 | else 529 | data.timer_message_history 530 | end 531 | 532 | %Data{data | timer_messages: new_messages, timer_message_history: timer_message_history} 533 | end 534 | 535 | defp find_msg(ref, msgs) do 536 | Enum.find(msgs, fn msg -> msg.ref == ref end) 537 | end 538 | 539 | defp time_left(nil, _state, _data) do 540 | false 541 | end 542 | 543 | defp time_left(msg, state, data) do 544 | (msg.time - mocked_monotonic_time(state, data)) 545 | |> to_non_negative() 546 | |> :erlang.convert_time_unit(:native, :millisecond) 547 | end 548 | 549 | defp cancel_response(_msg, _state, _data, _args, %{info: false}) do 550 | :ok 551 | end 552 | 553 | defp cancel_response(msg, state, data, {pid, ref}, %{async: true, info: true}) do 554 | timer_cancel_message = {:cancel_timer, ref, time_left(msg, state, data)} 555 | send(pid, timer_cancel_message) 556 | :ok 557 | end 558 | 559 | defp cancel_response(msg, state, data, _args, %{async: false, info: true}) do 560 | time_left(msg, state, data) 561 | end 562 | 563 | defp to_non_negative(number) do 564 | if number < 0 do 565 | 0 566 | else 567 | number 568 | end 569 | end 570 | 571 | defp timer_opts(acc, []) do 572 | acc 573 | end 574 | 575 | defp timer_opts(acc, [{key, value} | rest]) when is_map_key(acc, key) and is_boolean(value) do 576 | timer_opts(Map.put(acc, key, value), rest) 577 | end 578 | 579 | defp timer_opts(_acc, _opts) do 580 | raise ArgumentError, "Invalid options" 581 | end 582 | end 583 | -------------------------------------------------------------------------------- /lib/klotho/real.ex: -------------------------------------------------------------------------------- 1 | defmodule Klotho.Real do 2 | @moduledoc false 3 | 4 | def monotonic_time(unit) do 5 | :erlang.monotonic_time(unit) 6 | end 7 | 8 | def monotonic_time() do 9 | :erlang.monotonic_time() 10 | end 11 | 12 | def send_after(time, pid, message) do 13 | :erlang.send_after(time, pid, message) 14 | end 15 | 16 | def send_after(time, pid, message, opts) do 17 | :erlang.send_after(time, pid, message, opts) 18 | end 19 | 20 | def start_timer(time, pid, message) do 21 | :erlang.start_timer(time, pid, message) 22 | end 23 | 24 | def start_timer(time, pid, message, opts) do 25 | :erlang.start_timer(time, pid, message, opts) 26 | end 27 | 28 | def read_timer(ref) do 29 | :erlang.read_timer(ref) 30 | end 31 | 32 | def cancel_timer(ref) do 33 | :erlang.cancel_timer(ref) 34 | end 35 | 36 | def cancel_timer(ref, opts) do 37 | :erlang.cancel_timer(ref, opts) 38 | end 39 | 40 | def system_time() do 41 | :erlang.system_time() 42 | end 43 | 44 | def system_time(unit) do 45 | :erlang.system_time(unit) 46 | end 47 | 48 | def time_offset() do 49 | :erlang.time_offset() 50 | end 51 | 52 | def time_offset(unit) do 53 | :erlang.time_offset(unit) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Klotho.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :klotho, 7 | description: description(), 8 | version: "0.1.2", 9 | elixir: "~> 1.12", 10 | start_permanent: Mix.env() in [:dev, :test], 11 | deps: deps(), 12 | test_coverage: [tool: ExCoveralls], 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | package: package(), 15 | preferred_cli_env: [ 16 | coveralls: :test, 17 | "coveralls.detail": :test, 18 | "coveralls.post": :test, 19 | "coveralls.html": :test 20 | ], 21 | docs: docs() 22 | ] 23 | end 24 | 25 | defp elixirc_paths(:test), do: ["lib", "test/support"] 26 | defp elixirc_paths(_), do: ["lib"] 27 | 28 | def application do 29 | [ 30 | extra_applications: [:logger], 31 | mod: {Klotho.Application, []} 32 | ] 33 | end 34 | 35 | defp deps do 36 | [ 37 | {:excoveralls, "~> 0.10", only: :test}, 38 | {:earmark, "~> 1.4", only: :dev}, 39 | {:ex_doc, "~> 0.23", only: :dev} 40 | ] 41 | end 42 | 43 | defp description do 44 | "Opinionated library for testing timer-based Elixir code" 45 | end 46 | 47 | defp package do 48 | [ 49 | name: :klotho, 50 | files: ["lib", "mix.exs", "*.md", "LICENSE"], 51 | maintainers: ["Ilya Averyanov"], 52 | licenses: ["MIT"], 53 | links: %{ 54 | "GitHub" => "https://github.com/savonarola/klotho" 55 | } 56 | ] 57 | end 58 | 59 | defp docs do 60 | [ 61 | main: "usage", 62 | extras: [ 63 | "USAGE.md", 64 | "LICENSE" 65 | ] 66 | ] 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 3 | "earmark": {:hex, :earmark, "1.4.39", "acdb2f02c536471029dbcc509fbd6b94b89f40ad7729fb3f68f4b6944843f01d", [:mix], [{:earmark_parser, "~> 1.4.33", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "156c9d8ec3cbeccdbf26216d8247bdeeacc8c76b4d9eee7554be2f1b623ea440"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.33", "3c3fd9673bb5dcc9edc28dd90f50c87ce506d1f71b70e3de69aa8154bc695d44", [:mix], [], "hexpm", "2d526833729b59b9fdb85785078697c72ac5e5066350663e5be6a1182da61b8f"}, 5 | "ex_doc": {:hex, :ex_doc, "0.30.3", "bfca4d340e3b95f2eb26e72e4890da83e2b3a5c5b0e52607333bf5017284b063", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "fbc8702046c1d25edf79de376297e608ac78cdc3a29f075484773ad1718918b6"}, 6 | "excoveralls": {:hex, :excoveralls, "0.16.1", "0bd42ed05c7d2f4d180331a20113ec537be509da31fed5c8f7047ce59ee5a7c5", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dae763468e2008cf7075a64cb1249c97cb4bc71e236c5c2b5e5cdf1cfa2bf138"}, 7 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 8 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 9 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 10 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 13 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 14 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 15 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 16 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 17 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 18 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 19 | } 20 | -------------------------------------------------------------------------------- /test/klotho_test.exs: -------------------------------------------------------------------------------- 1 | defmodule KlothoTest do 2 | use ExUnit.Case 3 | 4 | setup do 5 | Klotho.Mock.reset() 6 | end 7 | 8 | ######################################## 9 | # frozen mode 10 | ######################################## 11 | 12 | test "frozen mode: send_after/start_timer" do 13 | :ok = Klotho.Mock.freeze() 14 | Klotho.send_after(100, self(), "hello") 15 | ref = Klotho.start_timer(200, self(), "world") 16 | Klotho.Mock.warp_by(99) 17 | refute_received "hello" 18 | Klotho.Mock.warp_by(2) 19 | assert_receive "hello" 20 | refute_received {:timeout, ^ref, "world"} 21 | Klotho.Mock.warp_by(100) 22 | assert_receive {:timeout, ^ref, "world"} 23 | end 24 | 25 | test "frozen mode: monotonic_time" do 26 | :ok = Klotho.Mock.freeze() 27 | time = Klotho.monotonic_time(:millisecond) 28 | Klotho.Mock.warp_by(100) 29 | assert Klotho.monotonic_time(:millisecond) == time + 100 30 | end 31 | 32 | test "frozen mode: cancel timer" do 33 | :ok = Klotho.Mock.freeze() 34 | _ref0 = Klotho.send_after(100, self(), "hello") 35 | ref1 = Klotho.send_after(200, self(), "world") 36 | _ref2 = Klotho.send_after(300, self(), "!!") 37 | Klotho.cancel_timer(ref1) 38 | Klotho.Mock.warp_by(101) 39 | assert_receive "hello" 40 | Klotho.Mock.warp_by(200) 41 | refute_receive "world" 42 | assert_receive "!!" 43 | end 44 | 45 | test "frozen mode: unfreeze" do 46 | :ok = Klotho.Mock.freeze() 47 | t1 = Klotho.monotonic_time() 48 | :timer.sleep(1) 49 | t2 = Klotho.monotonic_time() 50 | Klotho.Mock.unfreeze() 51 | :timer.sleep(1) 52 | t3 = Klotho.monotonic_time() 53 | 54 | assert t1 == t2 55 | assert t2 < t3 56 | end 57 | 58 | test "frozen mode: freeze" do 59 | :ok = Klotho.Mock.freeze() 60 | assert :ok == Klotho.Mock.freeze() 61 | end 62 | 63 | test "frozen mode: cancel ref" do 64 | :ok = Klotho.Mock.freeze() 65 | assert false == Klotho.cancel_timer(make_ref()) 66 | 67 | ref = Klotho.send_after(100, self(), "test") 68 | Klotho.Mock.warp_by(50) 69 | 70 | left = Klotho.cancel_timer(ref) 71 | assert is_integer(left) 72 | assert left <= 50 73 | 74 | Klotho.Mock.warp_by(50) 75 | 76 | refute_receive "test" 77 | end 78 | 79 | test "frozen mode: read ref" do 80 | :ok = Klotho.Mock.freeze() 81 | assert false == Klotho.cancel_timer(make_ref()) 82 | 83 | ref = Klotho.send_after(100, self(), "test") 84 | Klotho.Mock.warp_by(50) 85 | 86 | left = Klotho.read_timer(ref) 87 | assert is_integer(left) 88 | assert left <= 50 89 | 90 | Klotho.Mock.warp_by(50) 91 | 92 | assert_receive "test" 93 | end 94 | 95 | test "frozen mode: system_time" do 96 | :ok = Klotho.Mock.freeze() 97 | t0 = Klotho.system_time(:millisecond) 98 | 99 | Klotho.Mock.warp_by(1000) 100 | t1 = :erlang.convert_time_unit(Klotho.system_time(), :native, :millisecond) 101 | 102 | assert t1 - t0 >= 1000 103 | end 104 | 105 | test "frozen mode: time_offset" do 106 | :ok = Klotho.Mock.freeze() 107 | 108 | assert_in_delta Klotho.monotonic_time(:millisecond) + Klotho.time_offset(:millisecond), 109 | Klotho.system_time(:millisecond), 110 | 10 111 | 112 | Klotho.Mock.warp_by(1000) 113 | 114 | assert_in_delta Klotho.monotonic_time(:millisecond) + 115 | :erlang.convert_time_unit(Klotho.time_offset(), :native, :millisecond), 116 | Klotho.system_time(:millisecond), 117 | 10 118 | end 119 | 120 | test "frozen mode: send_ater/start_timer absolute" do 121 | :ok = Klotho.Mock.freeze() 122 | t = Klotho.monotonic_time(:millisecond) 123 | Klotho.send_after(t + 100, self(), "hello", abs: true) 124 | ref = Klotho.start_timer(t + 200, self(), "world", abs: true) 125 | Klotho.Mock.warp_by(99) 126 | refute_received "hello" 127 | Klotho.Mock.warp_by(2) 128 | assert_receive "hello" 129 | refute_received {:timeout, ^ref, "world"} 130 | Klotho.Mock.warp_by(100) 131 | assert_receive {:timeout, ^ref, "world"} 132 | end 133 | 134 | test "frozen: cancel" do 135 | :ok = Klotho.Mock.freeze() 136 | ref0 = Klotho.send_after(50, self(), "hello") 137 | ref1 = Klotho.send_after(150, self(), "whole") 138 | ref2 = Klotho.send_after(250, self(), "world") 139 | ref3 = Klotho.send_after(300, self(), "!!") 140 | ref4 = make_ref() 141 | 142 | assert :ok == Klotho.cancel_timer(ref0, info: false, async: true) 143 | assert :ok == Klotho.cancel_timer(ref1, info: false, async: false) 144 | assert_in_delta 250, Klotho.cancel_timer(ref2, info: true, async: false), 20 145 | Klotho.cancel_timer(ref3, info: true, async: true) 146 | Klotho.cancel_timer(ref4, info: true, async: true) 147 | 148 | Klotho.Mock.warp_by(300) 149 | refute_receive "!!" 150 | refute_receive "world" 151 | refute_receive "whole" 152 | refute_receive "hello" 153 | assert_receive {:cancel_timer, ^ref3, _} 154 | assert_receive {:cancel_timer, ^ref4, false} 155 | end 156 | 157 | ######################################################## 158 | # running mode 159 | ######################################################## 160 | 161 | test "running mode: monotonic_time" do 162 | time = Klotho.monotonic_time(:millisecond) 163 | :timer.sleep(5) 164 | assert Klotho.monotonic_time(:millisecond) >= time + 5 165 | end 166 | 167 | test "running mode: warp_by" do 168 | time = Klotho.monotonic_time(:millisecond) 169 | Klotho.Mock.warp_by(100) 170 | assert Klotho.monotonic_time(:millisecond) >= time + 100 171 | end 172 | 173 | test "running mode: send_after/start_timer" do 174 | Klotho.send_after(50, self(), "hello") 175 | ref = Klotho.start_timer(150, self(), "world") 176 | Klotho.send_after(250, self(), "!!") 177 | 178 | t1 = Klotho.monotonic_time(:millisecond) 179 | 180 | :timer.sleep(100) 181 | # because time is running 182 | assert_receive "hello" 183 | refute_received {:timeout, ^ref, "world"} 184 | 185 | Klotho.Mock.warp_by(100) 186 | # because time is warped by 100ms, 200ms now "passed" 187 | assert_receive {:timeout, ^ref, "world"} 188 | refute_received "!!" 189 | 190 | :timer.sleep(100) 191 | 192 | # because time is warped by 100ms, 300ms now "passed" 193 | assert_receive "!!" 194 | 195 | t2 = Klotho.monotonic_time(:millisecond) 196 | 197 | assert t2 >= t1 + 300 198 | end 199 | 200 | test "running: cancel" do 201 | ref0 = Klotho.send_after(50, self(), "hello") 202 | ref1 = Klotho.send_after(150, self(), "whole") 203 | ref2 = Klotho.send_after(250, self(), "world") 204 | ref3 = Klotho.send_after(300, self(), "!!") 205 | ref4 = make_ref() 206 | 207 | assert :ok == Klotho.cancel_timer(ref0, info: false, async: true) 208 | assert :ok == Klotho.cancel_timer(ref1, info: false, async: false) 209 | assert_in_delta 250, Klotho.cancel_timer(ref2, info: true, async: false), 20 210 | Klotho.cancel_timer(ref3, info: true, async: true) 211 | Klotho.cancel_timer(ref4, info: true, async: true) 212 | 213 | Klotho.Mock.warp_by(300) 214 | refute_receive "!!" 215 | refute_receive "world" 216 | refute_receive "whole" 217 | refute_receive "hello" 218 | assert_receive {:cancel_timer, ^ref3, _} 219 | assert_receive {:cancel_timer, ^ref4, false} 220 | end 221 | 222 | test "running mode: freeze/unfreeze" do 223 | t1 = Klotho.monotonic_time() 224 | :timer.sleep(1) 225 | # unfreeze is a noop in running mode 226 | assert :ok == Klotho.Mock.unfreeze() 227 | Klotho.Mock.freeze() 228 | t2 = Klotho.monotonic_time() 229 | :timer.sleep(1) 230 | t3 = Klotho.monotonic_time() 231 | Klotho.Mock.unfreeze() 232 | :timer.sleep(1) 233 | t4 = Klotho.monotonic_time() 234 | 235 | assert t1 < t2 236 | assert t2 == t3 237 | assert t3 < t4 238 | end 239 | 240 | test "running mode: cancel ref" do 241 | assert false == Klotho.cancel_timer(make_ref()) 242 | 243 | ref = Klotho.send_after(100, self(), "test") 244 | Klotho.Mock.warp_by(50) 245 | 246 | left = Klotho.cancel_timer(ref) 247 | assert is_integer(left) 248 | assert left <= 50 249 | 250 | Klotho.Mock.warp_by(50) 251 | 252 | refute_receive "test" 253 | end 254 | 255 | test "running mode: read ref" do 256 | assert false == Klotho.cancel_timer(make_ref()) 257 | 258 | ref = Klotho.send_after(100, self(), "test") 259 | Klotho.Mock.warp_by(50) 260 | 261 | left = Klotho.read_timer(ref) 262 | assert is_integer(left) 263 | assert left <= 50 264 | 265 | Klotho.Mock.warp_by(50) 266 | 267 | assert_receive "test" 268 | end 269 | 270 | test "running mode: system_time" do 271 | t0 = Klotho.system_time(:millisecond) 272 | 273 | Klotho.Mock.warp_by(500) 274 | :timer.sleep(100) 275 | t1 = :erlang.convert_time_unit(Klotho.system_time(), :native, :millisecond) 276 | 277 | assert t1 - t0 >= 600 278 | end 279 | 280 | test "running mode: time_offset" do 281 | assert_in_delta Klotho.monotonic_time(:millisecond) + Klotho.time_offset(:millisecond), 282 | Klotho.system_time(:millisecond), 283 | 10 284 | 285 | Klotho.Mock.warp_by(500) 286 | :timer.sleep(100) 287 | 288 | assert_in_delta Klotho.monotonic_time(:millisecond) + 289 | :erlang.convert_time_unit(Klotho.time_offset(), :native, :millisecond), 290 | Klotho.system_time(:millisecond), 291 | 10 292 | end 293 | 294 | test "running mode: send_after/start_timer absolute" do 295 | t = Klotho.monotonic_time(:millisecond) 296 | Klotho.send_after(t + 50, self(), "hello", abs: true) 297 | ref = Klotho.start_timer(t + 150, self(), "world", abs: true) 298 | Klotho.send_after(t + 250, self(), "!!", abs: true) 299 | 300 | t1 = Klotho.monotonic_time(:millisecond) 301 | 302 | :timer.sleep(100) 303 | # because time is running 304 | assert_receive "hello" 305 | refute_received {:timeout, ^ref, "world"} 306 | 307 | Klotho.Mock.warp_by(100) 308 | # because time is warped by 100ms, 200ms now "passed" 309 | assert_receive {:timeout, ^ref, "world"} 310 | refute_received "!!" 311 | 312 | :timer.sleep(100) 313 | 314 | # because time is warped by 100ms, 300ms now "passed" 315 | assert_receive "!!" 316 | 317 | t2 = Klotho.monotonic_time(:millisecond) 318 | 319 | assert t2 >= t1 + 300 320 | end 321 | 322 | ###################################################################### 323 | # common tests 324 | ###################################################################### 325 | 326 | test "real implementation" do 327 | t1 = Klotho.Real.monotonic_time(:millisecond) 328 | :timer.sleep(1) 329 | t2 = Klotho.Real.monotonic_time(:millisecond) 330 | assert t1 < t2 331 | 332 | t3 = Klotho.Real.monotonic_time() 333 | :timer.sleep(1) 334 | t4 = Klotho.Real.monotonic_time() 335 | assert t3 < t4 336 | 337 | ref = Klotho.Real.send_after(100, self(), "hello") 338 | assert Klotho.Real.read_timer(ref) <= 100 339 | assert Klotho.Real.cancel_timer(ref) <= 100 340 | 341 | Klotho.Real.send_after(0, self(), "world") 342 | assert_receive "world" 343 | 344 | Klotho.Real.send_after(:erlang.monotonic_time(:millisecond), self(), "world abs", abs: true) 345 | assert_receive "world abs" 346 | 347 | ref = Klotho.Real.start_timer(0, self(), "!!") 348 | assert_receive {:timeout, ^ref, "!!"} 349 | 350 | ref = 351 | Klotho.Real.start_timer(:erlang.monotonic_time(:millisecond), self(), "!! abs", abs: true) 352 | 353 | assert_receive {:timeout, ^ref, "!! abs"} 354 | 355 | t1 = Klotho.Real.system_time(:millisecond) 356 | :timer.sleep(1) 357 | t2 = Klotho.Real.system_time(:millisecond) 358 | assert t1 < t2 359 | 360 | t3 = Klotho.Real.system_time() 361 | :timer.sleep(1) 362 | t4 = Klotho.Real.system_time() 363 | assert t3 < t4 364 | 365 | assert_in_delta Klotho.Real.monotonic_time(:millisecond) + 366 | :erlang.convert_time_unit(Klotho.Real.time_offset(), :native, :millisecond), 367 | Klotho.Real.system_time(:millisecond), 368 | 10 369 | 370 | assert_in_delta Klotho.Real.monotonic_time(:millisecond) + 371 | Klotho.Real.time_offset(:millisecond), 372 | Klotho.Real.system_time(:millisecond), 373 | 10 374 | end 375 | 376 | test "history" do 377 | Klotho.send_after(50, self(), "hello") 378 | Klotho.start_timer(150, self(), "world") 379 | 380 | Klotho.Mock.warp_by(200) 381 | 382 | history = Klotho.Mock.timer_event_history() 383 | 384 | assert [ 385 | %Klotho.Mock.TimerMsg{message: "world", type: :start_timer}, 386 | %Klotho.Mock.TimerMsg{message: "hello", type: :send_after} 387 | ] = history 388 | 389 | Klotho.Mock.reset() 390 | 391 | assert [] = Klotho.Mock.timer_event_history() 392 | end 393 | end 394 | -------------------------------------------------------------------------------- /test/klotho_timeout_cache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Klotho.TimeoutCacheTest.Timer do 2 | use ExUnit.Case 3 | 4 | alias Klotho.Support.TimeoutCache 5 | 6 | setup do 7 | {:ok, pid} = TimeoutCache.start_link() 8 | Klotho.Mock.reset() 9 | {:ok, pid: pid} 10 | end 11 | 12 | test "set and get", %{pid: pid} do 13 | TimeoutCache.set(pid, :foo, :bar, 100) 14 | assert {:ok, :bar} == TimeoutCache.get(pid, :foo) 15 | end 16 | 17 | test "get after timeout", %{pid: pid} do 18 | TimeoutCache.set(pid, :foo, :bar, 100) 19 | :timer.sleep(200) 20 | assert :not_found == TimeoutCache.get(pid, :foo) 21 | end 22 | 23 | test "renew lifetime", %{pid: pid} do 24 | TimeoutCache.set(pid, :foo, :bar1, 100) 25 | :timer.sleep(50) 26 | TimeoutCache.set(pid, :foo, :bar2, 100) 27 | :timer.sleep(70) 28 | assert {:ok, :bar2} == TimeoutCache.get(pid, :foo) 29 | end 30 | end 31 | 32 | defmodule Klotho.TimeoutCacheTest.Klotho do 33 | use ExUnit.Case 34 | 35 | alias Klotho.Support.TimeoutCache 36 | 37 | setup do 38 | {:ok, pid} = TimeoutCache.start_link() 39 | Klotho.Mock.reset() 40 | Klotho.Mock.freeze() 41 | {:ok, pid: pid} 42 | end 43 | 44 | test "set and get", %{pid: pid} do 45 | TimeoutCache.set(pid, :foo, :bar, 1000) 46 | assert {:ok, :bar} == TimeoutCache.get(pid, :foo) 47 | end 48 | 49 | test "get after timeout", %{pid: pid} do 50 | TimeoutCache.set(pid, :foo, :bar, 1000) 51 | Klotho.Mock.warp_by(2000) 52 | assert :not_found == TimeoutCache.get(pid, :foo) 53 | end 54 | 55 | test "renew lifetime", %{pid: pid} do 56 | TimeoutCache.set(pid, :foo, :bar1, 1000) 57 | Klotho.Mock.warp_by(500) 58 | TimeoutCache.set(pid, :foo, :bar2, 1000) 59 | Klotho.Mock.warp_by(700) 60 | assert {:ok, :bar2} == TimeoutCache.get(pid, :foo) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/support/timeout_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule Klotho.Support.TimeoutCache do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | # API 7 | 8 | def start_link() do 9 | GenServer.start_link(__MODULE__, []) 10 | end 11 | 12 | def set(pid, key, value, timeout) do 13 | GenServer.call(pid, {:set, key, value, timeout}) 14 | end 15 | 16 | def get(pid, key) do 17 | GenServer.call(pid, {:get, key}) 18 | end 19 | 20 | # gen_server 21 | 22 | def init([]) do 23 | {:ok, %{}} 24 | end 25 | 26 | def handle_call({:set, key, value, timeout}, _from, state) do 27 | new_st = 28 | state 29 | |> maybe_delete(key) 30 | |> put_new(key, value, timeout) 31 | 32 | {:reply, :ok, new_st} 33 | end 34 | 35 | def handle_call({:get, key}, _from, state) do 36 | {:reply, get_value(state, key), state} 37 | end 38 | 39 | def handle_info({:timeout, ref, key}, state) do 40 | new_st = maybe_delete_timeout(state, key, ref) 41 | {:noreply, new_st} 42 | end 43 | 44 | # private 45 | 46 | defp maybe_delete(state, key) do 47 | case state do 48 | %{^key => {_value, ref}} -> 49 | Klotho.cancel_timer(ref) 50 | Map.delete(state, key) 51 | 52 | _ -> 53 | state 54 | end 55 | end 56 | 57 | defp put_new(state, key, value, timeout) do 58 | ref = Klotho.start_timer(timeout, self(), key) 59 | Map.put(state, key, {value, ref}) 60 | end 61 | 62 | defp maybe_delete_timeout(state, key, ref) do 63 | case state do 64 | %{^key => {_value, ^ref}} -> 65 | Map.delete(state, key) 66 | 67 | _ -> 68 | state 69 | end 70 | end 71 | 72 | defp get_value(state, key) do 73 | case state do 74 | %{^key => {value, _ref}} -> 75 | {:ok, value} 76 | 77 | _ -> 78 | :not_found 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------