Files
qbz/scripts/qml-tests/tst_keyed_model.qml
T
vicrodh 408a7af0c9 feat(qt): queue rows slide into place instead of the list being rebuilt
The Listen list and the sidebar queue now reach their views through
QbzKeyedModel. In the Listen list a removed track fades out while the
rows below slide up, "play next" slides the queue open, a history row
trimmed above the window leaves the rows on screen where they are, and a
committed drag lands the row where it was dropped while its neighbours
slide (the chevrons keep the animated swap). Identity is the track
(source + id, repeats by occurrence); phase, section and phaseIndex are
data, so a track ending updates rows in place and moves nothing. The
follow-playing position and the cover window now run after the model
holds the new rows. A search is a different list and is replaced at once.

The sidebar's Up Next and Recently played are Repeaters in Columns, which
have no remove transition: a leaving row disappears and the rows below
slide up, a joining row fades in, and a page or search change is laid out
synchronously without animation. Drop slots count the Repeater's own
items.

tst_keyed_model gains the Repeater-in-a-Column case (silent first page and
page change, sliding rows that keep their items, fading insert).

cc
2026-09-14 05:56:58 -06:00

406 lines
16 KiB
QML

// QbzKeyedModel: rows reconcile into removes / inserts / moves / updates
// instead of a model swap. Guards the order and identity contract under
// random edits, the minimal-move reorder, the silent reset, a removal that
// keeps the viewport and the delegates, the fade that keeps its content, the
// pooled delegate that must come back opaque (76 blank rows, 2026-09-14), and
// the Repeater-in-a-Column shape the sidebar queue uses.
import QtQuick
import QtTest
import "../../crates/qbz-qt/qml/controls"
Item {
id: root
width: 400; height: 400
property var countedRows: []
property var fadeRows: []
property var plainRows: []
property int created: 0
property int addRuns: 0
property int removeRuns: 0
property int moveSignals: 0
function make(n, prefix) {
var out = []
for (var i = 0; i < n; i++)
out.push({ "id": prefix + i, "title": "T" + prefix + i })
return out
}
// --- counted transitions: the reset / structure tests ------------------
QbzKeyedModel { id: counted; rows: root.countedRows; views: [countedView] }
Connections {
target: counted
function onRowsMoved() { root.moveSignals++ }
}
ListView {
id: countedView
width: 200; height: 400
model: counted
reuseItems: true
add: Transition {
enabled: counted.animate
ScriptAction { script: root.addRuns++ }
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 60 }
}
remove: Transition {
enabled: counted.animate
ScriptAction { script: root.removeRuns++ }
SequentialAnimation {
NumberAnimation { property: "opacity"; to: 0; duration: 60 }
PropertyAction { property: "opacity"; value: 1 }
}
}
displaced: QbzRowDisplaced { enabled: counted.animate; duration: 60 }
delegate: Item {
required property string rowKey
required property int rowRev
readonly property var modelData: counted.row(rowKey, rowRev)
width: 200; height: 20
Component.onCompleted: root.created++
ListView.onReused: { opacity = 1; scale = 1 }
}
}
// --- the shipped transitions: fade / reuse tests -----------------------
QbzKeyedModel { id: faded; rows: root.fadeRows; views: [fadeView] }
ListView {
id: fadeView
x: 200
width: 200; height: 400
model: faded
reuseItems: true
remove: QbzRowRemove { enabled: faded.animate; duration: 240 }
displaced: QbzRowDisplaced { enabled: faded.animate; duration: 240 }
delegate: Item {
required property string rowKey
required property int rowRev
readonly property var modelData: faded.row(rowKey, rowRev)
readonly property string shown: modelData.title || ""
width: 200; height: 20
}
}
// --- no view: reconcile arithmetic -------------------------------------
QbzKeyedModel { id: plain; rows: root.plainRows }
// --- a Repeater in a Column (the sidebar queue shape) -------------------
property var columnRows: []
property int columnAdds: 0
property int columnMoves: 0
QbzKeyedModel { id: columnModel; rows: root.columnRows; views: [column] }
Column {
id: column
x: 0; y: 0
width: 200
add: Transition {
enabled: columnModel.animate
ScriptAction { script: root.columnAdds++ }
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 60 }
}
move: Transition {
enabled: columnModel.animate
ScriptAction { script: root.columnMoves++ }
NumberAnimation { properties: "y"; duration: 120 }
}
Repeater {
id: columnRepeater
model: columnModel
delegate: Rectangle {
required property string rowKey
required property int rowRev
required property int index
readonly property var modelData: columnModel.row(rowKey, rowRev)
width: 200; height: 10
color: "transparent"
}
}
}
TestCase {
name: "KeyedModel"
when: windowShown
function settle(view) { wait(0); waitForRendering(view); wait(0) }
function order(m) {
var out = []
for (var i = 0; i < m.count; i++)
out.push(m.get(i).rowKey)
return out
}
function topKey(view) {
var it = view.itemAt(10, view.contentY + 1)
return it ? it.rowKey : ""
}
function prng(seed) {
var a = seed >>> 0
return function () {
a = (a + 0x6D2B79F5) >>> 0
var t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function test_a_random_edits_reconcile_order_identity_and_revs() {
var rnd = prng(20260914)
var rows = root.make(60, "r")
var serial = 60
root.plainRows = rows
plain.sync()
for (var step = 0; step < 400; step++) {
var next = rows.slice()
var kind = Math.floor(rnd() * 6)
if (kind === 0 && next.length > 0) {
next.splice(Math.floor(rnd() * next.length), 1 + Math.floor(rnd() * 3))
} else if (kind === 1) {
var at = Math.floor(rnd() * (next.length + 1))
var burst = []
for (var b = 0; b < 1 + Math.floor(rnd() * 4); b++)
burst.push({ "id": "r" + (serial++), "title": "new" })
Array.prototype.splice.apply(next, [at, 0].concat(burst))
} else if (kind === 2 && next.length > 1) {
var from = Math.floor(rnd() * next.length)
var moved = next.splice(from, 1)[0]
next.splice(Math.floor(rnd() * (next.length + 1)), 0, moved)
} else if (kind === 3 && next.length > 0) {
// A repeat of an existing entity (the queue allows it).
var dup = next[Math.floor(rnd() * next.length)]
next.splice(Math.floor(rnd() * (next.length + 1)), 0,
{ "id": dup.id, "title": dup.title })
} else if (kind === 4 && next.length > 0) {
var ci = Math.floor(rnd() * next.length)
next[ci] = { "id": next[ci].id, "title": "changed " + step }
} else {
// Same data, all-new objects (a republished document).
next = next.map(function (r) { return { "id": r.id, "title": r.title } })
}
var keys = plain.keysFor(next)
var revsBefore = ({})
for (var k = 0; k < plain.count; k++)
revsBefore[plain.get(k).rowKey] = plain.get(k).rowRev
var before = ({})
var prevKeys = plain.keysFor(rows)
for (var p = 0; p < rows.length; p++)
before[prevKeys[p]] = rows[p]
root.plainRows = next
plain.sync()
compare(order(plain), keys, "order after step " + step)
for (var i = 0; i < next.length; i++) {
verify(plain.row(keys[i]) === next[i], "identity " + keys[i])
var prev = before[keys[i]]
if (prev !== undefined && revsBefore[keys[i]] !== undefined) {
var contentChanged = prev.title !== next[i].title
compare(plain.get(i).rowRev !== revsBefore[keys[i]], contentChanged,
"rev bump iff content changed: " + keys[i] + " step " + step)
}
}
rows = next
}
}
function test_b_one_row_moved_far_is_one_move() {
root.moveSignals = 0
var rows = root.make(100, "m")
root.countedRows = rows
counted.scope = "moves"
counted.sync()
settle(countedView)
root.moveSignals = 0
var next = rows.slice()
next.splice(90, 0, next.splice(3, 1)[0])
root.countedRows = next
counted.sync()
compare(root.moveSignals, 1, "down")
compare(order(counted), counted.keysFor(next))
root.moveSignals = 0
var back = next.slice()
back.splice(3, 0, back.splice(90, 1)[0])
root.countedRows = back
counted.sync()
compare(root.moveSignals, 1, "up")
compare(order(counted), counted.keysFor(back))
}
function test_c_scope_change_resets_without_transitions_at_the_top() {
root.countedRows = root.make(300, "s")
counted.scope = "tab-a"
counted.sync()
settle(countedView)
countedView.contentY = countedView.originY + 2000
settle(countedView)
var adds = root.addRuns
var removes = root.removeRuns
counted.scope = "tab-b"
root.countedRows = root.make(120, "t")
wait(0)
settle(countedView)
wait(150)
compare(counted.count, 120)
compare(root.addRuns, adds, "no add transition for a reset")
compare(root.removeRuns, removes, "no remove transition for a reset")
verify(countedView.atYBeginning, "a reset starts at the top")
compare(topKey(countedView), "t0")
}
function test_c2_reset_from_empty_does_not_animate_either() {
counted.scope = "empty"
root.countedRows = []
wait(0)
settle(countedView)
var adds = root.addRuns
counted.scope = "filled"
root.countedRows = root.make(50, "e")
wait(0)
settle(countedView)
wait(150)
compare(counted.count, 50)
compare(root.addRuns, adds, "the first page of a scope appears at once")
}
function test_d_removal_keeps_the_viewport_and_the_delegates() {
var rows = root.make(400, "v")
root.countedRows = rows
counted.scope = "viewport"
counted.sync()
settle(countedView)
countedView.contentY = countedView.originY + 1000
settle(countedView)
var top = topKey(countedView)
compare(top, "v50")
var made = root.created
var removes = root.removeRuns
// Ten rows above the viewport and one inside it leave.
root.countedRows = rows.filter(function (r, i) { return i >= 10 && i !== 55 })
counted.sync()
settle(countedView)
wait(150)
compare(topKey(countedView), top, "the row at the top stays at the top")
verify(root.created - made <= 2, "no rebuild: " + (root.created - made) + " delegates created")
verify(root.removeRuns > removes, "the visible row animated out")
}
function test_e_removed_row_fades_out_with_its_content() {
root.fadeRows = root.make(40, "f")
faded.scope = "fade"
faded.sync()
settle(fadeView)
root.fadeRows = root.make(40, "f").filter(function (r) { return r.id !== "f5" })
faded.sync()
wait(80)
var ghost = null
for (var i = 0; i < fadeView.contentItem.children.length; i++) {
var ch = fadeView.contentItem.children[i]
if (ch.rowKey === "f5" && ch.opacity > 0.05 && ch.opacity < 0.99)
ghost = ch
}
verify(ghost !== null, "the leaving row is mid-fade")
compare(ghost.shown, "Tf5", "and still shows its own content")
wait(400)
}
function test_f_pooled_rows_come_back_opaque() {
var rows = root.make(40, "p")
root.fadeRows = rows
faded.scope = "reuse"
faded.sync()
settle(fadeView)
for (var pass = 0; pass < 3; pass++) {
rows = rows.filter(function (r, i) { return i % 7 !== 3 })
root.fadeRows = rows
faded.sync()
wait(400)
}
root.fadeRows = rows.concat(root.make(300, "q"))
faded.sync()
settle(fadeView)
var blank = []
for (var y = 0; y < 5000; y += 150) {
fadeView.contentY = fadeView.originY + y
wait(20)
for (var c = 0; c < fadeView.contentItem.children.length; c++) {
var it = fadeView.contentItem.children[c]
if (it.rowKey === undefined || it.index === -1)
continue
if (fadeView.itemAtIndex(faded.indexOfKey(it.rowKey)) !== it)
continue
if (it.opacity < 0.99)
blank.push(it.rowKey)
}
}
compare(blank.length, 0, "rows reused from the pool are opaque: " + blank.slice(0, 8).join(" "))
}
function test_g_paused_holds_rows_until_it_clears() {
root.plainRows = root.make(5, "h")
plain.scope = "paused"
plain.sync()
plain.paused = true
root.plainRows = root.make(8, "h")
plain.sync()
compare(plain.count, 5)
plain.paused = false
wait(0)
compare(plain.count, 8)
}
function test_h_ignored_keys_do_not_count_as_a_change() {
plain.ignoredKeys = ["_order"]
root.plainRows = [{ "id": "a", "title": "A", "_order": 0 }, { "id": "b", "title": "B", "_order": 1 }]
plain.scope = "ignored"
plain.sync()
var revA = plain.get(0).rowRev
root.plainRows = [{ "id": "a", "title": "A", "_order": 5 }, { "id": "b", "title": "B", "_order": 6 }]
plain.sync()
compare(plain.get(0).rowRev, revA)
root.plainRows = [{ "id": "a", "title": "A2", "_order": 5 }, { "id": "b", "title": "B", "_order": 6 }]
plain.sync()
verify(plain.get(0).rowRev !== revA)
plain.ignoredKeys = []
}
function test_j_column_repeater_resets_silently_and_slides_on_removal() {
columnModel.scope = "page-1"
root.columnRows = root.make(12, "c")
wait(0)
wait(150)
compare(columnRepeater.count, 12)
compare(root.columnAdds, 0, "the first page is not animated in")
var survivor = columnRepeater.itemAt(4)
// One row leaves: the rows below slide up, nothing is re-created.
root.columnRows = root.make(12, "c").filter(function (r) { return r.id !== "c2" })
columnModel.sync()
wait(30)
verify(root.columnMoves > 0, "the rows below slide")
wait(200)
compare(columnRepeater.itemAt(3), survivor, "the same row item, one slot up")
compare(survivor.y, 30)
// A row joins at the top: it fades in.
var adds = root.columnAdds
root.columnRows = [{ "id": "new", "title": "N" }].concat(root.columnRows)
columnModel.sync()
wait(150)
verify(root.columnAdds > adds, "the new row fades in")
// Another page replaces the list without animation.
adds = root.columnAdds
columnModel.scope = "page-2"
root.columnRows = root.make(8, "d")
wait(0)
wait(150)
compare(columnRepeater.count, 8)
compare(root.columnAdds, adds, "a page change is not animated")
}
function test_i_repeated_entities_are_distinct_rows() {
root.plainRows = [{ "id": "x" }, { "id": "y" }, { "id": "x" }]
plain.scope = "dups"
plain.sync()
compare(order(plain), ["x", "y", "x#1"])
root.plainRows = [{ "id": "y" }, { "id": "x" }]
plain.sync()
compare(order(plain), ["y", "x"])
}
}
}