Files
fluxer/fluxer_gateway/test/guild_channel_move_equivalence_tests.erl
T

2040 lines
70 KiB
Erlang

%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_channel_move_equivalence_tests).
-include_lib("eunit/include/eunit.hrl").
-define(GUILD_ID, 7100000).
-define(USER_BASE, 7200000).
-define(CHAN_BASE, 7300000).
-define(ROLE_BASE, 7400000).
-define(DANGLING_PARENT, 7399999).
-define(RECORDER, guild_channel_move_equivalence_recorder).
-define(MODE, guild_channel_move_equivalence_mode).
-define(DEFAULT_SEEDS, 300).
-define(SENSITIVITY_SEEDS, 150).
equivalence_test_() ->
{timeout, 1800, fun() ->
with_harness(fun() ->
First = env_int("CHMOVE_FIRST_SEED", 1),
lists:foreach(fun check_seed/1, lists:seq(First, First + seed_count() - 1))
end)
end}.
sensitivity_test_() ->
{timeout, 1800, fun() ->
with_harness(fun() ->
Undetected = [Name || Name <- broken_modes(), not diverges_for_some_case(Name)],
?assertEqual([], Undetected)
end)
end}.
move_into_public_category_does_no_per_member_work_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec = ticker_spec(),
[Moved | _] = ticker_ids(),
Event = bulk_event(
move_channels([Moved], {parent, ticker_category()}, layout(Spec))
),
Results = check_spec(Spec, [Event]),
[#{counts := Counts}] = maps:get(optimized, Results),
[#{counts := RefCounts}] = maps:get(reference, Results),
?assertEqual(0, count(bulk_load, Counts)),
?assertEqual(1, count(voice_fetch, Counts)),
?assert(count(bulk_load, RefCounts) > 0),
?assert(count(voice_fetch, RefCounts) >= 1),
?assert(count(can_view, Counts) * 4 < count(can_view, RefCounts))
end)
end}.
position_only_bulk_rebuilds_no_member_list_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec = ticker_spec(),
Results = check_spec(Spec, [position_move(Spec)]),
[#{counts := Counts}] = maps:get(optimized, Results),
[#{counts := RefCounts}] = maps:get(reference, Results),
?assertEqual(0, count(bulk_load, Counts)),
?assert(count(bulk_load, RefCounts) > 0),
?assertEqual(1, count(prune, Counts)),
?assertEqual(1, count(visibility, Counts))
end)
end}.
overwrite_change_on_a_subscribed_list_rebuilds_and_syncs_it_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec = ticker_spec(),
Text = ticker_category() + 1,
Changed = (find(Text, layout(Spec)))#{
<<"permission_overwrites">> => [overwrite(role_id(1), 0, 0, view())]
},
Results = check_spec(Spec, [{channel_update, Changed}]),
[#{log := Log, counts := Counts}] = maps:get(optimized, Results),
?assertEqual(1, count(bulk_load, Counts)),
?assert(lists:member(guild_member_list_update, [relay_event(E) || E <- Log]))
end)
end}.
created_channel_is_in_the_bulk_after_a_move_into_a_category_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec = (create_spec())#{hooks => [{channel_create, new_public_channel()}]},
Layout = layout(Spec) ++ [new_public_channel()],
Event = bulk_event(renumber(move_channels([new_id()], {parent, cat_id()}, Layout))),
Results = check_spec(Spec, [Event]),
[#{log := Log, state := {_, State}}] = maps:get(optimized, Results),
Bulks = [
Ids
|| {relay, _, [_, channel_update_bulk, Payload | _]} <- Log,
Ids <- [payload_channel_ids(Payload)]
],
?assertMatch([_ | _], Bulks),
[?assert(lists:member(new_id(), Ids)) || Ids <- Bulks],
[
?assert(maps:is_key(new_id(), session_map(session_id(L), State)))
|| L <- [1, 2, 3]
]
end)
end}.
created_channel_survives_a_position_only_move_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec = (create_spec())#{hooks => [{channel_create, new_public_channel()}]},
Layout = layout(Spec) ++ [new_public_channel()],
Results = check_spec(Spec, [bulk_event(renumber(lists:reverse(Layout)))]),
[#{state := {_, State}}] = maps:get(optimized, Results),
?assert(maps:is_key(new_id(), session_map(session_id(3), State)))
end)
end}.
old_owner_does_not_receive_channels_it_lost_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec = (owner_spec())#{hooks => [{owner, user_id(2)}]},
Results = check_spec(Spec, [position_move(Spec)]),
[#{log := Log, state := {_, State}}] = maps:get(optimized, Results),
ToOldOwner = [
E
|| {relay, _, [Targets, channel_update_bulk | _]} = E <- Log,
lists:member({session_pid, 1}, lists:flatten([Targets]))
],
?assertEqual([], ToOldOwner),
?assertEqual(#{}, session_map(session_id(1), State)),
?assertNot(maps:is_key(user_id(4), maps:get(member_subscriptions, State)))
end)
end}.
virtual_access_flip_leaves_no_stale_member_row_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec = flip_spec(),
Results = check_spec(Spec, [position_move(Spec)]),
[#{state := {Engines, _}, counts := Counts}] = maps:get(optimized, Results),
?assertEqual(1, count(bulk_load, Counts)),
?assertNot(lists:member(user_id(4), engine_user_ids(flip_channel(), Engines)))
end)
end}.
owner_flip_leaves_no_stale_member_row_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec = (flip_spec())#{
hooks => owner_flip_hooks()
},
Results = check_spec(Spec, [position_move(Spec)]),
[#{state := {Engines, _}}] = maps:get(optimized, Results),
?assert(lists:member(user_id(1), engine_user_ids(flip_channel(), Engines)))
end)
end}.
role_flip_on_an_unsubscribed_list_leaves_no_stale_member_row_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec = (flip_spec())#{
list_subscriptions => [],
hooks => [
{ensure_list, flip_channel()},
{role_permissions, 1, 0},
{member_update, user_id(2)},
{role_permissions, 1, view()}
]
},
Results = check_spec(Spec, [position_move(Spec)]),
[#{state := {Engines, _}}] = maps:get(optimized, Results),
?assert(lists:member(user_id(2), engine_user_ids(flip_channel(), Engines)))
end)
end}.
single_update_then_bulk_rebuilds_the_category_it_left_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec0 = gap_spec(?CHAN_BASE + 500),
Spec = Spec0#{
list_subscriptions => [{session_id(1), integer_to_binary(?CHAN_BASE + 500)}]
},
Moved = (find(?CHAN_BASE + 501, layout(Spec)))#{
<<"parent_id">> => integer_to_binary(?CHAN_BASE + 500)
},
After = replace(Moved, layout(Spec)),
Events = [{channel_update, Moved}, bulk_event(renumber(lists:reverse(After)))],
Results = check_spec(Spec, Events),
[_, #{counts := Counts, log := Log}] = maps:get(optimized, Results),
?assertEqual(1, count(bulk_load, Counts)),
?assert(lists:member(guild_member_list_update, [relay_event(E) || E <- Log]))
end)
end}.
category_flip_between_single_updates_leaves_no_stale_member_row_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
{Spec, Events} = category_flip_case(),
Results = check_spec(Spec, Events),
[_, _, _, #{state := {Engines, _}, counts := Counts}] = maps:get(
optimized, Results
),
?assertEqual(1, count(bulk_load, Counts)),
?assert(lists:member(user_id(2), engine_user_ids(?CHAN_BASE + 500, Engines)))
end)
end}.
reload_with_changed_member_roles_rebuilds_every_list_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
{Spec, Events} = reload_case(),
Results = check_spec(Spec, Events),
[#{state := {Engines, _}, counts := Counts}] = maps:get(optimized, Results),
?assertEqual(1, count(bulk_load, Counts)),
?assertNot(lists:member(user_id(2), engine_user_ids(flip_channel(), Engines)))
end)
end}.
reload_case() ->
Spec0 = flip_spec(),
Data = maps:get(data, Spec0),
Target = integer_to_binary(user_id(2)),
Reloaded = Data#{
<<"members">> => [
case M of
#{<<"user">> := #{<<"id">> := Target}} -> M#{<<"roles">> => []};
_ -> M
end
|| M <- maps:get(<<"members">>, Data)
]
},
Spec = Spec0#{hooks => [{reload, Reloaded}]},
{Spec, [position_move(Spec)]}.
category_flip_case() ->
Spec = (gap_spec(?CHAN_BASE + 501))#{
list_subscriptions => [{session_id(1), integer_to_binary(?CHAN_BASE + 500)}]
},
Child = find(?CHAN_BASE + 501, layout(Spec)),
{Spec, [
{channel_update, Child#{<<"parent_id">> => null}},
{hook, {member_update, user_id(2)}},
{channel_update, Child},
position_move(Spec)
]}.
bulk_category_flip_leaves_no_stale_member_row_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
{Spec, Events} = bulk_category_flip_case(),
Results = check_spec(Spec, Events),
[_, _, _, #{state := {Engines, _}, counts := Counts}] = maps:get(
optimized, Results
),
?assertEqual(1, count(bulk_load, Counts)),
?assert(lists:member(user_id(2), engine_user_ids(?CHAN_BASE + 500, Engines)))
end)
end}.
bulk_category_flip_case() ->
Spec = (gap_spec(?CHAN_BASE + 501))#{
list_subscriptions => [{session_id(1), integer_to_binary(?CHAN_BASE + 500)}]
},
Child = find(?CHAN_BASE + 501, layout(Spec)),
{Spec, [
bulk_event([Child#{<<"parent_id">> => null}]),
{hook, {member_update, user_id(2)}},
bulk_event([Child]),
position_move(Spec)
]}.
everyone_patch_flip_leaves_no_stale_member_row_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
{Spec, Events} = everyone_patch_flip_case(),
Results = check_spec(Spec, Events),
[#{state := {Engines, _}, counts := Counts}] = maps:get(optimized, Results),
?assertEqual(1, count(bulk_load, Counts)),
?assertNot(lists:member(user_id(4), engine_user_ids(flip_channel(), Engines)))
end)
end}.
everyone_patch_flip_case() ->
Spec = (flip_spec())#{
list_subscriptions => [],
hooks => [
{ensure_list, flip_channel()},
{patch_everyone, view()},
{member_update, user_id(4)},
{everyone_permissions, members_view() bor connect() bor speak()}
]
},
{Spec, [position_move(Spec)]}.
created_child_flip_leaves_no_stale_member_row_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
{Spec, Events} = created_child_flip_case(),
Results = check_spec(Spec, Events),
[_, #{state := {Engines, _}, counts := Counts}] = maps:get(optimized, Results),
?assertEqual(1, count(bulk_load, Counts)),
?assertNot(lists:member(user_id(4), engine_user_ids(?CHAN_BASE + 500, Engines)))
end)
end}.
created_child_flip_case() ->
Spec0 = (owner_spec())#{
data => owner_data(
renumber([
channel(?CHAN_BASE + 10, 0, null, []),
channel(?CHAN_BASE + 500, 4, null, [])
])
),
member_subscriptions => []
},
Child = channel(new_id(), 0, ?CHAN_BASE + 500, [overwrite(?GUILD_ID, 0, view(), 0)]),
Spec = Spec0#{
hooks => [
{ensure_list, ?CHAN_BASE + 500},
{channel_create, Child},
{member_update, user_id(4)}
]
},
Moved = Child#{<<"parent_id">> => null},
{Spec, [
{channel_update, Moved},
bulk_event(renumber(lists:reverse(layout(Spec0) ++ [Moved])))
]}.
subscribed_hidden_voice_list_is_rebuilt_after_access_is_granted_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
{Spec, Events} = subscribed_hidden_voice_case(),
Results = check_spec(Spec, Events),
[#{state := {Engines, _}}] = maps:get(optimized, Results),
?assert(lists:member(user_id(5), engine_user_ids(?CHAN_BASE + 202, Engines)))
end)
end}.
subscribed_hidden_voice_case() ->
Spec0 = hidden_voice_spec(),
Spec = Spec0#{
list_subscriptions => [{session_id(1), integer_to_binary(?CHAN_BASE + 202)}]
},
{Spec, [position_move(Spec)]}.
bulk_move_resyncs_voice_permissions_in_every_voice_channel_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec0 = ticker_spec(),
Voice = maps:merge(
gen_voice_states([user_id(I) || I <- lists:seq(1, 10)], [ticker_category() + 2]),
maps:from_list([
{<<"t", (integer_to_binary(I))/binary>>, #{
<<"user_id">> => integer_to_binary(user_id(20 + I)),
<<"channel_id">> => integer_to_binary(Id),
<<"guild_id">> => integer_to_binary(?GUILD_ID),
<<"connection_id">> => <<"t", (integer_to_binary(I))/binary>>,
<<"session_id">> => <<"ts", (integer_to_binary(I))/binary>>,
<<"deaf">> => false
}}
|| {I, Id} <- lists:zip(lists:seq(1, 3), ticker_ids())
])
),
Spec = Spec0#{voice_states => Voice},
Results = check_spec(Spec, [position_move(Spec)]),
[#{log := Log}] = maps:get(optimized, Results),
Synced = lists:usort([Conn || {voice_hook, _, _, _, Conn, _} <- Log]),
?assertEqual(lists:sort(maps:keys(Voice)), Synced)
end)
end}.
voice_user_without_view_is_granted_access_like_the_full_pass_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec = hidden_voice_spec(),
Results = check_spec(Spec, [position_move(Spec)]),
[#{state := {_, State}, log := Log}] = maps:get(optimized, Results),
?assert(lists:any(fun(E) -> element(1, E) =:= voice_hook end, Log)),
?assert(
guild_virtual_channel_access:has_virtual_access(
user_id(5), ?CHAN_BASE + 202, State
)
)
end)
end}.
members_sharing_a_role_set_with_the_owner_see_their_own_channels_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
?assertEqual(
[{session_pid, 3}, {session_pid, 4}],
shared_roles_deletes(
shared_roles_case(user_allow(), [hidden_overwrite() | user_allow()])
)
)
end)
end}.
user_overwrite_added_by_the_event_is_not_shared_through_the_role_memo_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
?assertEqual(
[{session_pid, 3}, {session_pid, 4}],
shared_roles_deletes(shared_roles_case([], [hidden_overwrite() | user_allow()]))
)
end)
end}.
user_overwrite_removed_by_the_event_is_not_shared_through_the_role_memo_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
?assertEqual(
[{session_pid, 2}],
shared_roles_deletes(
shared_roles_case([hidden_overwrite() | user_allow()], [hidden_overwrite()])
)
)
end)
end}.
shared_roles_deletes({Spec, Events}) ->
Results = check_spec(Spec, Events),
[#{log := Log}] = maps:get(optimized, Results),
lists:sort([Target || {relay, dispatch, [Target, channel_delete, _, _]} <- Log]).
user_allow() ->
[overwrite(user_id(2), 1, view(), 0)].
stale_detection_tracks_every_permission_input_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec = (flip_spec())#{list_subscriptions => []},
{State0, Env} = materialize(Spec),
ListId = integer_to_binary(flip_channel()),
State = guild_member_list_channel_engine:ensure(ListId, State0),
try
?assertNot(guild_member_list_engine_inputs:is_stale(ListId, State)),
?assertEqual(
[],
[
Name
|| {Name, Change} <- input_changes(),
not guild_member_list_engine_inputs:is_stale(ListId, Change(State))
]
),
?assert(
guild_member_list_engine_inputs:is_stale(
ListId,
guild_member_list_engine_inputs:mark_stale(flip_channel(), State)
)
),
?assertNot(
guild_member_list_engine_inputs:is_stale(
ListId, guild_member_list_engine_inputs:latch_stale(State)
)
),
[
?assert(
guild_member_list_engine_inputs:is_stale(
ListId,
(guild_member_list_engine_inputs:latch_stale(Change(State)))#{
data => maps:get(data, State),
virtual_channel_access => maps:get(
virtual_channel_access, State
)
}
)
)
|| {_Name, Change} <- input_changes()
],
?assertNot(
guild_member_list_engine_inputs:is_stale(
ListId,
with_channels(
fun(C) -> C#{<<"name">> => <<"x">>, <<"position">> => 99} end, State
)
)
)
after
teardown(State, Env)
end
end)
end}.
twin_channel_engines_match_a_fresh_build_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
{State0, Env} = materialize(twin_spec()),
Lists = twin_lists(),
State = lists:foldl(fun guild_member_list_channel_engine:ensure/2, State0, Lists),
Refs = [guild_member_list_channel_engine:ref(L, State) || L <- Lists],
?assertEqual(length(Lists), length(lists:usort(Refs))),
Final = lists:foldl(fun assert_engine_matches_fresh_build/2, State, Lists),
teardown(Final, Env)
end)
end}.
stale_twin_engine_is_not_cloned_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
{State0, Env} = materialize(twin_spec()),
[A, B | _] = twin_lists(),
State1 = guild_member_list_channel_engine:ensure(A, State0),
Hidden = with_channels(
fun(C) ->
case integer_to_binary(channel_int_id(C)) of
A -> C#{<<"permission_overwrites">> => [hidden_overwrite()]};
_ -> C
end
end,
State1
),
[
ok = guild_member_list_channel_engine:update_user(user_id(I), A, Hidden)
|| I <- lists:seq(1, 60)
],
State2 = guild_member_list_channel_engine:ensure(B, Hidden),
?assert(guild_member_list_engine_inputs:is_stale(A, State2)),
?assertNotEqual(engine_content(A, State2), engine_content(B, State2)),
teardown(assert_engine_matches_fresh_build(B, State2), Env)
end)
end}.
twin_spec() ->
Restricted = [overwrite(role_id(1), 0, 0, view())],
single_session_spec(
renumber([
channel(?CHAN_BASE + 700, 0, null, []),
channel(?CHAN_BASE + 701, 0, null, []),
channel(?CHAN_BASE + 702, 0, null, Restricted),
channel(?CHAN_BASE + 703, 0, null, Restricted)
])
).
twin_lists() ->
[integer_to_binary(?CHAN_BASE + I) || I <- [700, 701, 702, 703]].
assert_engine_matches_fresh_build(ListId, State) ->
Built = engine_content(ListId, State),
Rebuilt = guild_member_list_channel_engine:rebuild(ListId, State),
?assertEqual(engine_content(ListId, Rebuilt), Built),
Rebuilt.
engine_content(ListId, State) ->
Ref = guild_member_list_channel_engine:ref(ListId, State),
Items = guild_member_list_engine:get_all_item_keys(Ref),
Members = [UserId || {member, UserId} <- Items],
{
guild_member_list_engine:get_counts(Ref),
guild_member_list_engine:get_groups(Ref),
Items,
[{U, guild_member_list_engine:is_member_online(Ref, U)} || U <- Members]
}.
input_changes() ->
Flip = flip_channel(),
[
{owner,
with_data(fun(D) ->
G = maps:get(<<"guild">>, D),
D#{<<"guild">> => G#{<<"owner_id">> => integer_to_binary(user_id(2))}}
end)},
{roles,
with_data(fun(D) ->
[E | Rest] = guild_data_index:role_list(D),
guild_data_index:put_roles([E#{<<"permissions">> => <<"0">>} | Rest], D)
end)},
{role_order,
with_data(fun(D) -> D#{<<"roles">> => lists:reverse(maps:get(<<"roles">>, D))} end)},
{role_index, with_data(fun(D) -> D#{<<"role_index">> => #{}} end)},
{indexed_overwrites_only,
with_data(fun(D) ->
Index = maps:get(<<"channel_index">>, D),
Channel = maps:get(Flip, Index),
D#{
<<"channel_index">> => Index#{
Flip => Channel#{<<"permission_overwrites">> => [hidden_overwrite()]}
}
}
end)},
{role_perms_cache, with_data(fun(D) -> D#{role_perms_cache => #{}} end)},
{own_overwrites,
with_channels(fun(C) ->
case channel_int_id(C) of
Flip -> C#{<<"permission_overwrites">> => [hidden_overwrite()]};
_ -> C
end
end)},
{own_type,
with_channels(fun(C) ->
case channel_int_id(C) of
Flip -> C#{<<"type">> => 0};
_ -> C
end
end)},
{overwrite_cache,
with_data(fun(D) ->
D#{
overwrite_perms_cache => maps:remove(
Flip, maps:get(overwrite_perms_cache, D)
)
}
end)},
{virtual_access, fun(S) ->
S#{virtual_channel_access => #{user_id(9) => sets:from_list([Flip])}}
end}
].
category_children_are_permission_inputs_test_() ->
{timeout, 120, fun() ->
with_harness(fun() ->
Spec = (gap_spec(?CHAN_BASE + 501))#{list_subscriptions => []},
{State0, Env} = materialize(Spec),
ListId = integer_to_binary(?CHAN_BASE + 500),
State = guild_member_list_channel_engine:ensure(ListId, State0),
try
?assertNot(guild_member_list_engine_inputs:is_stale(ListId, State)),
Reparented = with_channels(
fun(C) ->
case channel_int_id(C) of
501 + ?CHAN_BASE -> C#{<<"parent_id">> => null};
_ -> C
end
end,
State
),
?assert(guild_member_list_engine_inputs:is_stale(ListId, Reparented)),
ChildOverwrites = with_channels(
fun(C) ->
case channel_int_id(C) of
501 + ?CHAN_BASE ->
C#{<<"permission_overwrites">> => [hidden_overwrite()]};
_ ->
C
end
end,
State
),
?assert(guild_member_list_engine_inputs:is_stale(ListId, ChildOverwrites))
after
teardown(State, Env)
end
end)
end}.
with_data(Fun) ->
fun(State) -> with_data(Fun, State) end.
with_data(Fun, State) ->
State#{data => Fun(maps:get(data, State))}.
with_channels(Fun) ->
fun(State) -> with_channels(Fun, State) end.
with_channels(Fun, State) ->
with_data(
fun(D) ->
guild_data_index:put_channels([Fun(C) || C <- guild_data_index:channel_list(D)], D)
end,
State
).
engine_user_ids(ChannelId, Engines) ->
{_, UserIds, _, _} = maps:get(integer_to_binary(ChannelId), Engines),
UserIds.
payload_channel_ids(#{<<"channels">> := Channels}) ->
[channel_int_id(C) || C <- Channels];
payload_channel_ids({pre_encoded, Bin}) ->
payload_channel_ids(json:decode(Bin));
payload_channel_ids(_) ->
[].
new_id() -> ?CHAN_BASE + 950.
cat_id() -> ?CHAN_BASE + 1.
new_public_channel() ->
channel(new_id(), 0, null, [overwrite(?GUILD_ID, 0, view(), 0)]).
create_spec() ->
(owner_spec())#{
data => owner_data(
renumber([
channel(cat_id(), 4, null, [overwrite(?GUILD_ID, 0, view(), 0)]),
channel(?CHAN_BASE + 10, 0, null, []),
channel(?CHAN_BASE + 30, 0, null, [overwrite(user_id(4), 1, view(), 0)]),
channel(?CHAN_BASE + 40, 0, null, [overwrite(?GUILD_ID, 0, view(), 0)])
])
),
sessions => [
#{label => 1, user_id => user_id(1), pending => false},
#{label => 2, user_id => user_id(2), pending => false},
#{label => 3, user_id => user_id(4), pending => false}
]
}.
owner_spec() ->
#{
data => owner_data(
renumber([
channel(?CHAN_BASE + 10, 0, null, []),
channel(?CHAN_BASE + 30, 0, null, [overwrite(user_id(4), 1, view(), 0)])
])
),
sessions => [
#{label => 1, user_id => user_id(1), pending => false},
#{label => 2, user_id => user_id(2), pending => false}
],
virtual_access => #{},
voice_states => #{},
list_subscriptions => [],
member_subscriptions => [{session_id(1), user_id(4)}],
dm_partners => []
}.
owner_data(Channels) ->
#{
<<"guild">> => guild(user_id(1)),
<<"roles">> => [
#{
<<"id">> => integer_to_binary(?GUILD_ID),
<<"name">> => <<"@everyone">>,
<<"permissions">> => integer_to_binary(
members_view() bor connect() bor speak()
),
<<"position">> => 0,
<<"hoist">> => false
},
(plain_role(1, role_id(1)))#{<<"permissions">> => integer_to_binary(view())}
],
<<"members">> => [
member(user_id(1), []),
member(user_id(2), [role_id(1)]),
member(user_id(3), [role_id(1)]),
member(user_id(4), [])
],
<<"channels">> => Channels,
<<"emojis">> => [],
<<"stickers">> => []
}.
guild(Owner) ->
#{
<<"id">> => integer_to_binary(?GUILD_ID),
<<"name">> => <<"equivalence">>,
<<"owner_id">> => integer_to_binary(Owner),
<<"features">> => [],
<<"disabled_operations">> => 0
}.
flip_channel() -> ?CHAN_BASE + 20.
owner_flip_hooks() ->
[
{owner, user_id(1)},
{role_permissions, 1, view()},
{owner, user_id(3)},
{member_update, user_id(1)},
{owner, user_id(1)}
].
flip_spec() ->
Hidden = flip_channel(),
(owner_spec())#{
data => owner_data(
renumber([
channel(?CHAN_BASE + 10, 0, null, []),
channel(Hidden, 2, null, [])
])
),
sessions => [#{label => 1, user_id => user_id(2), pending => false}],
list_subscriptions => [{session_id(1), integer_to_binary(Hidden)}],
member_subscriptions => [],
hooks => [
{add_virtual_channel_access, user_id(4), Hidden},
{member_update, user_id(4)},
{remove_virtual_channel_access, user_id(4), Hidden}
]
}.
shared_roles_case(Before, After) ->
Spec = single_session_spec(
renumber([
channel(?CHAN_BASE + 600, 0, null, []),
channel(?CHAN_BASE + 601, 0, null, Before)
])
),
Data = maps:get(data, Spec),
{
Spec#{
data => Data#{
<<"guild">> => guild(user_id(1)),
<<"members">> => [member(user_id(I), []) || I <- lists:seq(1, 4)]
},
sessions => [
#{label => L, user_id => user_id(L), pending => false}
|| L <- [1, 2, 3, 4]
]
},
[{channel_update, channel(?CHAN_BASE + 601, 0, null, After)}]
}.
check_seed(Seed) ->
Spec = gen_spec(Seed),
Events = gen_events(Seed, spec_layout_after_hooks(Spec)),
try
check_spec(Spec, Events)
catch
Class:Reason:Stack ->
io:format(
user, "~nchannel move equivalence failed: seed=~p hooks=~p events=~p~n", [
Seed, maps:get(hooks, Spec, []), [summarize_event(E) || E <- Events]
]
),
erlang:raise(Class, Reason, Stack)
end.
spec_layout_after_hooks(Spec) ->
layout(Spec) ++ [C || {channel_create, C} <- maps:get(hooks, Spec, [])].
check_spec(Spec, Events) ->
check_spec(Spec, Events, optimized).
check_spec(Spec, Events, Mode) ->
{RefHooks, Reference} = run(Spec, Events, reference),
{CandHooks, Candidate} = run(Spec, Events, Mode),
assert_same(hooks, log, RefHooks, CandHooks),
compare_runs(Reference, Candidate, Events, 1),
#{reference => Reference, optimized => Candidate}.
diverges_for_some_case(Broken) ->
Cases =
hand_cases() ++
[
begin
Spec = gen_spec(Seed),
{Spec, gen_events(Seed, spec_layout_after_hooks(Spec))}
end
|| Seed <- lists:seq(1, ?SENSITIVITY_SEEDS)
],
lists:any(
fun({Spec, Events}) ->
try check_spec(Spec, Events, {broken, Broken}) of
_ -> false
catch
error:_ -> true
end
end,
Cases
).
broken_modes() ->
[never_stale, vca_marks_off, latch_off, reload_marks_off].
hand_cases() ->
Create = (create_spec())#{hooks => [{channel_create, new_public_channel()}]},
CreateLayout = layout(Create) ++ [new_public_channel()],
Owner = (owner_spec())#{hooks => [{owner, user_id(2)}]},
OwnerFlip = (flip_spec())#{
hooks => owner_flip_hooks()
},
RoleFlip = (flip_spec())#{
list_subscriptions => [],
hooks => [
{ensure_list, flip_channel()},
{role_permissions, 1, 0},
{member_update, user_id(2)},
{role_permissions, 1, view()}
]
},
Gap = (gap_spec(?CHAN_BASE + 500))#{
list_subscriptions => [{session_id(1), integer_to_binary(?CHAN_BASE + 500)}]
},
GapMoved = (find(?CHAN_BASE + 501, layout(Gap)))#{
<<"parent_id">> => integer_to_binary(?CHAN_BASE + 500)
},
Text = ticker_category() + 1,
[
{Create, [
bulk_event(renumber(move_channels([new_id()], {parent, cat_id()}, CreateLayout)))
]},
{Owner, [position_move(Owner)]},
{flip_spec(), [position_move(flip_spec())]},
{OwnerFlip, [position_move(OwnerFlip)]},
{RoleFlip, [position_move(RoleFlip)]},
{Gap, [
{channel_update, GapMoved},
bulk_event(renumber(lists:reverse(replace(GapMoved, layout(Gap)))))
]},
{ticker_spec(), [
{channel_update, (find(Text, layout(ticker_spec())))#{
<<"permission_overwrites">> => [overwrite(role_id(1), 0, 0, view())]
}}
]},
{hidden_voice_spec(), [position_move(hidden_voice_spec())]},
category_flip_case(),
bulk_category_flip_case(),
everyone_patch_flip_case(),
reload_case(),
created_child_flip_case(),
subscribed_hidden_voice_case(),
shared_roles_case(user_allow(), [hidden_overwrite() | user_allow()]),
shared_roles_case([], [hidden_overwrite() | user_allow()]),
shared_roles_case([hidden_overwrite() | user_allow()], [hidden_overwrite()])
].
compare_runs([], [], [], _N) ->
ok;
compare_runs([F | Fs], [O | Os], [Event | Events], N) ->
Context = {event, N, summarize_event(Event)},
lists:foreach(
fun(Key) -> assert_same(Context, Key, maps:get(Key, F), maps:get(Key, O)) end,
[state, log, direct, perm_cache]
),
compare_runs(Fs, Os, Events, N + 1).
assert_same(Context, Key, Full, Optimized) ->
case first_difference(Full, Optimized, []) of
none -> ok;
{Path, A, B} -> erlang:error({Context, Key, Path, trim(A), trim(B)})
end.
trim(Term) ->
lists:flatten(io_lib:format("~P", [Term, 12])).
first_difference(Same, Same, _Path) ->
none;
first_difference(A, B, Path) when is_map(A), is_map(B) ->
Keys = lists:usort(maps:keys(A) ++ maps:keys(B)),
first_of(
fun(K) ->
first_difference(maps:get(K, A, '$missing'), maps:get(K, B, '$missing'), [K | Path])
end,
Keys
);
first_difference(A, B, Path) when is_tuple(A), is_tuple(B), tuple_size(A) =:= tuple_size(B) ->
first_difference(tuple_to_list(A), tuple_to_list(B), Path);
first_difference(A, B, Path) when is_list(A), is_list(B), length(A) =:= length(B) ->
first_of(
fun({I, X, Y}) -> first_difference(X, Y, [I | Path]) end,
lists:zip3(lists:seq(1, length(A)), A, B)
);
first_difference(A, B, Path) ->
{lists:reverse(Path), A, B}.
first_of(_Fun, []) ->
none;
first_of(Fun, [H | T]) ->
case Fun(H) of
none -> first_of(Fun, T);
Found -> Found
end.
run(Spec, Events, Mode) ->
put(?MODE, Mode),
try
{State00, Env} = materialize(Spec),
{State0, HookLog} = apply_hooks(maps:get(hooks, Spec, []), State00, Env),
{StateN, Results} = lists:foldl(
fun({Kind, Data}, {S, Acc}) ->
{S1, Result} = run_event(Kind, Data, S, Env),
{S1, [Result | Acc]}
end,
{State0, []},
Events
),
teardown(StateN, Env),
{HookLog, lists:reverse(Results)}
after
erase(?MODE)
end.
apply_hooks(Hooks, State, Env) ->
State1 = lists:foldl(fun apply_hook/2, State, Hooks),
State2 = guild_member_list:flush_pending_member_list_syncs(State1),
{Log, Direct, _Fetches} = take_log(Env),
{State2, {normalize(Log, Env), normalize(Direct, Env)}}.
apply_hook({Action, UserId, ChannelId}, State) when
Action =:= add_virtual_channel_access; Action =:= remove_virtual_channel_access
->
{noreply, Next} = guild:handle_cast({Action, UserId, ChannelId}, State),
Next;
apply_hook({channel_create, Channel}, State) ->
guild:dispatch_event(channel_create, Channel, State);
apply_hook({owner, UserId}, State) ->
guild:dispatch_event(guild_update, #{<<"owner_id">> => integer_to_binary(UserId)}, State);
apply_hook({member_update, UserId}, State) ->
Member = guild_data_index:get_member(UserId, maps:get(data, State)),
Nick =
case maps:get(<<"nick">>, Member, null) of
Previous when is_binary(Previous) -> <<Previous/binary, "x">>;
_ -> <<"n">>
end,
guild:dispatch_event(
guild_member_update, member_payload(Member#{<<"nick">> => Nick}), State
);
apply_hook({role_permissions, Index, Perms}, State) ->
Role = maps:get(role_id(Index), guild_data_index:role_index(maps:get(data, State))),
guild:dispatch_event(
guild_role_update,
#{<<"role">> => Role#{<<"permissions">> => integer_to_binary(Perms)}},
State
);
apply_hook({patch_everyone, Bit}, State) ->
{noreply, Next} = guild:handle_cast({patch_everyone_perms, Bit}, State),
Next;
apply_hook({everyone_permissions, Perms}, State) ->
Role = maps:get(?GUILD_ID, guild_data_index:role_index(maps:get(data, State))),
guild:dispatch_event(
guild_role_update,
#{<<"role">> => Role#{<<"permissions">> => integer_to_binary(Perms)}},
State
);
apply_hook({reload, Data}, State) ->
{reply, ok, Next} = guild:handle_call({reload, Data}, {self(), make_ref()}, State),
Next;
apply_hook({ensure_list, ChannelId}, State) ->
guild_member_list_channel_engine:ensure(integer_to_binary(ChannelId), State).
member_payload(Member) ->
maps:with([<<"user">>, <<"roles">>, <<"nick">>, <<"joined_at">>], Member).
run_event(Kind, Data, State, Env) ->
reset_counters(),
State1 =
case Kind of
hook -> apply_hook(Data, State);
_ -> guild:dispatch_event(Kind, Data, State)
end,
State2 = guild_member_list:flush_pending_member_list_syncs(State1),
{Log, Direct, Fetches} = take_log(Env),
Counts = read_counters(),
Result = #{
log => normalize(Log, Env),
direct => normalize(Direct, Env),
counts => Counts#{voice_fetch => Fetches},
state => normalize_state(State2, Env),
perm_cache => normalize(guild_permission_cache:get_snapshot(?GUILD_ID), Env)
},
{State2, Result}.
with_harness(Fun) ->
Recorder = spawn(fun() -> recorder_loop(empty_log()) end),
register(?RECORDER, Recorder),
meck:new(gateway_dispatch_relay, [no_link, no_history]),
Record = fun(Name, Args) ->
?RECORDER ! {rec, self(), {relay, Name, Args}},
ok
end,
meck:expect(gateway_dispatch_relay, dispatch, fun(A, B, C) ->
Record(dispatch, [A, B, C])
end),
meck:expect(gateway_dispatch_relay, dispatch, fun(A, B, C, D) ->
Record(dispatch, [A, B, C, D])
end),
meck:expect(gateway_dispatch_relay, dispatch_many, fun(A, B, C) ->
Record(dispatch_many, [A, B, C])
end),
meck:expect(gateway_dispatch_relay, dispatch_many, fun(A, B, C, D) ->
Record(dispatch_many, [A, B, C, D])
end),
meck:expect(gateway_dispatch_relay, dispatch_direct, fun(A, B, C) ->
Record(dispatch_direct, [A, B, C])
end),
meck:expect(gateway_dispatch_relay, dispatch_grouped, fun(A, B, C, D) ->
Record(dispatch_grouped, [A, B, C, D])
end),
install_modes(),
ok = start_counters(),
try
Fun()
after
stop_counters(),
meck:unload([
gateway_dispatch_relay, guild_visibility_memo, guild_member_list_engine_inputs
]),
unregister(?RECORDER),
exit(Recorder, kill)
end.
install_modes() ->
meck:new(guild_visibility_memo, [no_link, passthrough, no_history]),
meck:expect(guild_visibility_memo, new, fun(Ids, Old, New) ->
case get(?MODE) of
reference -> disabled;
_ -> meck:passthrough([Ids, Old, New])
end
end),
meck:new(guild_member_list_engine_inputs, [no_link, passthrough, no_history]),
meck:expect(guild_member_list_engine_inputs, is_stale, fun(ListId, State) ->
case get(?MODE) of
reference -> true;
{broken, never_stale} -> false;
_ -> meck:passthrough([ListId, State])
end
end),
Unmarked = fun(Broken, Args) ->
case get(?MODE) of
{broken, Broken} -> lists:last(Args);
_ -> meck:passthrough(Args)
end
end,
meck:expect(guild_member_list_engine_inputs, mark_stale, fun(ChannelId, State) ->
Unmarked(vca_marks_off, [ChannelId, State])
end),
meck:expect(guild_member_list_engine_inputs, latch_stale, fun(State) ->
Unmarked(latch_off, [State])
end),
meck:expect(guild_member_list_engine_inputs, forget_all, fun(State) ->
Unmarked(reload_marks_off, [State])
end).
empty_log() ->
#{main => [], direct => #{}, fetches => 0}.
recorder_loop(Log = #{main := Main, direct := Direct, fetches := Fetches}) ->
receive
{rec, _From, Entry} ->
recorder_loop(Log#{main := [Entry | Main]});
{direct, Label, Msg} ->
recorder_loop(Log#{direct := Direct#{Label => [Msg | maps:get(Label, Direct, [])]}});
voice_fetch ->
recorder_loop(Log#{fetches := Fetches + 1});
{take, From} ->
From ! {log, Log},
recorder_loop(empty_log())
end.
take_log(#{collectors := Collectors}) ->
lists:foreach(
fun(Pid) ->
Ref = make_ref(),
Pid ! {barrier, self(), Ref},
receive
{barrier_done, Ref} -> ok
after 5000 -> error(collector_timeout)
end
end,
Collectors
),
?RECORDER ! {take, self()},
receive
{log, #{main := Main, direct := Direct, fetches := Fetches}} ->
{lists:reverse(Main), maps:map(fun(_, V) -> lists:reverse(V) end, Direct), Fetches}
after 5000 -> error(recorder_timeout)
end.
collector_loop(Label) ->
receive
stop ->
ok;
{barrier, From, Ref} ->
From ! {barrier_done, Ref},
collector_loop(Label);
Msg ->
?RECORDER ! {direct, Label, Msg},
collector_loop(Label)
end.
voice_server_loop(VoiceStates) ->
receive
{'$gen_call', From, {get_voice_states_map}} ->
?RECORDER ! voice_fetch,
gen_server:reply(From, VoiceStates),
voice_server_loop(VoiceStates);
stop ->
ok;
_ ->
voice_server_loop(VoiceStates)
end.
-define(COUNTED, [
{bulk_load, {guild_member_list_engine, bulk_load, 3}},
{perm_put, {guild_permission_cache, put_state, 1}},
{visibility,
{guild_visibility_overwrites, compute_and_dispatch_visibility_changes_for_channels, 3}},
{prune, {guild_maintenance, prune_invalid_member_subscriptions, 1}},
{can_view, {guild_visibility_channels, channel_is_visible, 4}}
]).
start_counters() ->
[erlang:trace_pattern(MFA, true, [local, call_count]) || {_, MFA} <- ?COUNTED],
_ = erlang:trace(self(), true, [call]),
ok.
stop_counters() ->
_ = erlang:trace(self(), false, [call]),
[erlang:trace_pattern(MFA, false, [local, call_count]) || {_, MFA} <- ?COUNTED],
ok.
reset_counters() ->
[erlang:trace_pattern(MFA, restart, [local, call_count]) || {_, MFA} <- ?COUNTED],
ok.
read_counters() ->
maps:from_list([
{Name, count_or_zero(element(2, erlang:trace_info(MFA, call_count)))}
|| {Name, MFA} <- ?COUNTED
]).
count(Name, Counts) ->
maps:get(Name, Counts).
count_or_zero(N) when is_integer(N) -> N;
count_or_zero(_) -> 0.
relay_event({relay, _Name, [_Target, Event | _]}) -> Event;
relay_event(Other) -> Other.
materialize(Spec) ->
#{
data := Data,
sessions := SessionSpecs,
virtual_access := VirtualAccess,
voice_states := VoiceStates,
list_subscriptions := ListSubs,
member_subscriptions := MemberSubs,
dm_partners := DmPartners
} = Spec,
Collectors = maps:from_list([
{Label, spawn(fun() -> collector_loop(Label) end)}
|| #{label := Label} <- SessionSpecs
]),
VoicePid = spawn(fun() -> voice_server_loop(VoiceStates) end),
Base = guild_init:init_base_state(#{
id => ?GUILD_ID,
data => Data,
sessions => #{},
voice_states => VoiceStates,
disable_permission_cache_updates => false,
disable_guild_count_cache_updates => true
}),
S1 = guild_init:init_member_list(Base),
S2 = S1#{
virtual_channel_access => maps:map(
fun(_, Chs) -> sets:from_list(Chs) end, VirtualAccess
),
voice_server_pid => VoicePid,
test_permission_sync_fun => fun(G, C, U, Conn, P) ->
?RECORDER ! {rec, self(), {voice_hook, G, C, U, Conn, P}},
ok
end
},
S3 = lists:foldl(
fun(SessionSpec, Acc) -> add_session(SessionSpec, Collectors, Acc) end, S2, SessionSpecs
),
S4 = lists:foldl(
fun({Sid, ListId}, Acc) ->
{Acc1, _, _} = guild_member_list:subscribe_ranges(Sid, ListId, [{0, 99}], Acc),
Acc1
end,
S3,
ListSubs
),
S5 = S4#{
member_subscriptions => lists:foldl(
fun({Sid, UserId}, Acc) -> guild_subscriptions:subscribe(Sid, UserId, Acc) end,
guild_subscriptions:init_state(),
MemberSubs
),
dm_partners => maps:from_list([
{Sid, #{
user_id => UserId,
pid => maps:get(Label, Collectors),
partners => maps:from_keys(Partners, true),
eligible => #{}
}}
|| {Sid, Label, UserId, Partners} <- DmPartners
])
},
S6 = guild_dm_partners:maybe_reevaluate(
guild_role_update,
#{},
#{},
guild_maintenance:maybe_prune_invalid_member_subscriptions(
guild_role_update, guild_member_list:flush_pending_member_list_syncs(S5)
)
),
ok = guild_permission_cache:put_state(S6),
Labels = maps:merge(
maps:from_list([{Pid, {session_pid, Label}} || {Label, Pid} <- maps:to_list(Collectors)]),
#{VoicePid => voice_server, self() => guild}
),
Env = #{collectors => maps:values(Collectors), labels => Labels, voice_pid => VoicePid},
_ = take_log(Env),
{S6, Env}.
add_session(#{label := Label, user_id := UserId, pending := Pending}, Collectors, State) ->
Sid = session_id(Label),
Pid = maps:get(Label, Collectors),
Viewable =
case Pending of
true ->
#{};
false ->
guild_sessions:build_viewable_channel_map(
guild_visibility:get_user_viewable_channels(UserId, State)
)
end,
Session = #{
session_id => Sid,
user_id => UserId,
pid => Pid,
mref => make_ref(),
active_guilds => sets:new(),
user_roles => [],
bot => false,
is_staff => false,
pending_connect => Pending,
viewable_channels => Viewable
},
ets:insert(maps:get(member_presence, State), {UserId, #{<<"status">> => <<"online">>}}),
Sessions = maps:get(sessions, State, #{}),
Counts = maps:get(user_session_counts, State, #{}),
State#{
sessions => Sessions#{Sid => Session},
connected_user_ids => sets:add_element(UserId, maps:get(connected_user_ids, State)),
user_session_counts => Counts#{UserId => maps:get(UserId, Counts, 0) + 1}
}.
teardown(State, #{collectors := Collectors, voice_pid := VoicePid}) ->
_ = guild_member_list_channel_engine:destroy_all(State),
Data = maps:get(data, State),
lists:foreach(
fun(Tab) -> catch ets:delete(Tab) end,
[
maps:get(members_ets, Data),
maps:get(member_presence, State),
maps:get(member_list_subscriptions, State),
maps:get(viewable_channels_cache, State)
]
),
[Pid ! stop || Pid <- Collectors],
VoicePid ! stop,
guild_permission_cache:delete(?GUILD_ID),
ok.
normalize_state(State, Env) ->
Engines = maps:get(channel_member_list_engines, State, #{}),
EngineSnapshots = maps:map(
fun(ListId, Ref) ->
{
guild_member_list:member_list_snapshot(ListId, State),
guild_member_list_engine:get_sorted_user_ids(Ref),
guild_member_list_engine:get_counts(Ref),
guild_member_list_engine:get_groups(Ref)
}
end,
Engines
),
Stripped = maps:without(
[
channel_member_list_engines,
channel_member_list_engine_inputs,
member_list_engine,
test_permission_sync_fun
],
State
),
{EngineSnapshots, normalize(Stripped, Env)}.
normalize(Term, #{labels := Labels}) ->
normalize_term(Term, Labels).
normalize_term(Pid, Labels) when is_pid(Pid) ->
maps:get(Pid, Labels, pid);
normalize_term(Ref, Labels) when is_reference(Ref) ->
case catch ets:tab2list(Ref) of
Rows when is_list(Rows) -> {ets, lists:sort(normalize_term(Rows, Labels))};
_ -> ref
end;
normalize_term(Fun, _Labels) when is_function(Fun) ->
'fun';
normalize_term({relay, dispatch_many, [Pids | Rest]}, Labels) when is_list(Pids) ->
{relay, dispatch_many, [
lists:sort(normalize_term(Pids, Labels)) | normalize_term(Rest, Labels)
]};
normalize_term(List, Labels) when is_list(List) ->
[normalize_term(E, Labels) || E <- List];
normalize_term(Tuple, Labels) when is_tuple(Tuple) ->
list_to_tuple(normalize_term(tuple_to_list(Tuple), Labels));
normalize_term(Map, Labels) when is_map(Map) ->
maps:from_list([
{normalize_term(K, Labels), normalize_term(V, Labels)}
|| {K, V} <- maps:to_list(Map)
]);
normalize_term(Other, _Labels) ->
Other.
summarize_event({channel_update, Data}) ->
{channel_update, maps:get(<<"id">>, Data)};
summarize_event({channel_update_bulk, #{<<"channels">> := Channels}}) ->
{channel_update_bulk, length(Channels)};
summarize_event({hook, Hook}) ->
{hook, Hook};
summarize_event({Kind, Data}) ->
{Kind, Data}.
seed_count() ->
env_int("CHMOVE_SEEDS", ?DEFAULT_SEEDS).
env_int(Name, Default) ->
case os:getenv(Name) of
false -> Default;
"" -> Default;
Value -> list_to_integer(Value)
end.
session_id(Label) ->
<<"s", (integer_to_binary(Label))/binary>>.
session_map(Sid, State) ->
maps:get(viewable_channels, maps:get(Sid, maps:get(sessions, State))).
user_id(I) -> ?USER_BASE + I.
role_id(I) -> ?ROLE_BASE + I.
view() -> constants:view_channel_permission().
connect() -> constants:connect_permission().
speak() -> constants:speak_permission().
stream() -> constants:stream_permission().
members_view() -> constants:view_channel_members_permission().
admin() -> constants:administrator_permission().
hidden_overwrite() ->
overwrite(?GUILD_ID, 0, 0, view()).
position_move(Spec) ->
bulk_event(renumber(lists:reverse(spec_layout_after_hooks(Spec)))).
gap_spec(InitialParent) ->
Hidden = ?CHAN_BASE + 500,
Parent =
case InitialParent of
Hidden -> null;
_ -> Hidden
end,
Layout = renumber([
channel(Hidden, 4, null, [overwrite(?GUILD_ID, 0, 0, view())]),
channel(?CHAN_BASE + 501, 0, Parent, [])
]),
single_session_spec(Layout).
single_session_spec(Layout) ->
Spec = ticker_spec(),
Data = maps:get(data, Spec),
Spec#{
data => Data#{<<"channels">> => Layout},
voice_states => #{},
list_subscriptions => [],
sessions => [
#{label => 1, user_id => user_id(2), pending => false}
],
member_subscriptions => []
}.
replace(Channel, Layout) ->
Id = channel_int_id(Channel),
[
case channel_int_id(C) of
Id -> Channel;
_ -> C
end
|| C <- Layout
].
gen_spec(Seed) ->
rand:seed(exsss, {Seed, 104729, 7919}),
NumRoles = between(3, 8),
NumMembers = between(20, 150),
RoleIds = [role_id(R) || R <- lists:seq(1, NumRoles)],
UserIds = [user_id(I) || I <- lists:seq(1, NumMembers)],
Roles = [
everyone_role() | [role(R, Id) || {R, Id} <- lists:zip(lists:seq(1, NumRoles), RoleIds)]
],
Members = [member(U, pick_subset(RoleIds, 0.25)) || U <- UserIds],
Layout = gen_layout(RoleIds, UserIds),
Data = #{
<<"guild">> => guild(user_id(1)),
<<"roles">> => Roles,
<<"members">> => Members,
<<"channels">> => Layout,
<<"emojis">> => [],
<<"stickers">> => []
},
ChannelIds = [channel_int_id(C) || C <- Layout],
VoiceChannelIds = [channel_int_id(C) || C <- Layout, maps:get(<<"type">>, C) =:= 2],
SessionSpecs = [gen_session(L, UserIds) || L <- lists:seq(1, between(5, 40))],
SessionUsers = [U || #{user_id := U} <- SessionSpecs],
VirtualAccess = gen_virtual_access(SessionUsers, ChannelIds),
#{
data => Data,
sessions => SessionSpecs,
virtual_access => VirtualAccess,
hooks => gen_hooks(VirtualAccess, SessionUsers, UserIds, NumRoles, Layout),
voice_states => gen_voice_states(SessionUsers ++ UserIds, VoiceChannelIds),
list_subscriptions => [
{session_id(L), integer_to_binary(pick(ChannelIds))}
|| #{label := L} <- SessionSpecs, rand:uniform() < 0.5
],
member_subscriptions => [
{session_id(L), pick(UserIds)}
|| #{label := L} <- SessionSpecs, _ <- lists:seq(1, between(0, 4))
],
dm_partners => [
{session_id(L), L, U, [pick(UserIds) || _ <- lists:seq(1, between(1, 5))]}
|| #{label := L, user_id := U} <- SessionSpecs, rand:uniform() < 0.3
]
}.
layout(#{data := #{<<"channels">> := Layout}}) ->
Layout.
everyone_role() ->
Base = view() bor members_view() bor connect() bor speak(),
Perms =
case rand:uniform() < 0.15 of
true -> Base band bnot view();
false -> Base
end,
#{
<<"id">> => integer_to_binary(?GUILD_ID),
<<"name">> => <<"@everyone">>,
<<"permissions">> => integer_to_binary(Perms),
<<"position">> => 0,
<<"hoist">> => false
}.
role(Position, Id) ->
#{
<<"id">> => integer_to_binary(Id),
<<"name">> => <<"r", (integer_to_binary(Position))/binary>>,
<<"permissions">> => integer_to_binary(random_role_permissions()),
<<"position">> => Position,
<<"hoist">> => rand:uniform() < 0.4,
<<"color">> => 0
}.
random_role_permissions() ->
Bits = lists:foldl(
fun(Bit, Acc) -> Acc bor Bit end,
0,
pick_subset([view(), connect(), speak(), stream(), members_view()], 0.4)
),
case rand:uniform() < 0.05 of
true -> Bits bor admin();
false -> Bits
end.
plain_role(Position, Id) ->
(role(Position, Id))#{<<"permissions">> => <<"0">>, <<"hoist">> => false}.
member(UserId, Roles) ->
#{
<<"user">> => #{
<<"id">> => integer_to_binary(UserId),
<<"username">> => <<"u", (integer_to_binary(UserId))/binary>>,
<<"global_name">> => null,
<<"avatar">> => null,
<<"discriminator">> => <<"0001">>,
<<"bot">> => false
},
<<"roles">> => [integer_to_binary(R) || R <- Roles],
<<"nick">> => null,
<<"joined_at">> => <<"2026-08-01T00:00:00Z">>
}.
gen_layout(RoleIds, UserIds) ->
NumCats = between(2, 4),
Top = [
channel(?CHAN_BASE + 10 + I, pick([0, 0, 2]), null, gen_overwrites(RoleIds, UserIds))
|| I <- lists:seq(1, between(1, 3))
],
Cats = lists:append([
gen_category(C, RoleIds, UserIds)
|| C <- lists:seq(1, NumCats)
]),
Odd =
case rand:uniform() < 0.3 of
true ->
[TopParent | _] = Top,
[
channel(
?CHAN_BASE + 900,
0,
pick([channel_int_id(TopParent), ?DANGLING_PARENT]),
gen_overwrites(RoleIds, UserIds)
)
];
false ->
[]
end,
renumber(Top ++ Cats ++ Odd).
gen_category(C, RoleIds, UserIds) ->
CatId = ?CHAN_BASE + 100 * C,
CatOverwrites =
case rand:uniform() < 0.4 of
true -> [overwrite(?GUILD_ID, 0, 0, view())];
false -> gen_overwrites(RoleIds, UserIds)
end,
Children = [
channel(CatId + I, pick([0, 2]), CatId, gen_child_overwrites(RoleIds, UserIds))
|| I <- lists:seq(1, between(0, 4))
],
[channel(CatId, 4, null, CatOverwrites) | Children].
gen_child_overwrites(RoleIds, UserIds) ->
case rand:uniform() of
X when X < 0.2 ->
[overwrite(pick(RoleIds), 0, view(), 0)];
X when X < 0.3 ->
[overwrite(pick(UserIds), 1, view(), 0)];
_ ->
gen_overwrites(RoleIds, UserIds)
end.
gen_overwrites(RoleIds, UserIds) ->
[gen_overwrite(RoleIds, UserIds) || _ <- lists:seq(1, between(0, 3))].
gen_overwrite(RoleIds, UserIds) ->
{Id, Type} =
case rand:uniform(3) of
1 -> {?GUILD_ID, 0};
2 -> {pick(RoleIds), 0};
3 -> {pick(UserIds), 1}
end,
Bits = [view(), connect(), speak(), stream()],
Allow = lists:foldl(fun(B, A) -> A bor B end, 0, pick_subset(Bits, 0.3)),
Deny = lists:foldl(fun(B, A) -> A bor B end, 0, pick_subset(Bits, 0.3)) band bnot Allow,
overwrite(Id, Type, Allow, Deny).
overwrite(Id, Type, Allow, Deny) ->
#{
<<"id">> => integer_to_binary(Id),
<<"type">> => Type,
<<"allow">> => integer_to_binary(Allow),
<<"deny">> => integer_to_binary(Deny)
}.
channel(Id, Type, Parent, Overwrites) ->
Base = #{
<<"id">> => integer_to_binary(Id),
<<"guild_id">> => integer_to_binary(?GUILD_ID),
<<"type">> => Type,
<<"name">> => <<"ch-", (integer_to_binary(Id))/binary>>,
<<"position">> => 0,
<<"permission_overwrites">> => Overwrites
},
case Type of
4 -> Base;
_ -> Base#{<<"parent_id">> => parent_bin(Parent)}
end.
parent_bin(null) -> null;
parent_bin(Id) -> integer_to_binary(Id).
channel_int_id(Channel) ->
snowflake_id:parse_maybe(maps:get(<<"id">>, Channel)).
channel_parent(Channel) ->
case maps:get(<<"parent_id">>, Channel, null) of
null -> null;
P -> binary_to_integer(P)
end.
renumber(Layout) ->
[C#{<<"position">> => P} || {P, C} <- lists:zip(lists:seq(1, length(Layout)), Layout)].
gen_session(Label, UserIds) ->
#{
label => Label,
user_id => pick(UserIds),
pending => rand:uniform() < 0.05
}.
gen_virtual_access(UserIds, ChannelIds) ->
maps:from_list([
{pick(UserIds), [pick(ChannelIds) || _ <- lists:seq(1, between(1, 2))]}
|| _ <- lists:seq(1, between(0, 4))
]).
gen_hooks(VirtualAccess, SessionUsers, UserIds, NumRoles, Layout) ->
ChannelIds = [channel_int_id(C) || C <- Layout],
Held = [{U, C} || {U, Cs} <- maps:to_list(VirtualAccess), C <- Cs],
lists:append([
gen_hook(Held, SessionUsers, UserIds, NumRoles, Layout, ChannelIds, I)
|| I <- lists:seq(1, pick([0, 0, 1, 2, 3]))
]).
gen_hook(Held, SessionUsers, UserIds, NumRoles, Layout, ChannelIds, I) ->
case rand:uniform(10) of
1 when Held =/= [] ->
{U, C} = pick(Held),
[{remove_virtual_channel_access, U, C}];
2 ->
[{add_virtual_channel_access, pick(UserIds), pick(ChannelIds)}];
3 ->
U = pick(UserIds),
C = pick(ChannelIds),
[
{add_virtual_channel_access, U, C},
{member_update, U},
{remove_virtual_channel_access, U, C}
];
4 ->
[{owner, pick(SessionUsers ++ UserIds)}];
5 ->
Other = pick(UserIds),
[
{owner, user_id(1)},
{role_permissions, 1, random_role_permissions()},
{owner, Other},
{member_update, user_id(1)},
{member_update, Other},
{owner, user_id(1)}
];
6 ->
[{channel_create, created_channel(Layout, I)}];
7 ->
[{ensure_list, pick(ChannelIds)}];
8 ->
Index = between(1, NumRoles),
[
{role_permissions, Index, random_role_permissions()},
{member_update, pick(UserIds)}
];
9 ->
[{member_update, pick(UserIds)}];
_ ->
[
{ensure_list, pick(ChannelIds)},
{add_virtual_channel_access, pick(SessionUsers ++ UserIds), pick(ChannelIds)}
]
end.
created_channel(Layout, I) ->
Cats = [channel_int_id(C) || C <- Layout, maps:get(<<"type">>, C) =:= 4],
Parent =
case Cats of
[] -> null;
_ -> pick([null | Cats])
end,
RoleIds = [role_id(R) || R <- lists:seq(1, 3)],
UserIds = [user_id(U) || U <- lists:seq(1, 20)],
channel(?CHAN_BASE + 950 + I, pick([0, 2]), Parent, gen_overwrites(RoleIds, UserIds)).
gen_voice_states(_UserIds, []) ->
#{};
gen_voice_states(UserIds, VoiceChannelIds) ->
maps:from_list([
begin
Conn = <<"c", (integer_to_binary(I))/binary>>,
{Conn, #{
<<"user_id">> => integer_to_binary(pick(UserIds)),
<<"channel_id">> => integer_to_binary(pick(VoiceChannelIds)),
<<"guild_id">> => integer_to_binary(?GUILD_ID),
<<"connection_id">> => Conn,
<<"session_id">> => <<"vs", (integer_to_binary(I))/binary>>,
<<"self_mute">> => false,
<<"self_deaf">> => false,
<<"self_stream">> => rand:uniform() < 0.3,
<<"self_video">> => false,
<<"deaf">> => false,
<<"mute">> => false
}}
end
|| I <- lists:seq(1, between(0, 8))
]).
gen_events(Seed, Layout0) ->
rand:seed(exsss, {Seed, 15485863, 32452843}),
{Events, _} = lists:foldl(
fun(_, {Acc, Layout}) ->
{Event, Layout1} = gen_event(Layout),
Interleaved =
case rand:uniform() < 0.25 of
true -> [Event, {hook, {member_update, user_id(between(1, 20))}}];
false -> [Event]
end,
{lists:reverse(Interleaved, Acc), Layout1}
end,
{[], Layout0},
lists:seq(1, between(1, 4))
),
lists:reverse(Events).
gen_event(Layout) ->
NonCats = [C || C <- Layout, maps:get(<<"type">>, C) =/= 4],
Kinds =
[position_only, noop, overwrite, name, subset, category_overwrite] ++
case NonCats of
[] ->
[];
_ ->
[
move,
move,
move,
single_move,
single_move,
subset_move,
type_change,
mixed
]
end,
gen_event(pick(Kinds), Layout, NonCats).
gen_event(position_only, Layout, _NonCats) ->
New = renumber(rotate(Layout)),
{bulk_event(New), New};
gen_event(noop, Layout, _NonCats) ->
{bulk_event(Layout), Layout};
gen_event(move, Layout, NonCats) ->
Ids = lists:usort([channel_int_id(pick(NonCats)) || _ <- lists:seq(1, between(1, 3))]),
New = move_channels(Ids, pick_target(Layout), Layout),
{bulk_event(New), New};
gen_event(single_move, Layout, NonCats) ->
Id = channel_int_id(pick(NonCats)),
New = move_channels([Id], pick_target(Layout), Layout),
{{channel_update, find(Id, New)}, New};
gen_event(subset_move, Layout, NonCats) ->
Ids = lists:usort([channel_int_id(pick(NonCats)) || _ <- lists:seq(1, between(1, 3))]),
New = move_channels(Ids, pick_target(Layout), Layout),
{bulk_event([C || C <- New, lists:member(channel_int_id(C), Ids)]), New};
gen_event(overwrite, Layout, _NonCats) ->
Id = channel_int_id(pick(Layout)),
New = update_channel(
Id, fun(C) -> C#{<<"permission_overwrites">> => random_overwrites(Layout)} end, Layout
),
single_or_bulk(Id, New);
gen_event(category_overwrite, Layout, _NonCats) ->
case [C || C <- Layout, maps:get(<<"type">>, C) =:= 4] of
[] ->
{bulk_event(Layout), Layout};
Cats ->
Id = channel_int_id(pick(Cats)),
Toggle = fun(C) ->
case maps:get(<<"permission_overwrites">>, C) of
[] ->
C#{<<"permission_overwrites">> => [overwrite(?GUILD_ID, 0, 0, view())]};
_ ->
C#{<<"permission_overwrites">> => []}
end
end,
single_or_bulk(Id, update_channel(Id, Toggle, Layout))
end;
gen_event(name, Layout, _NonCats) ->
Id = channel_int_id(pick(Layout)),
New = update_channel(Id, fun(C) -> C#{<<"name">> => <<"renamed">>} end, Layout),
single_or_bulk(Id, New);
gen_event(type_change, Layout, NonCats) ->
Id = channel_int_id(pick(NonCats)),
Flip = fun(C) ->
case maps:get(<<"type">>, C) of
0 -> C#{<<"type">> => 2};
_ -> C#{<<"type">> => 0}
end
end,
single_or_bulk(Id, update_channel(Id, Flip, Layout));
gen_event(subset, Layout, _NonCats) ->
Subset = pick_subset(Layout, 0.4),
SubsetIds = [channel_int_id(C) || C <- Subset],
New = renumber(rotate(Layout)),
{
bulk_event([C || C <- New, lists:member(channel_int_id(C), SubsetIds)]),
merge_subset(Layout, New, SubsetIds)
};
gen_event(mixed, Layout, NonCats) ->
Id = channel_int_id(pick(NonCats)),
Moved = move_channels([Id], pick_target(Layout), Layout),
Other = channel_int_id(pick(Moved)),
New = update_channel(
Other, fun(C) -> C#{<<"permission_overwrites">> => random_overwrites(Moved)} end, Moved
),
{bulk_event(New), New}.
single_or_bulk(Id, New) ->
case rand:uniform() < 0.5 of
true -> {{channel_update, find(Id, New)}, New};
false -> {bulk_event(New), New}
end.
bulk_event(Channels) ->
{channel_update_bulk, #{<<"channels">> => Channels}}.
merge_subset(Old, New, SubsetIds) ->
[
case lists:member(channel_int_id(C), SubsetIds) of
true -> find(channel_int_id(C), New);
false -> C
end
|| C <- Old
].
random_overwrites(Layout) ->
RoleIds = [role_id(R) || R <- lists:seq(1, 3)],
UserIds = [user_id(I) || I <- lists:seq(1, 20)],
case rand:uniform() < 0.2 of
true ->
Existing = maps:get(<<"permission_overwrites">>, pick(Layout)),
Existing;
false ->
gen_overwrites(RoleIds, UserIds)
end.
pick_target(Layout) ->
Cats = [channel_int_id(C) || C <- Layout, maps:get(<<"type">>, C) =:= 4],
NonCats = [channel_int_id(C) || C <- Layout, maps:get(<<"type">>, C) =/= 4],
case rand:uniform() of
X when X < 0.65, Cats =/= [] -> {parent, pick(Cats)};
X when X < 0.85 -> {parent, null};
X when X < 0.93, NonCats =/= [] -> {parent, pick(NonCats)};
_ -> {parent, ?DANGLING_PARENT}
end.
move_channels(Ids, {parent, Target}, Layout) ->
Moving = [
C#{<<"parent_id">> => parent_bin(Target)}
|| C <- Layout, lists:member(channel_int_id(C), Ids)
],
Rest = [C || C <- Layout, not lists:member(channel_int_id(C), Ids)],
renumber(insert_after(Target, Moving, Rest)).
insert_after(Target, Moving, Rest) ->
case lists:splitwith(fun(C) -> channel_int_id(C) =/= Target end, Rest) of
{Before, [T | After]} ->
{Kids, Tail} = lists:splitwith(fun(C) -> channel_parent(C) =:= Target end, After),
Before ++ [T | Kids] ++ Moving ++ Tail;
{_, []} ->
Moving ++ Rest
end.
update_channel(Id, Fun, Layout) ->
[
case channel_int_id(C) of
Id -> Fun(C);
_ -> C
end
|| C <- Layout
].
find(Id, Layout) ->
hd([C || C <- Layout, channel_int_id(C) =:= Id]).
rotate([]) -> [];
rotate([H | T]) -> T ++ [H].
between(Lo, Hi) ->
Lo + rand:uniform(Hi - Lo + 1) - 1.
pick(List) ->
lists:nth(rand:uniform(length(List)), List).
pick_subset(List, P) ->
[E || E <- List, rand:uniform() < P].
ticker_category() ->
?CHAN_BASE + 100.
ticker_ids() ->
[?CHAN_BASE + 60 + I || I <- lists:seq(1, 3)].
ticker_spec() ->
rand:seed(exsss, {1, 2, 3}),
UserIds = [user_id(I) || I <- lists:seq(1, 60)],
RoleIds = [role_id(1), role_id(2)],
Tickers = [
channel(Id, 2, null, [overwrite(?GUILD_ID, 0, 0, connect())])
|| Id <- ticker_ids()
],
Public = [
channel(ticker_category(), 4, null, []),
channel(ticker_category() + 1, 0, ticker_category(), []),
channel(ticker_category() + 2, 2, ticker_category(), [])
],
Private = [
channel(?CHAN_BASE + 200, 4, null, [overwrite(?GUILD_ID, 0, 0, view())]),
channel(?CHAN_BASE + 201, 0, ?CHAN_BASE + 200, [
overwrite(?GUILD_ID, 0, 0, view()), overwrite(role_id(1), 0, view(), 0)
])
],
Layout = renumber(Tickers ++ Public ++ Private),
Sessions = [
#{label => L, user_id => user_id(L), pending => false}
|| L <- lists:seq(1, 20)
],
#{
data => #{
<<"guild">> => guild(user_id(1)),
<<"roles">> => [
#{
<<"id">> => integer_to_binary(?GUILD_ID),
<<"permissions">> => integer_to_binary(
view() bor members_view() bor connect() bor speak()
),
<<"position">> => 0
}
| [role(P, Id) || {P, Id} <- lists:zip([1, 2], RoleIds)]
],
<<"members">> => [member(U, pick_subset(RoleIds, 0.3)) || U <- UserIds],
<<"channels">> => Layout,
<<"emojis">> => [],
<<"stickers">> => []
},
sessions => Sessions,
virtual_access => #{},
voice_states => gen_voice_states(UserIds, [ticker_category() + 2]),
list_subscriptions => [
{
session_id(L),
integer_to_binary(pick([ticker_category() + 1, ticker_category() + 2]))
}
|| L <- lists:seq(1, 20)
],
member_subscriptions => [{session_id(L), pick(UserIds)} || L <- lists:seq(1, 20)],
dm_partners => []
}.
hidden_voice_spec() ->
Spec = ticker_spec(),
Hidden = ?CHAN_BASE + 202,
Data = maps:get(data, Spec),
Layout = renumber(
maps:get(<<"channels">>, Data) ++
[channel(Hidden, 2, ?CHAN_BASE + 200, [overwrite(?GUILD_ID, 0, 0, view())])]
),
Voice = #{
<<"hv">> => #{
<<"user_id">> => integer_to_binary(user_id(5)),
<<"channel_id">> => integer_to_binary(Hidden),
<<"guild_id">> => integer_to_binary(?GUILD_ID),
<<"connection_id">> => <<"hv">>,
<<"session_id">> => <<"hvs">>,
<<"self_stream">> => true,
<<"deaf">> => false
}
},
Spec#{data => Data#{<<"channels">> => Layout}, voice_states => Voice}.