diff --git a/src/meck.erl b/src/meck.erl index 9a3f835..1ec8ad1 100644 --- a/src/meck.erl +++ b/src/meck.erl @@ -130,6 +130,11 @@ %% It is used in {@link expect/3} and {@link expect/4} to define a function %% clause of complex multi-clause expectations. +-type condition_state() :: term(). +-type condition_fun() :: fun((Args :: [term()], condition_state()) -> condition_state()). +-type condition() :: {condition_fun(), condition_state()}. +%% It is used in {@link wait/5} and {@link wait/6} to define a condition. + %%%============================================================================ %%% Interface exports %%%============================================================================ @@ -545,7 +550,7 @@ num_calls(Mod, OptFun, OptArgsSpec, OptPid) -> %% arguments matching `OptArgsSpec', or `Timeout' has elapsed. In the latter %% case the call fails with `error:timeout'. %% -%% The number of calls is counted starting from the most resent call to +%% The number of calls is counted starting from the most recent call to %% {@link reset/1} on the mock or from the mock creation, whichever occurred %% latter. If a matching call has already occurred, then the function returns %% `ok' immediately. @@ -559,35 +564,66 @@ num_calls(Mod, OptFun, OptArgsSpec, OptPid) -> wait(Mod, OptFunc, OptArgsSpec, Timeout) -> wait(1, Mod, OptFunc, OptArgsSpec, '_', Timeout). -%% @doc Blocks until either function `Mod:Func' is called at least `Times' with -%% arguments matching `OptArgsSpec', or `Timeout' has elapsed. In the latter -%% case the call fails with `error:timeout'. +%% @doc Blocks until function `Mod:Func' is called with arguments matching +%% `OptArgsSpec' and matching the `Condition`. +%% +%% Times out if instead `Timeout' has elapsed. In this case the call fails with +%% `error:timeout'. +%% +%% `Condition` is a tuple: `{CondFunc, CondState}`. +%% +%% On each call to `Mod:Func`, `CondFunc(Args, CondState)` is called. +%% +%% The condition function must return either `{halt, ok}` or +%% `{cont, NextCondState}`. +%% +%% In the former case, the condition is considered satisfied, and `wait` +%% returns. In the latter case, `wait` waits for further calls to `Mod:Func`. +%% +%% For backwards-compatibility, the condition can be specified as an integer. +%% This counts the number of calls to the function. %% -%% The number of calls is counted starting from the most resent call to +%% The number of calls is counted starting from the most recent call to %% {@link reset/1} on the mock or from the mock creation, whichever occurred %% latter. If `Times' number of matching calls has already occurred, then the %% function returns `ok' immediately. %% -%% @equiv wait(Times, Mod, OptFunc, OptArgsSpec, '_', Timeout) --spec wait(Times, Mod, OptFunc, OptArgsSpec, Timeout) -> ok when - Times :: pos_integer(), +%% @equiv wait(Condition, Mod, OptFunc, OptArgsSpec, '_', Timeout) +-spec wait(Condition, Mod, OptFunc, OptArgsSpec, Timeout) -> ok when + Condition :: pos_integer() | condition(), Mod :: atom(), OptFunc :: '_' | atom(), OptArgsSpec :: '_' | args_spec(), Timeout :: non_neg_integer(). -wait(Times, Mod, OptFunc, OptArgsSpec, Timeout) -> - wait(Times, Mod, OptFunc, OptArgsSpec, '_', Timeout). +wait(Condition, Mod, OptFunc, OptArgsSpec, Timeout) -> + wait(Condition, Mod, OptFunc, OptArgsSpec, '_', Timeout). -%% @doc Blocks until either function `Mod:Func' is called at least `Times' with -%% arguments matching `OptArgsSpec' by process `OptCallerPid', or `Timeout' has -%% elapsed. In the latter case the call fails with `error:timeout'. + +%% @doc Blocks until function `Mod:Func' is called with arguments matching +%% `OptArgsSpec' and matching the `Condition`. +%% +%% Times out if instead `Timeout' has elapsed. In this case the call fails with +%% `error:timeout'. +%% +%% `Condition` is a tuple: `{CondFunc, CondState}`. +%% +%% On each call to `Mod:Func`, `CondFunc(Args, CondState)` is called. +%% +%% The condition function must return either `{halt, ok}` or +%% `{cont, NextCondState}`. %% -%% The number of calls is counted starting from the most resent call to +%% In the former case, the condition is considered satisfied, and `wait` +%% returns. In the latter case, `wait` waits for further calls to `Mod:Func`. +%% +%% For backwards-compatibility, the condition can be specified as an integer. +%% This counts the number of calls to the function. +%% +%% The number of calls is counted starting from the most recent call to %% {@link reset/1} on the mock or from the mock creation, whichever occurred -%% latter. If `Times' number of matching call has already occurred, then the +%% latter. If `Times' number of matching calls has already occurred, then the %% function returns `ok' immediately. --spec wait(Times, Mod, OptFunc, OptArgsSpec, OptCallerPid, Timeout) -> ok when - Times :: pos_integer(), +-spec wait(Condition, Mod, OptFunc, OptArgsSpec, OptCallerPid, Timeout) -> ok when + Condition :: pos_integer() | condition(), Mod :: atom(), OptFunc :: '_' | atom(), OptArgsSpec :: '_' | args_spec(), @@ -599,7 +635,18 @@ wait(Times, Mod, OptFunc, OptArgsSpec, OptCallerPid, Timeout) when is_integer(Times) andalso Times > 0 andalso is_integer(Timeout) andalso Timeout >= 0 -> ArgsMatcher = meck_args_matcher:new(OptArgsSpec), - meck_proc:wait(Mod, Times, OptFunc, ArgsMatcher, OptCallerPid, Timeout). + Condition = { + fun + (_, T) when T =:= 0 -> {halt, ok}; + (_, T) -> {cont, T - 1} + end, + Times - 1 + }, + meck_proc:wait(Mod, Condition, OptFunc, ArgsMatcher, OptCallerPid, Timeout); +wait({CondFun, _} = Condition, Mod, OptFunc, OptArgsSpec, OptCallerPid, Timeout) + when is_function(CondFun, 2) -> + ArgsMatcher = meck_args_matcher:new(OptArgsSpec), + meck_proc:wait(Mod, Condition, OptFunc, ArgsMatcher, OptCallerPid, Timeout). %% @doc Erases the call history for a mocked module or a list of mocked modules. %% diff --git a/src/meck_proc.erl b/src/meck_proc.erl index 80bbf81..74d0d79 100644 --- a/src/meck_proc.erl +++ b/src/meck_proc.erl @@ -48,6 +48,10 @@ %%% Definitions %%%============================================================================ +-type condition_state() :: term(). +-type condition_fun() :: fun((Args :: [term()], condition_state()) -> condition_state()). +-type condition() :: {condition_fun(), condition_state()}. + -type meck_dict() :: dict:dict(). -record(state, {mod :: atom(), @@ -67,8 +71,8 @@ -record(tracker, {opt_func :: '_' | atom(), args_matcher :: meck_args_matcher:args_matcher(), opt_caller_pid :: '_' | pid(), - countdown :: non_neg_integer(), - reply_to :: {Caller::pid(), Tag::any()}, + condition :: condition(), + reply_to :: gen_server:from(), expire_at :: erlang:timestamp()}). %%%============================================================================ @@ -151,13 +155,13 @@ get_history(Mod) -> gen_server(call, Mod, get_history). -spec wait(Mod::atom(), - Times::non_neg_integer(), + Condition::condition(), OptFunc::'_' | atom(), meck_args_matcher:args_matcher(), OptCallerPid::'_' | pid(), Timeout::non_neg_integer()) -> ok. -wait(Mod, Times, OptFunc, ArgsMatcher, OptCallerPid, Timeout) -> +wait(Mod, {_CondFun, _CondSt} = Condition, OptFunc, ArgsMatcher, OptCallerPid, Timeout) -> EffectiveTimeout = case Timeout of 0 -> infinity; @@ -165,7 +169,7 @@ wait(Mod, Times, OptFunc, ArgsMatcher, OptCallerPid, Timeout) -> Timeout end, Name = meck_util:proc_name(Mod), - try gen_server:call(Name, {wait, Times, OptFunc, ArgsMatcher, OptCallerPid, + try gen_server:call(Name, {wait, Condition, OptFunc, ArgsMatcher, OptCallerPid, Timeout}, EffectiveTimeout) of @@ -304,18 +308,35 @@ handle_call(get_history, _From, S = #state{history = undefined}) -> {reply, [], S}; handle_call(get_history, _From, S) -> {reply, lists:reverse(S#state.history), S}; -handle_call({wait, Times, OptFunc, ArgsMatcher, OptCallerPid, Timeout}, From, +handle_call({wait, {CondFun, CondState} = _Condition, OptFunc, ArgsMatcher, OptCallerPid, Timeout}, From, S = #state{history = History, trackers = Trackers}) -> - case times_called(OptFunc, ArgsMatcher, OptCallerPid, History) of - CalledSoFar when CalledSoFar >= Times -> + Filter = meck_history:new_filter(OptCallerPid, OptFunc, ArgsMatcher), + Result = lists:foldl( + fun + (HistoryRec, {cont, CondSt} = Acc) -> + case Filter(HistoryRec) of + true -> + {_Pid, {_M, _F, Args}, _Result} = HistoryRec, + CondFun(Args, CondSt); + false -> + Acc + end; + (_HistoryRec, {halt, _} = Acc) -> + Acc + end, + {cont, CondState}, + History + ), + case Result of + {halt, ok} -> {reply, ok, S}; - _CalledSoFar when Timeout =:= 0 -> + {cont, _} when Timeout =:= 0 -> {reply, {error, timeout}, S}; - CalledSoFar -> + {cont, CondState2} -> Tracker = #tracker{opt_func = OptFunc, args_matcher = ArgsMatcher, opt_caller_pid = OptCallerPid, - countdown = Times - CalledSoFar, + condition = {CondFun, CondState2}, reply_to = From, expire_at = timeout_to_timestamp(Timeout)}, {noreply, S#state{trackers = [Tracker | Trackers]}} @@ -687,28 +708,12 @@ cleanup(Mod) -> code:purge(meck_util:original_name(Mod)), Res = code:delete(meck_util:original_name(Mod)), - % `cover:export` might still export the meck generated module, + % `cover:export` might still export the meck generated module, % make sure that does not happen. _ = cover:reset(meck_util:original_name(Mod)), Res. --spec times_called(OptFunc::'_' | atom(), - meck_args_matcher:args_matcher(), - OptCallerPid::'_' | pid(), - meck_history:history()) -> - non_neg_integer(). -times_called(OptFunc, ArgsMatcher, OptCallerPid, History) -> - Filter = meck_history:new_filter(OptCallerPid, OptFunc, ArgsMatcher), - lists:foldl(fun(HistoryRec, Acc) -> - case Filter(HistoryRec) of - true -> - Acc + 1; - _Else -> - Acc - end - end, 0, History). - -spec update_trackers(meck_history:history_record(), [tracker()]) -> UpdTracker::[tracker()]. update_trackers(HistoryRecord, Trackers) -> @@ -738,7 +743,7 @@ update_tracker(Func, Args, CallerPid, #tracker{opt_func = OptFunc, args_matcher = ArgsMatcher, opt_caller_pid = OptCallerPid, - countdown = Countdown, + condition = {CondFun, CondState}, reply_to = ReplyTo, expire_at = ExpireAt} = Tracker) when (OptFunc =:= '_' orelse Func =:= OptFunc) andalso @@ -750,11 +755,14 @@ update_tracker(Func, Args, CallerPid, case is_expired(ExpireAt) of true -> expired; - false when Countdown == 1 -> - gen_server:reply(ReplyTo, ok), - expired; false -> - Tracker#tracker{countdown = Countdown - 1} + case CondFun(Args, CondState) of + {halt, ok} -> + gen_server:reply(ReplyTo, ok), + expired; + {cont, CondState2} -> + Tracker#tracker{condition = {CondFun, CondState2}} + end end end; update_tracker(_Func, _Args, _CallerPid, Tracker) -> diff --git a/test/meck_tests.erl b/test/meck_tests.erl index 0c33988..942c74f 100644 --- a/test/meck_tests.erl +++ b/test/meck_tests.erl @@ -1408,7 +1408,7 @@ can_mock_sticky_modules_test() -> meck_reentrant_test() -> meck:new(string, [unstick, passthrough]), - meck:expect(string, strip, + meck:expect(string, strip, fun(String) -> meck:passthrough([string:reverse(String)]) end), ?assertEqual(string:strip(" ABC "), "CBA"), meck:unload(string). @@ -1508,6 +1508,18 @@ wait_already_called_test() -> %% Clean meck:unload(). +wait_already_called_more_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + test:foo(1, 2), + test:foo(1, 2), + %% Then + ?assertMatch(ok, meck:wait(1, test, foo, [1, '_'], 100)), + %% Clean + meck:unload(). + wait_not_called_zero_timeout_test() -> %% Given meck:new(test, [non_strict]), @@ -1550,6 +1562,70 @@ wait_called_another_proc_test() -> %% Clean meck:unload(). +wait_already_called_reset_more_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + test:foo(1, 2), + test:foo(1, 2), + meck:reset(test), + test:foo(1, 2), + test:foo(1, 2), + %% Then + ?assertMatch(ok, meck:wait(1, test, foo, [1, '_'], 100)), + %% Clean + meck:unload(). + +wait_already_called_reset_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + test:foo(1, 2), + test:foo(1, 2), + meck:reset(test), + %% Then + ?assertError(timeout, meck:wait(2, test, foo, [1, '_'], 0)), + %% Clean + meck:unload(). + +wait_reset_on_different_processes_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + Fun = fun() -> + test:foo(1, 2) + end, + {_Pid1, MRef1} = erlang:spawn_monitor(Fun), + ?assertTerminated(MRef1, normal, 300), + meck:reset(test), + {_Pid2, MRef2} = erlang:spawn_monitor(Fun), + %% Then + ?assertMatch(ok, meck:wait(1, test, foo, [1, '_'], '_', 10)), + ?assertTerminated(MRef2, normal, 300), + %% Clean + meck:unload(). + +wait_reset_on_different_processes_2_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + Fun = fun() -> + test:foo(1, 2) + end, + {_Pid1, MRef1} = erlang:spawn_monitor(Fun), + ?assertTerminated(MRef1, normal, 300), + meck:reset(test), + {_Pid2, MRef2} = erlang:spawn_monitor(Fun), + %% Then + ?assertError(timeout, meck:wait(2, test, foo, [1, '_'], '_', 10)), + ?assertTerminated(MRef2, normal, 300), + %% Clean + meck:unload(). + wait_timeout_test() -> %% Given meck:new(test, [non_strict]), @@ -1634,6 +1710,162 @@ wait_purge_expired_tracker_test() -> %% Clean meck:unload(). +%% These tests replicate the ones that exist for meck:wait(Times, ...), but they're explicit. +wait_times_called_condition_already_called_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + test:foo(1, 2), + test:foo(1, 2), + %% Then + ?assertMatch(ok, meck:wait(times_called(2), test, foo, [1, '_'], 100)), + %% Clean + meck:unload(). + +wait_times_called_condition_already_called_more_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + test:foo(1, 2), + test:foo(1, 2), + %% Then + ?assertMatch(ok, meck:wait(times_called(1), test, foo, [1, '_'], 100)), + %% Clean + meck:unload(). + +wait_times_called_condition_not_called_zero_timeout_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + test:foo(1, 2), + test:foo(1, 2), + %% Then + ?assertError(timeout, meck:wait(times_called(3), test, foo, [1, '_'], 100)), + %% Clean + meck:unload(). + +times_called(Times) -> + ConditionFun = fun + (_, T) when T =:= 0 -> {halt, ok}; + (_, T) when is_integer(T) -> {cont, T - 1} + end, + {ConditionFun, Times - 1}. + +%% These tests wait for a function to be called multiple times, in any order, with the expected arguments. +wait_for_all_condition_already_called_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + test:foo(1, 1), + test:foo(2, 2), + %% Then + ?assertMatch(ok, meck:wait(called_for_all([1, 2]), test, foo, ['_', '_'], 100)), + %% Clean + meck:unload(). + +wait_for_all_condition_not_called_zero_timeout_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + test:foo(1, 1), + test:foo(2, 2), + %% Then + ?assertError(timeout, meck:wait(called_for_all([1, 2, 3]), test, foo, ['_', '_'], 0)), + %% Clean + meck:unload(). + +wait_for_all_condition_called_another_proc_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + Pid = erlang:spawn(fun() -> + timer:sleep(50), + test:foo(1, 1), + test:foo(2, 2), % Unexpected first argument + test:foo(1, 2) + end), + %% Then + ?assertMatch(ok, meck:wait(called_for_all([1, 2]), test, foo, [1, '_'], Pid, 500)), + %% Clean + meck:unload(). + +called_for_all(Expected) -> + ConditionFun = + fun([_, X], State) -> + case State -- [X] of + [] -> + {halt, ok}; + State2 -> + {cont, State2} + end + end, + {ConditionFun, Expected}. + +wait_for_all_condition_already_called_reset_more_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + test:foo(1, 1), + test:foo(1, 2), + meck:reset(test), + test:foo(1, 1), + test:foo(1, 2), + %% Then + ?assertMatch(ok, meck:wait(called_for_all([1, 2]), test, foo, [1, '_'], 100)), + %% Clean + meck:unload(). + +wait_for_all_condition_already_called_reset_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + test:foo(1, 1), + test:foo(1, 2), + meck:reset(test), + %% Then + ?assertError(timeout, meck:wait(called_for_all([1, 2]), test, foo, [1, '_'], 0)), + %% Clean + meck:unload(). + +wait_for_all_condition_already_called_reset_2_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + test:foo(1, 1), + meck:reset(test), + test:foo(1, 2), + %% Then + ?assertError(timeout, meck:wait(called_for_all([1, 2]), test, foo, [1, '_'], 0)), + %% Clean + meck:unload(). + +wait_for_all_condition_already_called_reset_2_another_process_test() -> + %% Given + meck:new(test, [non_strict]), + meck:expect(test, foo, 2, ok), + %% When + test:foo(1, 1), + meck:reset(test), + Fun = fun() -> + timer:sleep(50), + test:foo(1, 2) + end, + {_Pid1, MRef1} = erlang:spawn_monitor(Fun), + ?assertTerminated(MRef1, normal, 300), + %% Then + ?assertError(timeout, meck:wait(called_for_all([1, 2]), test, foo, [1, '_'], 100)), + %% Clean + meck:unload(). + mocked_test() -> %% At start, no modules should be mocked: [] = meck:mocked(),