Files
qbz/scripts/qml-tests/tst_multi_select.qml
T
vicrodh cf3069386b feat(qt): Shift ranges in the library folders settings table
A row or checkbox click toggled one folder. Shift now adds the run over the
rows the filter shows, Ctrl adds one, and the ids stay numbers for the
removal action.

cc
2026-09-14 10:31:40 -06:00

437 lines
18 KiB
QML

// Excel-style multi-select regression suite: the shared selection rule
// (controls/SelectionModel.qml) and the REAL row, card, checkbox and rail
// components of the select-mode surfaces. Every click target a select-mode
// surface offers must deliver Shift (a range from the anchor) and Ctrl (one
// more row) to the selection; a target that swallows the modifier, or that
// navigates away instead of selecting, is exactly the regression this guards.
import QtQuick
import QtTest
import com.blitzfc.qbz
import "../../crates/qbz-qt/qml/controls"
import "../../crates/qbz-qt/qml/rows"
import "../../crates/qbz-qt/qml/cards"
import "../../crates/qbz-qt/qml/views" as Views
import "../../crates/qbz-qt/qml/views/local" as Local
import "../../crates/qbz-qt/qml/views/myqbz" as MyQbz
import "../../crates/qbz-qt/qml/settings" as Settings
Item {
id: root
width: 1200; height: 1400
function track(i) {
return { "id": "t" + i, "title": "Track " + i, "artist": "Artist " + i,
"artistId": "a" + i, "album": "Album " + i, "albumId": "al" + i,
"duration": "3:00", "qualityTier": "", "qualityDetail": "" }
}
function album(i) {
return { "id": "b" + i, "title": "Record " + i, "artist": "Band " + i,
"artistId": "band" + i, "label": "Label " + i, "labelId": "l" + i,
"year": "2001", "qualityTier": "", "qualityDetail": "" }
}
function range(n, make) {
var out = []
for (var i = 0; i < n; i++)
out.push(make(i))
return out
}
// ---- the track list (AlbumView / PlaylistView wiring) -------------------
property var tracks: range(8, track)
property var trackSelected: ({})
SelectionModel { id: trackSel }
function toggleTrack(id, mods) {
root.trackSelected = trackSel.next(root.trackSelected, id, root.tracks,
mods === undefined ? Qt.NoModifier : mods)
}
Column {
id: trackColumn
width: 1000
Repeater {
id: trackRepeater
model: root.tracks
delegate: TrackRow {
required property var modelData
required property int index
width: 1000
item: modelData
number: index + 1
artistLink: true
showAlbum: true
selectMode: true
checked: root.trackSelected[modelData.id] === true
onToggleSelect: function (mods) { root.toggleTrack(modelData.id, mods) }
}
}
}
// ---- the album list (Library albums list wiring) ------------------------
property var albums: range(6, album)
property var albumSelected: ({})
SelectionModel { id: albumSel }
function toggleAlbum(id, mods) {
root.albumSelected = albumSel.next(root.albumSelected, id, root.albums,
mods === undefined ? Qt.NoModifier : mods)
}
Column {
id: albumColumn
y: 420
width: 1000
Repeater {
id: albumRepeater
model: root.albums
delegate: Views.AlbumListRow {
required property var modelData
required property int index
width: 1000
item: modelData
rowIndex: index
selectMode: true
checked: root.albumSelected[modelData.id] === true
onToggleSelect: function (mods) { root.toggleAlbum(modelData.id, mods) }
}
}
}
// ---- one album card and the two checkbox controls ------------------------
property var cardMods: []
AlbumCard {
id: card
x: 1000; y: 0
albumId: "card"
title: "Card title"
artist: "Card artist"
artistId: "card-artist"
selectMode: true
onSelectToggled: function (mods) { root.cardMods = root.cardMods.concat([mods]) }
}
property var checkboxMods: []
QbzCheckbox {
id: checkbox
x: 1000; y: 320
onToggled: function (mods) { root.checkboxMods = root.checkboxMods.concat([mods]) }
}
property var selectCheckMods: []
Local.SelectCheck {
id: selectCheck
x: 1040; y: 320
onToggled: function (mods) { root.selectCheckMods = root.selectCheckMods.concat([mods]) }
}
// ---- a My QBZ collection row (selection lives in Rust) ----------------------
MyQbz.MyQbzDetailRow {
id: myqbzRow
x: 0; y: 820
width: 1000
selectMode: true
item: ({ "position": 3, "itemType": "album", "source": "qobuz",
"sourceItemId": "alb3", "title": "Collected record", "subtitle": "Collected band",
"subtitleIsLink": true, "artistId": "cb" })
}
// ---- the Local Library folder rail (selection lives in Rust) ----------------
QtObject {
id: railView
property bool treeSelectMode: true
property string selectedFolder: ""
property string treeSearch: ""
property bool skelPhase: false
property var opened: []
property var tree: [
{ "path": "/m/a", "segment": "a", "depth": 0, "isFolder": true, "canExpand": true },
{ "path": "/m/a/1.flac", "segment": "1.flac", "depth": 1, "isFolder": false },
{ "path": "/m/b", "segment": "b", "depth": 0, "isFolder": true, "canExpand": true },
{ "path": "/m/c", "segment": "c", "depth": 0, "isFolder": true, "canExpand": true },
{ "path": "/m/d", "segment": "d", "depth": 0, "isFolder": true, "canExpand": true }
]
function toggleTreeSelectMode() { treeSelectMode = !treeSelectMode }
function selectFolder(path) { opened = opened.concat([path]) }
}
Local.LocalTreeRail {
id: rail
x: 1000; y: 400
width: 200; height: 400
view: railView
}
// ---- Settings > Local Library folders ---------------------------------------
Settings.LibraryFolderTable {
id: folderTable
x: 0; y: 880
width: 1200
lib: ({ "folders": [1, 2, 3, 4, 5].map(function (n) {
return { "id": n, "displayName": "Music " + n, "path": "/music/" + n,
"isNetwork": false, "enabled": true, "accessible": true,
"status": "active", "lastScan": 0 }
}) })
}
TestCase {
name: "MultiSelect"
when: windowShown
function find(item, pred) {
if (!item)
return null
if (pred(item))
return item
var kids = item.children || []
for (var i = 0; i < kids.length; i++) {
var hit = find(kids[i], pred)
if (hit)
return hit
}
return null
}
function shown(item) {
for (var it = item; it; it = it.parent)
if (!it.visible)
return false
return true
}
function textItem(host, text) {
var t = find(host, function (it) {
return it.text === text && it.contentWidth !== undefined && shown(it)
})
verify(t !== null, "text '" + text + "' is drawn")
return t
}
// Clicks go through the window at the item's position, so whichever
// MouseArea really sits on top there receives them — the point of
// using the real components.
function clickOn(item, x, y, mods) {
var p = item.mapToItem(root, x, y)
mouseClick(root, p.x, p.y, Qt.LeftButton, mods === undefined ? Qt.NoModifier : mods)
wait(20)
}
function selectedIds(map) {
return Object.keys(map).sort().join(",")
}
function trackRow(i) { return trackRepeater.itemAt(i) }
function albumRow(i) { return albumRepeater.itemAt(i) }
function disc(row) {
var d = find(row, function (it) {
return it.width === 14 && it.height === 14 && it.hoverEnabled !== undefined
&& it.cursorShape !== undefined && shown(it)
})
verify(d !== null, "the select-mode disc is drawn")
return d
}
function init() {
root.trackSelected = ({})
trackSel.anchorId = ""
root.albumSelected = ({})
albumSel.anchorId = ""
root.cardMods = []
root.checkboxMods = []
root.selectCheckMods = []
QbzArtist.opened = []
QbzAlbum.opened = []
QbzHome.labels = []
QbzMyQbz.selectCalls = []
QbzMyQbz.opened = []
QbzMyQbz.played = []
QbzLocal.treeCalls = []
railView.opened = []
railView.treeSelectMode = true
folderTable.selectedIds = []
}
// A point of the row with nothing drawn over it but the body: the
// duration cell.
function bodyPoint(row) {
var duration = textItem(row, "3:00")
return duration.mapToItem(row, duration.width / 2, duration.height / 2)
}
function clickBody(row, mods) {
var p = bodyPoint(row)
clickOn(row, p.x, p.y, mods)
}
function test_row_body_shift_and_ctrl() {
clickBody(trackRow(1))
clickBody(trackRow(4), Qt.ShiftModifier)
compare(selectedIds(root.trackSelected), "t1,t2,t3,t4")
clickBody(trackRow(6), Qt.ControlModifier)
compare(selectedIds(root.trackSelected), "t1,t2,t3,t4,t6")
}
function test_checkbox_disc_shift_and_ctrl() {
clickOn(disc(trackRow(0)), 7, 7)
clickOn(disc(trackRow(3)), 7, 7, Qt.ShiftModifier)
compare(selectedIds(root.trackSelected), "t0,t1,t2,t3",
"Shift on the disc selects the range")
clickOn(disc(trackRow(5)), 7, 7, Qt.ControlModifier)
clickOn(disc(trackRow(7)), 7, 7, Qt.ControlModifier | Qt.ShiftModifier)
compare(selectedIds(root.trackSelected), "t0,t1,t2,t3,t5,t6,t7",
"Ctrl adds a row and Ctrl+Shift adds a second group")
}
function test_artist_link_selects_in_select_mode() {
clickBody(trackRow(2))
var name = textItem(trackRow(5), "Artist 5")
clickOn(name, 10, name.height / 2, Qt.ShiftModifier)
compare(QbzArtist.opened.length, 0, "no artist page opens in select mode")
compare(selectedIds(root.trackSelected), "t2,t3,t4,t5")
}
function test_album_link_selects_in_select_mode() {
var name = textItem(trackRow(6), "Album 6")
clickOn(name, 10, name.height / 2)
compare(QbzAlbum.opened.length, 0, "no album page opens in select mode")
compare(selectedIds(root.trackSelected), "t6")
}
function test_album_row_checkbox_shift_range() {
var first = find(albumRow(0), function (it) {
return it.checked !== undefined && it.width === 18 && it.height === 18 && shown(it)
})
var fourth = find(albumRow(3), function (it) {
return it.checked !== undefined && it.width === 18 && it.height === 18 && shown(it)
})
verify(first !== null && fourth !== null, "the album row checkboxes are drawn")
clickOn(first, 9, 9)
clickOn(fourth, 9, 9, Qt.ShiftModifier)
compare(selectedIds(root.albumSelected), "b0,b1,b2,b3")
}
function test_album_row_links_select_in_select_mode() {
clickOn(albumRow(1), 600, 32)
var band = textItem(albumRow(4), "Band 4")
clickOn(band, 10, band.height / 2, Qt.ShiftModifier)
compare(QbzArtist.opened.length, 0, "no artist page opens in select mode")
var label = textItem(albumRow(5), "Label 5")
clickOn(label, 10, label.height / 2, Qt.ControlModifier)
compare(QbzHome.labels.length, 0, "no label page opens in select mode")
compare(selectedIds(root.albumSelected), "b1,b2,b3,b4,b5")
}
function test_album_card_forwards_modifiers() {
clickOn(card, 100, 100, Qt.ShiftModifier)
var title = textItem(card, "Card title")
clickOn(title, 10, title.height / 2, Qt.ControlModifier)
var artist = textItem(card, "Card artist")
clickOn(artist, 10, artist.height / 2, Qt.ShiftModifier)
compare(QbzArtist.opened.length, 0, "no artist page opens in select mode")
compare(root.cardMods.length, 3)
verify((root.cardMods[0] & Qt.ShiftModifier) !== 0, "artwork carries Shift")
verify((root.cardMods[1] & Qt.ControlModifier) !== 0, "title carries Ctrl")
verify((root.cardMods[2] & Qt.ShiftModifier) !== 0, "artist line carries Shift")
}
function test_checkbox_controls_forward_modifiers() {
clickOn(checkbox, 9, 9, Qt.ShiftModifier)
clickOn(selectCheck, 6, 6, Qt.ControlModifier)
compare(root.checkboxMods.length, 1)
verify((root.checkboxMods[0] & Qt.ShiftModifier) !== 0, "QbzCheckbox carries Shift")
compare(root.selectCheckMods.length, 1)
verify((root.selectCheckMods[0] & Qt.ControlModifier) !== 0, "SelectCheck carries Ctrl")
checkbox.forceActiveFocus()
keyClick(Qt.Key_Space)
compare(root.checkboxMods.length, 2)
compare(root.checkboxMods[1] & Qt.ShiftModifier, 0, "the keyboard toggle is a plain toggle")
}
function test_myqbz_row_targets_select_with_shift() {
var title = textItem(myqbzRow, "Collected record")
clickOn(title, 10, title.height / 2)
var subtitle = textItem(myqbzRow, "Collected band")
clickOn(subtitle, 10, subtitle.height / 2, Qt.ShiftModifier)
compare(QbzMyQbz.opened.length, 0, "nothing opens in select mode")
compare(QbzMyQbz.selectCalls.length, 2)
compare(QbzMyQbz.selectCalls[0].position, 3)
compare(QbzMyQbz.selectCalls[0].shift, false)
compare(QbzMyQbz.selectCalls[1].shift, true, "Shift reaches the Rust range")
}
function railRow(path) {
var list = find(rail, function (it) { return it.model !== undefined && it.itemAtIndex !== undefined })
verify(list !== null, "the rail list exists")
for (var i = 0; i < railView.tree.length; i++)
if (railView.tree[i].path === path)
return list.itemAtIndex(i)
return null
}
function railCheck(path) {
var row = railRow(path)
verify(row !== null, "rail row " + path + " is drawn")
var check = find(row, function (it) {
return it.partial !== undefined && it.on !== undefined && shown(it)
})
verify(check !== null, "rail checkbox " + path + " is drawn")
return check
}
function test_folder_rail_shift_range_and_modifier_body_click() {
tryVerify(function () { return railRow("/m/d") !== null }, 2000)
clickOn(railCheck("/m/a"), 6, 6)
clickOn(railCheck("/m/c"), 6, 6, Qt.ShiftModifier)
compare(QbzLocal.treeCalls.length, 2)
compare(QbzLocal.treeCalls[0], "folder:/m/a")
var range = JSON.parse(QbzLocal.treeCalls[1].slice("range:".length))
compare(range.map(function (n) { return n.path }).join(","), "/m/a,/m/a/1.flac,/m/b,/m/c")
compare(range[1].isFolder, false)
// A Ctrl click on a row body selects instead of opening the folder.
var d = railRow("/m/d")
clickOn(d, d.width - 20, d.height / 2, Qt.ControlModifier)
compare(QbzLocal.treeCalls[2], "folder:/m/d")
compare(railView.opened.length, 0)
// A plain click on the body still opens it.
clickOn(d, d.width - 20, d.height / 2)
compare(railView.opened.length, 1)
// Leaving select mode forgets the anchor: Shift is a plain toggle.
railView.treeSelectMode = false
railView.treeSelectMode = true
wait(0)
clickOn(railCheck("/m/b"), 6, 6, Qt.ShiftModifier)
compare(QbzLocal.treeCalls[3], "folder:/m/b")
}
function test_settings_folder_table_shift_range() {
var first = textItem(folderTable, "Music 1")
clickOn(first, 10, first.height / 2)
var fourth = textItem(folderTable, "Music 4")
clickOn(fourth, 10, fourth.height / 2, Qt.ShiftModifier)
compare(folderTable.selectedIds.slice().sort().join(","), "1,2,3,4")
verify(folderTable.isSelected(2), "the numeric ids stay numbers")
}
}
// ---- the selection rule itself ---------------------------------------------
SelectionModel { id: rule }
SelectionModel { id: keyedRule; idKey: "trackId" }
TestCase {
name: "SelectionRule"
function init() {
rule.anchorId = ""
keyedRule.anchorId = ""
}
function test_numeric_ids_range() {
var rows = [{ "id": 11 }, { "id": 12 }, { "id": 13 }, { "id": 14 }]
var m = rule.next({}, 11, rows, Qt.NoModifier)
m = rule.next(m, 13, rows, Qt.ShiftModifier)
compare(Object.keys(m).sort().join(","), "11,12,13")
}
function test_rows_without_id_are_skipped() {
var rows = [{ "id": "a" }, { "kind": "header" }, { "id": "b" }]
var m = rule.next({}, "a", rows, Qt.NoModifier)
m = rule.next(m, "b", rows, Qt.ShiftModifier)
compare(Object.keys(m).sort().join(","), "a,b")
}
function test_id_key() {
var rows = [{ "trackId": 7 }, { "trackId": 8 }, { "trackId": 9 }]
var m = keyedRule.next({}, 9, rows, Qt.NoModifier)
m = keyedRule.next(m, 7, rows, Qt.ShiftModifier)
compare(Object.keys(m).sort().join(","), "7,8,9")
}
}
}