Make the music list, player list and inspiration list their own scenes
MusicListPanel, PlayersPanel (was Players) and InspirationList (was ScrollContainer) each get their own scene and script. They report what happens through signals, and the quiz wires them to the playlist, the music player, the log and the popups. Every node keeps its place on screen. Screenshots of every UI state are pixel-identical to before, the layout test still matches the original baseline, and each part is tested on its own as well as through the quiz.
This commit is contained in:
1 parent
3c7b4e5c9d
commit
8c1987807c
24 files changed
+932
-466
No files matched your search
@@ -77,16 +77,16 @@ func _play_first_song() -> void:
|
||||
## Adds a player through the name field, the same way the user does.
|
||||
func _add_player(player_name: String) -> PlayerObject:
|
||||
var name_field: TextEdit = _quiz.get_node(
|
||||
"Players/VBoxContainer/AddPlayerContainer/PlayerNameField")
|
||||
"PlayersPanel/VBoxContainer/AddPlayerContainer/PlayerNameField")
|
||||
name_field.text = player_name
|
||||
_quiz.add_player()
|
||||
_quiz.players_panel.add_player()
|
||||
return Settings.player_array.back() if !Settings.player_array.is_empty() else null
|
||||
|
||||
|
||||
## Returns the row in the player list that shows [param player_object].
|
||||
func _player_row(player_object: PlayerObject) -> Player:
|
||||
var rows: Node = _quiz.get_node(
|
||||
"Players/VBoxContainer/ScrollContainer/PlayersListVBoxContainer")
|
||||
"PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer")
|
||||
for row: Player in rows.get_children():
|
||||
if row.player_object == player_object:
|
||||
return row
|
||||
|
||||
@@ -179,11 +179,14 @@ func _node(node_name: String) -> Node:
|
||||
return null
|
||||
|
||||
|
||||
## Calls [param method] on the main window, or on its Quiz scene if that has it.
|
||||
## Calls [param method] on the players panel, the quiz or the main window, whichever
|
||||
## has it first.
|
||||
func _call(method: String, args: Array = []) -> Variant:
|
||||
var quiz: Node = _main.get_node_or_null("Quiz")
|
||||
var target: Node = quiz if quiz != null && quiz.has_method(method) else _main
|
||||
return target.callv(method, args)
|
||||
for path: String in ["Quiz/PlayersPanel", "Quiz"]:
|
||||
var target: Node = _main.get_node_or_null(path)
|
||||
if target != null && target.has_method(method):
|
||||
return target.callv(method, args)
|
||||
return _main.callv(method, args)
|
||||
|
||||
|
||||
func _server() -> FakeHttpTransport:
|
||||
@@ -196,13 +199,13 @@ func _settle() -> void:
|
||||
|
||||
|
||||
func _add_player(player_name: String) -> void:
|
||||
(_node("Players").get_node("VBoxContainer/AddPlayerContainer/PlayerNameField") \
|
||||
(_node("PlayersPanel").get_node("VBoxContainer/AddPlayerContainer/PlayerNameField") \
|
||||
as TextEdit).text = player_name
|
||||
_call("add_player")
|
||||
|
||||
|
||||
func _player_row(index: int) -> Player:
|
||||
return _node("Players").get_node(
|
||||
return _node("PlayersPanel").get_node(
|
||||
"VBoxContainer/ScrollContainer/PlayersListVBoxContainer").get_child(index)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
extends GdUnitTestSuite
|
||||
## The inspiration list on its own: it shows every soundtrack from the server and
|
||||
## scrolls by itself.
|
||||
|
||||
const SOUNDTRACKS: Array[String] = ["Chrono Trigger", "Final Fantasy VI", "Secret of Mana"]
|
||||
|
||||
var _snapshot: AutoloadSnapshot
|
||||
var _server: FakeHttpTransport
|
||||
var _runner: GdUnitSceneRunner
|
||||
var _list: InspirationList
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
_snapshot = AutoloadSnapshot.take([Settings])
|
||||
Settings.inspiration_list_speed = 0
|
||||
_server = FakeHttpTransport.new().install()
|
||||
_server.respond_json("/music/all", SOUNDTRACKS)
|
||||
_runner = scene_runner("res://InspirationList.tscn")
|
||||
_list = _runner.scene()
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
await await_idle_frame()
|
||||
_server.uninstall()
|
||||
_snapshot.restore()
|
||||
|
||||
|
||||
func _rows() -> Array[Node]:
|
||||
return _list.get_node("VBoxContainer").get_children()
|
||||
|
||||
|
||||
func _texts() -> Array:
|
||||
return _rows().map(func(row: ListRow) -> String: return row.text)
|
||||
|
||||
|
||||
func test_load_from_server_shows_every_soundtrack_in_order() -> void:
|
||||
_list.load_from_server()
|
||||
|
||||
assert_array(_texts()).contains_exactly(SOUNDTRACKS)
|
||||
assert_array(_list.soundtracks).contains_exactly(SOUNDTRACKS)
|
||||
|
||||
|
||||
func test_rows_alternate_backgrounds() -> void:
|
||||
_list.load_from_server()
|
||||
|
||||
var rows: Array[Node] = _rows()
|
||||
assert_that((rows[0] as ListRow).get_theme_stylebox("panel")) \
|
||||
.is_not_equal((rows[1] as ListRow).get_theme_stylebox("panel"))
|
||||
assert_that((rows[0] as ListRow).get_theme_stylebox("panel")) \
|
||||
.is_equal((rows[2] as ListRow).get_theme_stylebox("panel"))
|
||||
|
||||
|
||||
func test_loading_again_replaces_the_rows() -> void:
|
||||
_list.load_from_server()
|
||||
_server.respond_json("/music/all", ["Terranigma"])
|
||||
|
||||
_list.load_from_server()
|
||||
|
||||
assert_array(_texts()).contains_exactly(["Terranigma"])
|
||||
|
||||
|
||||
func test_failed_load_keeps_the_rows() -> void:
|
||||
_list.load_from_server()
|
||||
_server.respond_bytes("/music/all", PackedByteArray(), 500)
|
||||
|
||||
_list.load_from_server()
|
||||
|
||||
assert_array(_texts()).contains_exactly(SOUNDTRACKS)
|
||||
|
||||
|
||||
func _long_list() -> Array[String]:
|
||||
var names: Array[String] = []
|
||||
for i: int in 40:
|
||||
names.append("Soundtrack %d" % i)
|
||||
return names
|
||||
|
||||
|
||||
func test_scrolls_down_by_itself() -> void:
|
||||
_list.show_soundtracks(_long_list())
|
||||
Settings.inspiration_list_speed = 5
|
||||
|
||||
await _runner.simulate_frames(30, 16)
|
||||
|
||||
assert_int(_list.scroll_vertical).is_greater(0)
|
||||
|
||||
|
||||
func test_does_not_scroll_at_speed_zero() -> void:
|
||||
_list.show_soundtracks(_long_list())
|
||||
|
||||
await _runner.simulate_frames(30, 16)
|
||||
|
||||
assert_int(_list.scroll_vertical).is_equal(0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://2hfs3x1k46wu
|
||||
@@ -12,12 +12,18 @@ const BASELINE_PATH: String = "res://tests/data/layout_baseline.json"
|
||||
|
||||
## Nodes that moved from the main window into the Quiz scene, by their old name.
|
||||
const QUIZ_NODES: Array[String] = [
|
||||
"Open", "FileDialog", "MusicListPanel", "ScrollContainer", "VBoxContainer",
|
||||
"MusicPlayer", "Players", "ResetPlaylistButton", "ResetPointsButton", "SettingsButton",
|
||||
"Open", "FileDialog", "MusicListPanel", "VBoxContainer",
|
||||
"MusicPlayer", "ResetPlaylistButton", "ResetPointsButton", "SettingsButton",
|
||||
"SoundTestButton", "ShowAnswerButton", "NextButton", "RepeatSongCheckButton",
|
||||
"DebugLabel", "SettingsWindow", "NewCharacterSelect", "WinnerWindow", "Log",
|
||||
]
|
||||
|
||||
## Nodes that moved into the Quiz scene and were renamed there: old name to new name.
|
||||
const RENAMED_QUIZ_NODES: Dictionary = {
|
||||
"Players": "PlayersPanel",
|
||||
"ScrollContainer": "InspirationList",
|
||||
}
|
||||
|
||||
## Nodes that moved from the main window into the TopBar scene, by their old name.
|
||||
const TOP_BAR_NODES: Array[String] = [
|
||||
"TopPanelContainer", "Title", "SearchButton", "SyncButton", "StatisticsButton",
|
||||
@@ -60,6 +66,8 @@ static func new_path(old_path: String) -> String:
|
||||
var first: String = old_path.get_slice("/", 0)
|
||||
if first in QUIZ_NODES:
|
||||
return "Quiz/" + old_path
|
||||
if first in RENAMED_QUIZ_NODES:
|
||||
return "Quiz/" + RENAMED_QUIZ_NODES[first] + old_path.trim_prefix(first)
|
||||
if first in TOP_BAR_NODES:
|
||||
return "TopBar/" + old_path
|
||||
return old_path
|
||||
|
||||
@@ -240,12 +240,3 @@ func test_log_adds_one_row_per_point_with_padded_time() -> void:
|
||||
var time_pattern: RegEx = RegEx.create_from_string("^\\d\\d:\\d\\d:\\d\\d Crono got a point$")
|
||||
assert_object(time_pattern.search((log_box.get_child(0) as Label).text)).is_not_null()
|
||||
assert_str((log_box.get_child(1) as Label).text).ends_with("Crono lost a point")
|
||||
|
||||
|
||||
func test_inspiration_list_alternates_row_backgrounds() -> void:
|
||||
var rows: Array[Node] = _quiz.get_node("ScrollContainer/VBoxContainer").get_children()
|
||||
|
||||
assert_int(rows.size()).is_equal(2)
|
||||
assert_str((rows[0] as ListRow).text).is_equal("Chrono Trigger")
|
||||
assert_that((rows[0] as ListRow).get_theme_stylebox("panel")) \
|
||||
.is_not_equal((rows[1] as ListRow).get_theme_stylebox("panel"))
|
||||
@@ -0,0 +1,79 @@
|
||||
extends GdUnitTestSuite
|
||||
## The music list on its own: one row per song in the playlist, and clicking a row
|
||||
## tells which song was clicked.
|
||||
|
||||
var _snapshot: AutoloadSnapshot
|
||||
var _runner: GdUnitSceneRunner
|
||||
var _panel: MusicListPanel
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
_snapshot = AutoloadSnapshot.take([Settings, Playlist])
|
||||
Playlist.reset_playlist()
|
||||
for i: int in 3:
|
||||
var song: SongObject = SongObject.new()
|
||||
song.song_number = i
|
||||
song.soundtrack_title = "Soundtrack %d" % i
|
||||
song.song_title = "Song %d" % i
|
||||
song.has_played = true
|
||||
Playlist.add_to_playlist(song)
|
||||
_runner = scene_runner("res://MusicListPanel.tscn")
|
||||
_panel = _runner.scene()
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
await await_idle_frame()
|
||||
_snapshot.restore()
|
||||
|
||||
|
||||
func _rows() -> Array[Node]:
|
||||
return _panel.get_node("ScrollContainer/MusicList").get_children()
|
||||
|
||||
|
||||
func test_starts_empty() -> void:
|
||||
assert_array(_rows()).is_empty()
|
||||
|
||||
|
||||
func test_refresh_shows_one_row_per_song_in_order() -> void:
|
||||
_panel.refresh()
|
||||
|
||||
assert_array(_rows().map(func(row: SongListObject) -> int: return row.song_object.song_number)) \
|
||||
.contains_exactly([0, 1, 2])
|
||||
|
||||
|
||||
func test_refresh_again_replaces_the_rows() -> void:
|
||||
_panel.refresh()
|
||||
Playlist.song_object_array.pop_back()
|
||||
|
||||
_panel.refresh()
|
||||
|
||||
assert_int(_rows().size()).is_equal(2)
|
||||
|
||||
|
||||
func test_clicking_a_row_tells_which_song() -> void:
|
||||
_panel.refresh()
|
||||
await _runner.simulate_frames(2)
|
||||
var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel))
|
||||
var row: Control = _rows()[1]
|
||||
# The left end of the row is the song text, not one of the player icons
|
||||
var target: Vector2 = row.get_global_rect().position + Vector2(20, row.size.y / 2)
|
||||
|
||||
_runner.set_mouse_position(_panel.get_viewport().get_final_transform() * target)
|
||||
await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_LEFT)
|
||||
await _runner.await_input_processed()
|
||||
|
||||
await signals.is_emitted("song_clicked", [1])
|
||||
|
||||
|
||||
func test_right_click_does_nothing() -> void:
|
||||
_panel.refresh()
|
||||
await _runner.simulate_frames(2)
|
||||
var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel))
|
||||
var row: Control = _rows()[0]
|
||||
|
||||
_runner.set_mouse_position(_panel.get_viewport().get_final_transform()
|
||||
* (row.get_global_rect().position + Vector2(20, row.size.y / 2)))
|
||||
await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
|
||||
await _runner.await_input_processed()
|
||||
|
||||
await signals.wait_until(200).is_not_emitted("song_clicked")
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1dj75bnbdjle
|
||||
@@ -144,7 +144,7 @@ func test_player_list_shows_rows_in_player_order() -> void:
|
||||
var robo: PlayerObject = _add_player("Robo")
|
||||
|
||||
var rows: Array[Node] = _quiz.get_node(
|
||||
"Players/VBoxContainer/ScrollContainer/PlayersListVBoxContainer").get_children()
|
||||
"PlayersPanel/VBoxContainer/ScrollContainer/PlayersListVBoxContainer").get_children()
|
||||
|
||||
assert_array(rows.map(func(row: Player) -> PlayerObject: return row.player_object)) \
|
||||
.contains_exactly([_marle, _lucca, robo])
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
extends GdUnitTestSuite
|
||||
## The player list on its own: adding players through the name field, removing them
|
||||
## and asking for a new character.
|
||||
|
||||
var _snapshot: AutoloadSnapshot
|
||||
var _runner: GdUnitSceneRunner
|
||||
var _panel: PlayersPanel
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
_snapshot = AutoloadSnapshot.take([Settings])
|
||||
Settings.player_array.clear()
|
||||
Settings.edit_players = false
|
||||
_runner = scene_runner("res://PlayersPanel.tscn")
|
||||
_panel = _runner.scene()
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
await await_idle_frame()
|
||||
_snapshot.restore()
|
||||
|
||||
|
||||
func _rows() -> Array[Node]:
|
||||
return _panel.players_list_container.get_children()
|
||||
|
||||
|
||||
func _add(player_name: String) -> void:
|
||||
_panel.new_player_name_field.text = player_name
|
||||
_panel.add_player()
|
||||
|
||||
|
||||
func _click(control: Control) -> void:
|
||||
_runner.set_mouse_position(_panel.get_viewport().get_final_transform()
|
||||
* control.get_global_rect().get_center())
|
||||
await _runner.simulate_mouse_button_pressed(MOUSE_BUTTON_LEFT)
|
||||
await _runner.await_input_processed()
|
||||
|
||||
|
||||
func test_add_button_shows_and_hides_the_add_row() -> void:
|
||||
assert_bool(_panel.add_player_container.visible).is_false()
|
||||
|
||||
await _click(_panel.add_players_button)
|
||||
assert_bool(_panel.add_player_container.visible).is_true()
|
||||
assert_bool(Settings.edit_players).is_true()
|
||||
|
||||
await _click(_panel.add_players_button)
|
||||
assert_bool(_panel.add_player_container.visible).is_false()
|
||||
assert_bool(Settings.edit_players).is_false()
|
||||
|
||||
|
||||
func test_adding_a_name_adds_a_player_and_a_row() -> void:
|
||||
var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel))
|
||||
|
||||
_add("Crono")
|
||||
|
||||
assert_int(Settings.player_array.size()).is_equal(1)
|
||||
var crono: PlayerObject = Settings.player_array[0]
|
||||
assert_str(crono.player_name).is_equal("Crono")
|
||||
await signals.is_emitted("player_added", [crono])
|
||||
assert_str(_panel.new_player_name_field.text).is_empty()
|
||||
assert_object((_rows()[0] as Player).player_object).is_same(crono)
|
||||
|
||||
|
||||
func test_add_button_in_the_row_adds_the_player() -> void:
|
||||
await _click(_panel.add_players_button)
|
||||
_panel.new_player_name_field.text = "Marle"
|
||||
|
||||
await _click(_panel.add_player_button)
|
||||
|
||||
assert_int(Settings.player_array.size()).is_equal(1)
|
||||
|
||||
|
||||
func test_enter_in_the_name_field_adds_the_player() -> void:
|
||||
await _click(_panel.add_players_button)
|
||||
_panel.new_player_name_field.text = "Lucca"
|
||||
_panel.new_player_name_field.grab_focus()
|
||||
|
||||
_runner.simulate_key_pressed(KEY_ENTER)
|
||||
await _runner.await_input_processed()
|
||||
|
||||
assert_array(Settings.player_array.map(
|
||||
func(p: PlayerObject) -> String: return p.player_name)).contains_exactly(["Lucca"])
|
||||
|
||||
|
||||
func test_empty_name_adds_nobody() -> void:
|
||||
var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel))
|
||||
|
||||
_add("")
|
||||
|
||||
assert_array(Settings.player_array).is_empty()
|
||||
assert_array(_rows()).is_empty()
|
||||
await signals.wait_until(200).is_not_emitted("player_added")
|
||||
|
||||
|
||||
func test_cannot_add_more_than_max_players() -> void:
|
||||
for i: int in Settings.MAX_NUMBER_OF_PLAYERS + 1:
|
||||
_add("Player %d" % i)
|
||||
|
||||
assert_int(Settings.player_array.size()).is_equal(Settings.MAX_NUMBER_OF_PLAYERS)
|
||||
assert_int(_rows().size()).is_equal(Settings.MAX_NUMBER_OF_PLAYERS)
|
||||
|
||||
|
||||
func test_rows_follow_player_order_and_numbering() -> void:
|
||||
_add("Crono")
|
||||
_add("Marle")
|
||||
_add("Lucca")
|
||||
|
||||
assert_array(_rows().map(func(row: Player) -> String: return row.player_object.player_name)) \
|
||||
.contains_exactly(["Crono", "Marle", "Lucca"])
|
||||
assert_array(_rows().map(func(row: Player) -> int: return row.list_index)) \
|
||||
.contains_exactly([0, 1, 2])
|
||||
|
||||
|
||||
func test_remove_button_removes_the_player_and_row() -> void:
|
||||
_add("Crono")
|
||||
_add("Marle")
|
||||
var crono: PlayerObject = Settings.player_array[0]
|
||||
var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel))
|
||||
|
||||
(_rows()[0].get_node("HBoxContainer/RemovePlayer") as BaseButton).pressed.emit()
|
||||
|
||||
await signals.is_emitted("player_removed", [crono])
|
||||
assert_array(Settings.player_array.map(
|
||||
func(p: PlayerObject) -> String: return p.player_name)).contains_exactly(["Marle"])
|
||||
assert_int(_rows().size()).is_equal(1)
|
||||
assert_int((_rows()[0] as Player).list_index).is_equal(0)
|
||||
|
||||
|
||||
func test_character_button_asks_for_a_new_character() -> void:
|
||||
_add("Crono")
|
||||
var row: Player = _rows()[0]
|
||||
var signals: GdUnitSignalAssert = assert_signal(monitor_signals(_panel))
|
||||
|
||||
(row.get_node("HBoxContainer/Character") as BaseButton).pressed.emit()
|
||||
|
||||
await signals.is_emitted("change_character_requested", [row, "Crono"])
|
||||
|
||||
|
||||
func test_reload_shows_new_scores() -> void:
|
||||
_add("Crono")
|
||||
Settings.player_array[0].player_score = 3
|
||||
|
||||
_panel.reload()
|
||||
|
||||
assert_str(((_rows()[0] as Player).get_node("HBoxContainer/Name") as Label).text) \
|
||||
.ends_with("Crono: 3")
|
||||
@@ -0,0 +1 @@
|
||||
uid://chtn6hbf4vqb6
|
||||
@@ -50,6 +50,12 @@ func test_top_bar_is_drawn_below_the_quiz() -> void:
|
||||
.is_less(_window.get_node("Quiz").get_index())
|
||||
|
||||
|
||||
func test_quiz_parts_are_their_own_scenes() -> void:
|
||||
for node_name: String in ["MusicListPanel", "PlayersPanel", "InspirationList"]:
|
||||
assert_str(_quiz.get_node(node_name).scene_file_path) \
|
||||
.is_equal("res://%s.tscn" % node_name)
|
||||
|
||||
|
||||
func test_top_bar_holds_only_title_and_buttons() -> void:
|
||||
assert_array(_children(_window.get_node("TopBar"))) \
|
||||
.contains_exactly_in_any_order(TOP_BAR_CHILDREN)
|
||||
@@ -89,6 +95,48 @@ func test_clicking_quiz_buttons_plays_and_shows_answer() -> void:
|
||||
.is_equal("Chrono Trigger")
|
||||
|
||||
|
||||
func test_clicking_a_song_in_the_music_list_plays_it() -> void:
|
||||
_next_song_is(SONG_INFO[1])
|
||||
await _play_first_song()
|
||||
_press("NextButton")
|
||||
# Changing song waits a moment for the old one to stop
|
||||
await get_tree().create_timer(0.7).timeout
|
||||
assert_int(Playlist.currently_playing_song).is_equal(1)
|
||||
var rows: Array[Node] = _quiz.get_node("MusicListPanel/ScrollContainer/MusicList") \
|
||||
.get_children()
|
||||
var first_row: Control = rows.filter(
|
||||
func(row: SongListObject) -> bool: return row.song_object.song_number == 0)[0]
|
||||
|
||||
await _click_at(first_row.get_global_rect().position + Vector2(20, first_row.size.y / 2))
|
||||
await get_tree().create_timer(0.7).timeout
|
||||
|
||||
assert_int(Playlist.currently_playing_song).is_equal(0)
|
||||
|
||||
|
||||
func test_points_from_the_player_list_reach_the_song_and_log() -> void:
|
||||
await _play_first_song()
|
||||
var crono: PlayerObject = _add_player("Crono")
|
||||
|
||||
(_player_row(crono).get_node("HBoxContainer/AddPoint") as BaseButton).pressed.emit()
|
||||
|
||||
assert_array(Playlist.song_object_array[0].players_given_point).contains_exactly([crono.id])
|
||||
assert_int((_quiz.get_node("%Log").get_node("%LogVBoxContainer") as Node).get_child_count()) \
|
||||
.is_equal(1)
|
||||
|
||||
|
||||
func test_character_button_in_the_player_list_picks_a_new_character() -> void:
|
||||
var crono: PlayerObject = _add_player("Crono")
|
||||
var character_select: PopupWindow = _quiz.get_node("NewCharacterSelect")
|
||||
|
||||
(_player_row(crono).get_node("HBoxContainer/Character") as BaseButton).pressed.emit()
|
||||
|
||||
assert_bool(character_select.visible).is_true()
|
||||
var texture: ImageTexture = ImageTexture.create_from_image(
|
||||
Image.create(4, 4, false, Image.FORMAT_RGBA8))
|
||||
character_select.new_new_character_selected.emit(texture)
|
||||
assert_object(crono.character).is_same(texture)
|
||||
|
||||
|
||||
func test_quiz_popup_backdrop_covers_the_top_bar() -> void:
|
||||
var settings: PopupWindow = _quiz.get_node("SettingsWindow")
|
||||
settings.open()
|
||||
|
||||
Reference in new issue
Block a user