Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cypress.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = {
'tests/e2e/navigation.cy.js',
'tests/e2e/pick-reject.cy.js',
'tests/e2e/comments.cy.js',
'tests/e2e/mobile-virtualization.cy.js',
'tests/e2e/api-security.cy.js',
],
supportFile: false,
Expand Down
74 changes: 32 additions & 42 deletions src/components/GridView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import RatingStars from './RatingStars.vue'
import {
spacerHeightForRows as _spacerHeightForRows,
computeCompressionRatio,
computeTopSpacerHeight,
iterateViewportFirst,
} from '../utils/gridLayout.js'

const props = defineProps({
/** Array von Bild-Objekten (von der API) */
Expand Down Expand Up @@ -292,17 +298,13 @@ function onScroll() {
scrollEndTimer = setTimeout(resyncRenderedThumbs, 200)
}

// Items im Render-Range mit Viewport-Priorität enqueuen:
// 1. Viewport-Reihen zuerst (priority=true, unshift) — User sieht die sofort
// 2. Buffer-unten dahinter (priority=false, push) — was bei Scroll-Down
// als nächstes kommt
// 3. Buffer-oben zuletzt (priority=false, push) — am wenigsten dringend
// Bereits geladene oder gerade ladende Items werden übersprungen; cached
// Items kriegen ihre URL direkt zugewiesen ohne Queue-Roundtrip. Die naive
// "alles priority=true im Reverse" Variante schob die BUFFER Reihen oberhalb
// des Viewports vor die echten Viewport-Items in die Queue → bei kalter
// Cache (großes Shooting frisch geöffnet) sah man die ersten ~4 Slots mit
// Buffer-Above-Items belegt, bevor die sichtbaren Bilder dran waren.
// Items im Render-Range mit Viewport-Priorität enqueuen. Iterations-
// Reihenfolge (Viewport → Buffer-unten → Buffer-oben) liegt als Generator
// in gridLayout.js und ist dort separat unit-getestet. Wir übersetzen
// jedes [index, priority]-Pair in unshift (priority=true) bzw. push
// (priority=false) via enqueueThumb. Bereits geladene oder gerade ladende
// Items werden übersprungen; cached Items kriegen ihre URL direkt
// zugewiesen ohne Queue-Roundtrip.
function enqueueRenderedRange() {
if (!virtualEnabled.value || rowStride.value === 0) return
const cols = columnsCount.value || 1
Expand All @@ -311,23 +313,16 @@ function enqueueRenderedRange() {
const vpStart = Math.max(renderStartIdx.value, vpTopRow * cols)
const vpEnd = Math.min(renderEndIdx.value, (vpTopRow + vpRowCount) * cols)

const tryEnqueue = (i, priority) => {
for (const [i, priority] of iterateViewportFirst(renderStartIdx.value, renderEndIdx.value, vpStart, vpEnd)) {
const img = props.images[i]
if (!img || img.thumbLoaded || img.thumbLoading) return
if (!img || img.thumbLoaded || img.thumbLoading) continue
if (thumbCache.value[img.id]) {
img.thumbUrl = thumbCache.value[img.id]
img.thumbLoaded = true
return
continue
}
enqueueThumb(img, priority)
}

// 1. Viewport — Reverse-Iter, damit unshift die Top-of-Viewport vorne lässt
for (let i = vpEnd - 1; i >= vpStart; i--) tryEnqueue(i, true)
// 2. Buffer unten (next bei Scroll-Down) — push behält Top-Down-Order
for (let i = vpEnd; i < renderEndIdx.value; i++) tryEnqueue(i, false)
// 3. Buffer oben (least likely) — push ans Ende
for (let i = renderStartIdx.value; i < vpStart; i++) tryEnqueue(i, false)
}

function resyncRenderedThumbs() {
Expand Down Expand Up @@ -377,9 +372,10 @@ const virtualEnabled = computed(() => rowStride.value > 0 && viewportHeight.valu
// Logical-Scroll-Mapping: bei Listen, die rechnerisch über MAX_PHYSICAL_HEIGHT
// kämen, wird der physische Scrollbereich gekappt und auf den logischen
// Bereich gemappt. compressionRatio=1 bedeutet kein Mapping (kleine Listen).
// Pure Math liegt in src/utils/gridLayout.js für Unit-Tests.
const fullLogicalHeight = computed(() => totalRows.value * rowStride.value)
const compressionRatio = computed(() =>
Math.max(1, fullLogicalHeight.value / MAX_PHYSICAL_HEIGHT)
computeCompressionRatio(fullLogicalHeight.value, MAX_PHYSICAL_HEIGHT)
)
const physicalScrollHeight = computed(() => fullLogicalHeight.value / compressionRatio.value)
const logicalScrollTop = computed(() => scrollTop.value * compressionRatio.value)
Expand Down Expand Up @@ -409,29 +405,23 @@ const renderedImages = computed(() => {

// Spacer-Höhen: bei kleinen Listen (compressionRatio=1) klassisch — N Reihen
// × rowHeight + (N-1) × GRID_GAP, damit Items exakt an Reihen-Grenzen sitzen.
// Bei Compression: kontinuierliches Sub-Row-Mapping — siehe topSpacer-Formel.
// Bei Compression: kontinuierliches Sub-Row-Mapping — siehe topSpacer-Formel
// in gridLayout.js (dort auch Unit-Tests für die Math).
function spacerHeightForRows(n) {
if (n <= 0) return 0
return n * rowHeight.value + (n - 1) * GRID_GAP
return _spacerHeightForRows(n, rowHeight.value, GRID_GAP)
}

const topSpacerHeight = computed(() => {
if (!virtualEnabled.value) return 0
if (compressionRatio.value === 1) {
return spacerHeightForRows(visibleStartRow.value)
}
if (visibleStartRow.value === 0) return 0
// Kontinuierliche Compression: die erste sichtbare Reihe (logische Reihe
// visibleStartRow+BUFFER) wird so platziert, dass ihre Top-Kante genau
// `rowOffset` Pixel über der Viewport-Oberkante liegt — wobei rowOffset =
// (logicalScrollTop mod rowStride). Ergebnis: pro 1 px scrollTop bewegt sich
// der Inhalt `compressionRatio` px relativ zum Viewport, glatt und linear,
// ohne „Festbeißen + Sprung am Row-Tick" wie in der naiven scrollTop-1:1-
// Formel. Am Row-Tick verschwindet ein Item oben aus dem DOM und ein neues
// taucht unten auf — visuell ohne Diskontinuität.
const rowOffset = logicalScrollTop.value % rowStride.value
return Math.max(0, scrollTop.value - rowOffset - VIRTUAL_BUFFER_ROWS * rowStride.value)
})
const topSpacerHeight = computed(() => computeTopSpacerHeight({
virtualEnabled: virtualEnabled.value,
compressionRatio: compressionRatio.value,
visibleStartRow: visibleStartRow.value,
rowHeight: rowHeight.value,
rowStride: rowStride.value,
gap: GRID_GAP,
scrollTop: scrollTop.value,
logicalScrollTop: logicalScrollTop.value,
bufferRows: VIRTUAL_BUFFER_ROWS,
}))

const bottomSpacerHeight = computed(() => {
if (!virtualEnabled.value) return 0
Expand Down
90 changes: 90 additions & 0 deletions src/utils/gridLayout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Pure helpers for the virtualized grid layout & thumbnail-loading queue.
*
* Extracted from GridView.vue so the Math is testable in isolation —
* jsdom liefert keine echten Layout-Werte, was die Component-Tests im
* virtualEnabled-Pfad nicht abdeckt. Die hier exportierten Funktionen
* sind reine Berechnungen ohne DOM-Zugriff und decken die zentralen
* Algorithmen (Compression-Ratio, kontinuierliche topSpacer-Formel,
* Viewport-First-Iteration) testbar ab.
*/

/**
* Höhe für N nicht-gerenderte Reihen oberhalb/unterhalb des Viewports —
* exakt die CSS-Grid-Layout-Höhe N×rowHeight plus (N-1)×Gap (kein Edge-Gap).
*/
export function spacerHeightForRows(n, rowHeight, gap) {
if (n <= 0) return 0
return n * rowHeight + (n - 1) * gap
}

/**
* Compression-Ratio: wenn der rechnerische Container höher wäre als der
* physische Cap, mappt scrollTop linear auf alle Items (Ratio > 1). Bei
* kleinen Listen unter dem Cap: Ratio = 1, kein Mapping.
*/
export function computeCompressionRatio(fullLogicalHeight, maxPhysicalHeight) {
if (maxPhysicalHeight <= 0) return 1
return Math.max(1, fullLogicalHeight / maxPhysicalHeight)
}

/**
* Top-Spacer-Höhe für den virtualisierten Grid.
*
* Zwei Modi:
* - Compression-Ratio = 1 (kleine Listen):
* klassische Spacer-Höhe via spacerHeightForRows(visibleStartRow).
* - Compression aktiv (Ratio > 1):
* kontinuierliches Sub-Row-Mapping. Die erste sichtbare Reihe
* (visibleStartRow + bufferRows) liegt mit ihrer Top-Kante genau
* `rowOffset` Pixel über der Viewport-Oberkante, wobei rowOffset =
* logicalScrollTop mod rowStride. Der Spacer = scrollTop - rowOffset
* - bufferRows × rowStride; clampt auf 0 nahe dem Top-Edge.
*
* Liefert 0 in folgenden Fällen:
* - Virtualisierung nicht aktiv (Layout nicht gemessen)
* - visibleStartRow ≤ 0 (kein Spacer nötig vor erster Reihe)
* - Compression-Formel landet im negativen Bereich (Top-Edge-Region)
*/
export function computeTopSpacerHeight({
virtualEnabled,
compressionRatio,
visibleStartRow,
rowHeight,
rowStride,
gap,
scrollTop,
logicalScrollTop,
bufferRows,
}) {
if (!virtualEnabled) return 0
if (compressionRatio === 1) {
return spacerHeightForRows(visibleStartRow, rowHeight, gap)
}
if (visibleStartRow <= 0) return 0
const rowOffset = logicalScrollTop % rowStride
return Math.max(0, scrollTop - rowOffset - bufferRows * rowStride)
}

/**
* Iteration über den Render-Range in Viewport-First-Reihenfolge.
*
* Yields [index, priority] pairs:
* 1. Viewport-Items in Reverse-Reihenfolge (wenn der Konsument unshift't,
* landet das Top-of-Viewport ganz vorne).
* 2. Buffer-unten in Forward-Reihenfolge (push behält Top-Down-Order).
* 3. Buffer-oben in Forward-Reihenfolge (least likely, push ans Ende).
*
* priority ist `true` für Viewport-Items, `false` für Buffer. Konsumenten
* (siehe enqueueThumb in GridView) können das in unshift-vs-push übersetzen.
*
* @param renderStartIdx Erstes gerendertes Item (inklusive)
* @param renderEndIdx Letztes gerendertes Item + 1 (exklusive)
* @param vpStart Erstes Viewport-Item (inklusive, ≥ renderStartIdx)
* @param vpEnd Letztes Viewport-Item + 1 (exklusive, ≤ renderEndIdx)
*/
export function* iterateViewportFirst(renderStartIdx, renderEndIdx, vpStart, vpEnd) {
for (let i = vpEnd - 1; i >= vpStart; i--) yield [i, true]
for (let i = vpEnd; i < renderEndIdx; i++) yield [i, false]
for (let i = renderStartIdx; i < vpStart; i++) yield [i, false]
}
95 changes: 95 additions & 0 deletions tests/e2e/mobile-virtualization.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* StarRate E2E – Mobile-Viewport Virtualisierung
*
* Sichert ab, dass auf Mobile-Viewport-Breite (414×896, iPhone-Plus-ish)
* die Grid-Virtualisierung aktiv ist und Scrollen den Render-Range
* shiftet — die Logik die in jsdom-Vitest nicht abgedeckt ist
* (clientWidth/Height = 0 → virtualEnabled=false → Fallback rendert alles).
*
* Voraussetzung: TEST_FOLDER hat ≥20 Bilder, sonst skippen die Tests
* mit Log-Meldung. Anzahl der Spalten wird hier NICHT asserted — Cypress'
* Viewport-Setting steuert window.innerWidth, aber NC's mobile UI/Sidebar-
* Verhalten variiert; wichtig ist nur dass virtualEnabled triggert und
* Range-Shift + Spacer-Logik funktionieren.
*/

import { login, openFolder, NC_URL, NC_USER, NC_PASS, TEST_FOLDER } from './helpers'

describe('Mobile Viewport Virtualisierung', () => {
let totalImages = 0

before(() => {
login()
cy.request({
method: 'GET',
url: `${NC_URL}/index.php/apps/starrate/api/images?path=${encodeURIComponent(TEST_FOLDER)}`,
headers: { 'OCS-APIREQUEST': 'true' },
auth: { user: NC_USER, pass: NC_PASS },
}).then(resp => {
totalImages = resp.body.images?.length || 0
cy.log(`Test folder hat ${totalImages} Bilder`)
})
})

beforeEach(() => {
cy.viewport(414, 896) // iPhone Plus, 2-Spalten-Layout
cy.clearLocalStorage()
login()
})

it('aktiviert Virtualisierung wenn Bilder den Mobile-Viewport übersteigen', () => {
if (totalImages < 12) {
cy.log(`Skip: nur ${totalImages} Bilder im Test-Folder, braucht ≥12 für Virtualisierung-Trigger`)
return
}
openFolder()
// Bei ≥12 Items auf 2-Spalten-Mobile (~6+ Reihen) geht das über den
// initialen Render-Range hinaus — bottomSpacer muss existieren und
// gerenderte Items < total.
cy.get('.sr-grid__item:not(.sr-grid__item--skeleton)').should('have.length.lessThan', totalImages)
cy.get('.sr-grid__spacer').should('exist')
})

it('shiftet den Render-Range beim Scrollen', () => {
if (totalImages < 20) {
cy.log(`Skip: nur ${totalImages} Bilder, braucht ≥20 für sichtbares Range-Shift`)
return
}
openFolder()
// data-index des ersten gerenderten Items festhalten
cy.get('.sr-grid__item:not(.sr-grid__item--skeleton)').first()
.invoke('attr', 'data-index')
.then(initialIdx => {
// Weit genug scrollen, dass der Top-Buffer rausfällt
// (BUFFER=2 Reihen × 163 ≈ 326px; 1500px = ~9 Reihen Scroll)
cy.get('.sr-grid').scrollTo(0, 1500, { ensureScrollable: false })
cy.wait(400) // rAF + Vue render + observer

cy.get('.sr-grid__item:not(.sr-grid__item--skeleton)').first()
.invoke('attr', 'data-index')
.then(newIdx => {
// Wenn wir am Top starteten (idx=0): nach Scroll muss data-index > 0
// Wenn schon initial verschoben (Loupe-Sync etc.): nicht regressionsfähig,
// aber mindestens nicht negativ und Number-Parse muss klappen
expect(parseInt(newIdx, 10)).to.be.gte(parseInt(initialIdx, 10))
if (initialIdx === '0') {
expect(parseInt(newIdx, 10)).to.be.greaterThan(0)
}
})
})
})

it('topSpacer wächst nach Scrollen aus dem Top-Bereich', () => {
if (totalImages < 20) {
cy.log(`Skip: nur ${totalImages} Bilder, braucht ≥20`)
return
}
openFolder()
// Initial: visibleStartRow=0 → topSpacer wird via v-if NICHT gerendert
// (es gibt nur den bottomSpacer im DOM, falls überhaupt einer nötig ist).
// Nach Scroll > BUFFER × rowStride sollten zwei Spacer existieren (top + bottom).
cy.get('.sr-grid').scrollTo(0, 1500, { ensureScrollable: false })
cy.wait(400)
cy.get('.sr-grid__spacer').should('have.length.gte', 2)
})
})
44 changes: 44 additions & 0 deletions tests/js/GridView.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -462,4 +462,48 @@ describe('GridView', () => {
expect(items[0].attributes('data-index')).toBe('0')
expect(items[19].attributes('data-index')).toBe('19')
})

// ── Tastatur-Navigation: columnsEstimate ─────────────────────────────────
//
// ↑/↓ steppen um eine Reihe = `columnsCount` Items. Vor dem Fix nutzte
// columnsEstimate() eine eigene offsetWidth/THUMB_SIZE-Berechnung, die in
// jsdom 0 lieferte und auf Mobile (390px / 280) immer 1 ergab — auf
// Mobile-2-Spalten navigierte ↓ also nur 1 Item statt 1 Reihe. Mit dem
// Fix nutzt columnsEstimate columnsCount.value, das via gridColumns-Prop
// deterministisch ohne Layout setzbar ist.

it('ArrowDown steppt um columnsCount Items (gridColumns=2)', async () => {
const w = factory({ images: makeImages(20), gridColumns: '2' })
const items = w.findAll('.sr-grid__item:not(.sr-grid__item--skeleton)')
await items[0].trigger('click')
await w.trigger('keydown', { key: 'ArrowDown' })
// Bei 2 Spalten: Reihe-runter = +2 Items
expect(w.find('.sr-grid__item--focused').attributes('data-index')).toBe('2')
})

it('ArrowDown steppt um columnsCount Items (gridColumns=4)', async () => {
const w = factory({ images: makeImages(20), gridColumns: '4' })
const items = w.findAll('.sr-grid__item:not(.sr-grid__item--skeleton)')
await items[0].trigger('click')
await w.trigger('keydown', { key: 'ArrowDown' })
expect(w.find('.sr-grid__item--focused').attributes('data-index')).toBe('4')
})

it('ArrowUp steppt rückwärts um columnsCount Items', async () => {
const w = factory({ images: makeImages(20), gridColumns: '3' })
const items = w.findAll('.sr-grid__item:not(.sr-grid__item--skeleton)')
await items[6].trigger('click') // Reihe 2 (Items 6, 7, 8)
await w.trigger('keydown', { key: 'ArrowUp' })
expect(w.find('.sr-grid__item--focused').attributes('data-index')).toBe('3')
})

it('ArrowDown am unteren Rand clampt auf letztes Item', async () => {
// 20 Items bei 3 Spalten: letzte volle Reihe ist Items 18,19 in Reihe 6.
// Von Item 19 aus ↓: würde Index 22 ergeben, geclampt auf 19.
const w = factory({ images: makeImages(20), gridColumns: '3' })
const items = w.findAll('.sr-grid__item:not(.sr-grid__item--skeleton)')
await items[19].trigger('click')
await w.trigger('keydown', { key: 'ArrowDown' })
expect(w.find('.sr-grid__item--focused').attributes('data-index')).toBe('19')
})
})
Loading
Loading