Files
qbz/scripts/qml-tests/tst_multi_select.qml
T
vicrodh 7c9bf8043f fix(qt): Shift and Ctrl work on every select-mode click target, not only the row body
Excel-style selection (750c59034) read the modifiers only where a row BODY
took the click. Every other target a select-mode surface draws dropped them,
so a Shift-click there was a plain toggle:

  - the track row's checkbox disc passed Qt.NoModifier;
  - QbzCheckbox and SelectCheck emitted toggled() with no argument, so the
    Library album rows, the local album rows and the folder rows, which
    already expected `function (mods)`, received undefined;
  - AlbumCard emitted selectToggled() with no argument, and the paged local
    albums grid (299dcde5b) spelled the drop out as Qt.NoModifier.

Some targets were never selection targets at all: in select mode the artist
and album names on a track row, the artist and label on a Library album row
and the artist line of an album card still navigated, leaving the page and
the selection mid-gesture. The row links stand down in select mode so the
click reaches the row body with its modifiers, and the card's artist line
toggles like its artwork and title. A hover-only tooltip on the pulled but
cached glyph no longer eats the row's click.

Reproduced on the release binary in a nested KWin with injected input: in
Local Library Tracks, the checkbox discs of rows 1, Shift 4 and Ctrl 6
selected 3 rows; the same clicks on the row bodies selected 5. No list
view, Flickable or wheel code changes; tst_wheel passes unchanged.

tst_multi_select clicks each of these targets through the real TrackRow,
AlbumListRow, AlbumCard, QbzCheckbox and SelectCheck.

cc
2026-09-14 10:30:34 -06:00

316 lines
12 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
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]) }
}
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 = []
}
// 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")
}
}
// ---- 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")
}
}
}