// ----------------------------------------------------------------------------- // Rent-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // scripts/test-sync.mjs : validation E2E de la synchronisation liste ↔ carte // (critères d'acceptation 1-9 du chantier « sync légendaire »). // Usage : node scripts/test-sync.mjs (RENTKA_BASE pour cibler un déploiement) // Captures : reports/sync-validation/*.png // ----------------------------------------------------------------------------- import { chromium, devices } from 'playwright'; import { mkdirSync } from 'fs'; const BASE = process.env.RENTKA_BASE || 'http://localhost:8125'; const OUT = new URL('../reports/sync-validation/', import.meta.url).pathname; mkdirSync(OUT, { recursive: true }); let passed = 0, failed = 0; const results = []; function check(name, ok, detail = '') { results.push({ name, ok, detail }); if (ok) { passed++; console.log(` ✓ ${name}${detail ? ` — ${detail}` : ''}`); } else { failed++; console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`); } } const browser = await chromium.launch(); const initScript = () => { localStorage.setItem('rentka-consent-v1', JSON.stringify({ essential: true, preferences: true, statistics: false, ts: 1 })); sessionStorage.setItem('rentka_geo_session', '1'); // pas de prompt géoloc ici }; async function newDesktop() { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'fr-CA', }); await ctx.addInitScript(initScript); return ctx; } const engineState = (page) => page.evaluate(() => { const m = globalThis.__kaMap; if (!m) return null; const st = m.getState(); return { ...st, nProps: m.getState().activeLayers.length >= 0 ? (m.map.querySourceFeatures ? undefined : undefined) : undefined }; }); const engineCount = (page) => page.evaluate(() => { const m = globalThis.__kaMap; // byId est privé : compter via les données GeoJSON poussées return m ? m['byId'].size : -1; }); const counterTotal = async (page) => { const txt = await page.locator('.ms-count b').first().textContent({ timeout: 15000 }); return Number(txt.replace(/\s| |,/g, '')); }; const canvasBox = async (page) => { await page.locator('.mapview').scrollIntoViewIfNeeded(); await page.waitForTimeout(400); return page.locator('.ka-map-canvas').boundingBox(); }; const waitSettled = async (page, ms = 1200) => { await page.waitForTimeout(ms); await page.waitForFunction(() => !document.querySelector('.ms-progress'), null, { timeout: 20000 }) .catch(() => {}); }; // =============================== TESTS ===================================== console.log('— Vue carte desktop —'); { const ctx = await newDesktop(); const page = await ctx.newPage(); await page.goto(`${BASE}/?view=carte`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.ms-count b', { timeout: 30000 }); await page.waitForFunction(() => { const m = globalThis.__kaMap; return m && m['byId'].size > 0; }, null, { timeout: 30000 }); await waitSettled(page, 2500); // C1 — compteur liste = points carte const total = await counterTotal(page); const nPts = await engineCount(page); check('C1 compteur liste = points carte (arrivée)', total === nPts, `${total} = ${nPts}`); await page.screenshot({ path: `${OUT}01-carte-desktop.png` }); // C2/C5 — déplacement carte → liste mise à jour, jamais de résultat périmé const urlBefore = page.url(); const cb = await canvasBox(page); const mx = cb.x + cb.width / 2, my = cb.y + cb.height / 2; await page.mouse.move(mx, my); await page.mouse.down(); await page.mouse.move(mx - 260, my - 60, { steps: 12 }); await page.mouse.up(); await waitSettled(page, 1800); const total2 = await counterTotal(page); const nPts2 = await engineCount(page); check('C2 déplacement → compteur/liste rafraîchis, égalité conservée', total2 === nPts2, `${total2} = ${nPts2}`); check('C7 caméra dans l’URL après déplacement', page.url() !== urlBefore && /lat=/.test(page.url())); // rafale de mouvements rapides : la dernière réponse gagne toujours for (let i = 0; i < 4; i++) { await page.mouse.move(mx, my); await page.mouse.down(); await page.mouse.move(mx - 70 * (i + 1), my + 20, { steps: 4 }); await page.mouse.up(); await page.waitForTimeout(120); } await waitSettled(page, 2200); const total3 = await counterTotal(page); const nPts3 = await engineCount(page); check('C5 mouvements rapides successifs → aucun résultat périmé', total3 === nPts3, `${total3} = ${nPts3}`); // retour dans une zone dense avant les tests de miroirs await page.goto(`${BASE}/?view=carte&lat=46.81&lng=-71.23&zoom=13`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.map-card', { timeout: 30000 }); await waitSettled(page, 2500); // C3 — survol d'une carte de liste → marqueur en survol < 100 ms const firstCard = page.locator('.map-card').first(); const uid = await firstCard.getAttribute('data-uid'); await firstCard.hover(); await page.waitForTimeout(90); const hovered = await page.evaluate(() => globalThis.__kaMap.getState().hoveredPropertyId); check('C3 survol liste → marqueur illuminé (< 100 ms)', hovered === uid, `${hovered}`); // C3 inverse — survol venu de la carte → carte de liste surlignée await page.mouse.move(10, 10); // souris hors de la liste await page.evaluate(() => globalThis.__kaMap.setHovered(null, 'map')); await page.waitForTimeout(60); await page.evaluate((u) => globalThis.__kaMap.setHovered(u, 'map'), uid); await page.waitForTimeout(90); const hl = await page.locator(`.map-card[data-uid="${uid}"]`).getAttribute('class'); check('C3 survol carte → annonce surlignée dans la liste', hl.includes('map-card-hover')); await page.evaluate(() => globalThis.__kaMap.setHovered(null, 'map')); // C4 — clic marqueur (simulé moteur) → liste défile vers l'annonce + mini-fiche const farUid = await page.evaluate(() => { const m = globalThis.__kaMap; const ids = [...m['byId'].keys()]; return ids[Math.min(ids.length - 1, 30)]; }); await page.evaluate((u) => globalThis.__kaMap.select(u, 'map'), farUid); await page.waitForTimeout(1400); const selVisible = await page.evaluate((u) => { const el = document.querySelector(`.map-card[data-uid="${CSS.escape(u)}"]`); if (!el) return 'absente'; const r = el.getBoundingClientRect(); const c = el.closest('.ms-list').getBoundingClientRect(); return r.bottom > c.top && r.top < c.bottom ? 'visible' : 'hors-vue'; }, farUid); check('C4 clic marqueur → défilement de la liste jusqu’à l’annonce', selVisible === 'visible', selVisible); const preview = await page.locator('.ka-preview').count(); check('C4 mini-fiche ouverte au clic marqueur', preview === 1); await page.screenshot({ path: `${OUT}02-selection-miroir.png` }); // C4 inverse — clic annonce → carte recentrée sur le marqueur + sélection await page.evaluate(() => globalThis.__kaMap.select(null, 'app')); const cardUid = await page.locator('.map-card').nth(2).getAttribute('data-uid'); await page.locator('.map-card').nth(2).click(); await page.waitForTimeout(900); const selId = await page.evaluate(() => globalThis.__kaMap.getSelectedId()); check('C4 clic annonce → marqueur sélectionné sur la carte', selId === cardUid); // C2 — filtre resserré depuis l'URL partagée → les deux vues suivent await ctx.close(); } console.log('— Filtres → carte (compteur partagé) —'); { const ctx = await newDesktop(); const page = await ctx.newPage(); await page.goto(`${BASE}/?view=carte&price_max=900`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.ms-count b', { timeout: 30000 }); await waitSettled(page, 2500); const total = await counterTotal(page); const nPts = await engineCount(page); check('C1 filtre loyer ≤ 900 $ : compteur = points', total === nPts, `${total} = ${nPts}`); // tri partagé : passer en « Meilleures affaires » réordonne liste + points await page.locator('.ms-sort select').selectOption('deal'); await waitSettled(page, 1800); const firstUid = await page.locator('.map-card').first().getAttribute('data-uid'); const firstPointUid = await page.evaluate(() => [...globalThis.__kaMap['byId'].keys()][0]); check('C1 tri partagé : 1er de la liste = 1er point du tri', firstUid === firstPointUid, `${firstUid}`); await page.screenshot({ path: `${OUT}03-filtre-tri.png` }); await ctx.close(); } console.log('— Zone dessinée (polygone) —'); { const ctx = await newDesktop(); const page = await ctx.newPage(); await page.goto(`${BASE}/?view=carte&lat=46.81&lng=-71.23&zoom=13`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.ka-draw-btn', { timeout: 30000 }); await waitSettled(page, 2500); const box = await canvasBox(page); await page.locator('.ka-draw-btn').click(); const cx = box.x + box.width / 2, cy = box.y + box.height / 2; await page.mouse.click(cx - 160, cy - 80); await page.mouse.click(cx + 160, cy - 60); await page.mouse.click(cx + 40, cy + 140); await page.mouse.click(cx - 160, cy - 80); // fermeture sur le 1er sommet await waitSettled(page, 2000); const chip = await page.locator('.ms-pills .pill').count(); check('Polygone → puce « Zone dessinée » visible', chip >= 1); const total = await counterTotal(page); const nPts = await engineCount(page); check('C1 polygone : compteur = points', total === nPts && total > 0, `${total} = ${nPts}`); check('C7 zone dans l’URL', /zone=/.test(page.url())); await page.screenshot({ path: `${OUT}04-polygone.png` }); // retrait de la puce → retour à la zone visible await page.locator('.ms-pills .pill').first().click(); await waitSettled(page, 1500); check('Puce retirée → polygone effacé', !/zone=/.test(page.url())); await ctx.close(); } console.log('— Mode manuel & zéro résultat —'); { const ctx = await newDesktop(); const page = await ctx.newPage(); await page.goto(`${BASE}/?view=carte&lat=46.81&lng=-71.23&zoom=12`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.ka-search-area-auto input', { timeout: 30000 }); await waitSettled(page, 2500); await page.locator('.ka-search-area-auto input').setChecked(false); const mb = await canvasBox(page); const mmx = mb.x + mb.width / 2, mmy = mb.y + mb.height / 2; await page.mouse.move(mmx, mmy); await page.mouse.down(); await page.mouse.move(mmx - 320, mmy - 30, { steps: 10 }); await page.mouse.up(); await page.waitForTimeout(900); const btn = await page.locator('.ka-search-area-btn').count(); check('Case décochée + déplacement → « Rechercher dans cette zone »', btn === 1); check('C7 move=0 dans l’URL', /move=0/.test(page.url())); // zéro résultat : plein nord await page.goto(`${BASE}/?view=carte&lat=55.5&lng=-70.0&zoom=9`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.ms-empty, .ka-empty', { timeout: 30000 }); await waitSettled(page, 2000); const emptyTxt = await page.locator('.ms-empty h3').first().textContent().catch(() => ''); check('C9 zone vide → état vide propre + « Élargir la zone »', (emptyTxt || '').includes('Aucun logement'), emptyTxt?.trim()); const widen = await page.locator('.ms-empty .btn-primary').count(); check('C9 bouton « Élargir la zone » présent', widen === 1); await page.screenshot({ path: `${OUT}05-zero-resultat.png` }); await ctx.close(); } console.log('— URL partagée = recherche reproduite —'); { const ctx = await newDesktop(); const page = await ctx.newPage(); const shared = `${BASE}/?view=carte&price_max=1200&unit_type=3%C2%BD&lat=46.80&lng=-71.24&zoom=13&tri=recent`; await page.goto(shared, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.ms-count b', { timeout: 30000 }); await waitSettled(page, 2500); const total = await counterTotal(page); const nPts = await engineCount(page); const sortVal = await page.locator('.ms-sort select').inputValue(); const zoom = await page.evaluate(() => Math.round(globalThis.__kaMap.getState().zoom * 10) / 10); check('C7 URL partagée : filtres + zone + tri reproduits', total === nPts && sortVal === 'recent' && Math.abs(zoom - 13) < 0.6, `${total} résultats, tri=${sortVal}, zoom=${zoom}`); await page.screenshot({ path: `${OUT}06-url-partagee.png` }); await ctx.close(); } console.log('— Mobile : bascule + carrousel synchronisé —'); { const ctx = await browser.newContext({ ...devices['iPhone 14'], locale: 'fr-CA' }); await ctx.addInitScript(initScript); const page = await ctx.newPage(); await page.goto(`${BASE}/?view=carte&lat=46.81&lng=-71.23&zoom=13`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.ms-carousel', { timeout: 30000 }); await waitSettled(page, 3000); const items = await page.locator('.ms-car-item').count(); check('C8 carrousel mobile présent', items > 0, `${items} cartes`); // marqueur → carrousel : sélection du 5e point → carte correspondante mise en avant const uid5 = await page.evaluate(() => [...globalThis.__kaMap['byId'].keys()][4]); await page.evaluate((u) => globalThis.__kaMap.select(u, 'map'), uid5); await page.waitForTimeout(1200); const selCar = await page.locator('.ms-car-item.sel').getAttribute('data-uid').catch(() => null); check('C8 taper un marqueur → carrousel défile sur l’annonce', selCar === uid5, `${selCar}`); // carrousel → marqueur : balayage → sélection carte await page.locator('.ms-carousel').evaluate((el) => { el.scrollLeft += el.clientWidth * 2; }); await page.waitForTimeout(700); const selId = await page.evaluate(() => globalThis.__kaMap.getSelectedId()); check('C8 balayer le carrousel → sélection déplacée sur la carte', selId !== null && selId !== uid5, `${selId}`); await page.screenshot({ path: `${OUT}07-mobile-carrousel.png` }); // bascule Liste ↔ Carte sans perte d'état const urlCarte = page.url(); await page.locator('.view-toggle button').first().click(); // Liste await page.waitForTimeout(800); await page.locator('.view-toggle button').nth(1).click(); // Carte await page.waitForSelector('.ms-carousel', { timeout: 20000 }); await waitSettled(page, 2500); check('C8 bascule Liste ↔ Carte : état conservé (URL stable)', /view=carte/.test(page.url()) && /lat=/.test(page.url()), page.url().replace(BASE, '')); await page.screenshot({ path: `${OUT}08-mobile-carte.png` }); await ctx.close(); } console.log('— Géolocalisation d’accueil —'); { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'fr-CA', geolocation: { latitude: 46.8139, longitude: -71.2080 }, permissions: ['geolocation'], }); await ctx.addInitScript(() => { localStorage.setItem('rentka-consent-v1', JSON.stringify({ essential: true, preferences: true, statistics: false, ts: 1 })); }); const page = await ctx.newPage(); await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => /view=carte/.test(location.search), null, { timeout: 20000 }) .catch(() => {}); const ok = /view=carte/.test(page.url()) && /lat=46\.81/.test(page.url()); check('Arrivée + géoloc accordée → vue carte centrée sur l’utilisateur', ok, page.url().replace(BASE, '')); await page.waitForSelector('.ms-count b', { timeout: 30000 }); await waitSettled(page, 2500); await page.screenshot({ path: `${OUT}09-geolocalisation.png` }); await ctx.close(); } await browser.close(); console.log(`\n${passed} réussis, ${failed} échoués`); process.exit(failed > 0 ? 1 : 0);