feat(marketing): round 3 — hero remplacé par 3-step flow canonique + 99+ langues + Cégeps spotlight + CyberPerformance + FAQ enrichie
Hero (templates/marketing/landing.html) : - Reproduction fidèle de dictia.ca/solutions/dictai (source : Website-Sanity/components/sections/dictai-page-content.tsx lignes 260-518) - REMPLACE le mockup app DictIA par le 3-step flow inline canonique (Importez → Texte 2 min → Résumé + actions) - Wordmark large « DictIA » (style production) + H2 cyan « Transcription IA locale en 2 minutes — conforme Barreau, CPA Québec et ChAD » - Sub canonique référençant OVH Beauharnois, Cadre IA MCN, 5 ordres à directives IA formelles - Stats grid (4 col) : ~2 min · 5 ordres · 95 %+ · 0 $ (NBSP OQLF) - Eyebrow back-link « Toutes les solutions » - 5 animations Framer Motion → CSS pure + Alpine.js : 1. 3-step flow auto-cycle 1→2→3 (setInterval 1.8 s, désactivé reduced-motion) 2. Magnetic CTA primary (mousemove → translate max 8 px) 3. Mouse parallax orb 3D (mousemove window → CSS transition) 4. Shockwave on click (CSS pseudo-element scale 0→4 + opacity) 5. Word-staggered title reveal (Dict + IA via animation-delay) Sections enrichies / ajoutées : - Pipeline : sous-titre « Du fichier au résumé — en temps réel » + hint canonique - NEW « 99+ langues détectées » + carte « IA Mistral 7B (LOCAL) » 4 bullets - Pricing : sous-titres canoniques par forfait + note « Tous les prix en CAD, taxes en sus (TPS 5 % + TVQ 9,975 %) » - Conformité : 3 chips claims (~192 000 pros · 5 ordres · 0 donnée hors-Québec) + phrase secteurs réglementés - NEW Cégeps spotlight « Conformité au 19 juin 2026 » avec Cadre IA MCN détaillé (7 bullets, 9 chips organismes, badge pulse glow) - NEW Partenaire CyberPerformance (card horizontale + lien externe) - FAQ : enrichie de 7 → 10 questions canoniques sourcées de dictai-page-content.tsx (Teams Copilot, Otter.ai, Barreau, Clio Manage, etc.) - CTA final : « Prêt à protéger vos données ? » + bouton « Réserver ma démo gratuite » (préserve mailto pré-inscription) Tests : - Ajout tests/conftest.py (stub fcntl POSIX + env vars test) pour permettre exécution sur Windows - Mise à jour 8 assertions liées au nouveau hero, FAQ 10 Q, CTA renforcé, NBSP OQLF dans eyebrow - 61 passed / 3 pré-existant échecs baseline (/blog dans nav + footer + trust-bar phrasing) Contraintes respectées : zéro JS externe, aucun emoji (SVG inline aria-hidden), V3 radii (rounded/rounded-full), brand tokens, OQLF NBSP partout, WCAG (aria-labels, focus-visible, prefers-reduced-motion désactive toutes les animations hero). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
25
tests/conftest.py
Normal file
25
tests/conftest.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Test bootstrap — Windows shim for fcntl (used by src/init_db.py on POSIX).
|
||||
|
||||
Allows running tests on Windows even though the production app targets Linux.
|
||||
Mirrors the stub used by serve_marketing.py for local preview.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
# Stub fcntl BEFORE pytest collects any test that imports src.app
|
||||
if sys.platform.startswith('win') and 'fcntl' not in sys.modules:
|
||||
fcntl_stub = types.ModuleType('fcntl')
|
||||
fcntl_stub.LOCK_EX = 2
|
||||
fcntl_stub.LOCK_NB = 4
|
||||
fcntl_stub.LOCK_UN = 8
|
||||
fcntl_stub.LOCK_SH = 1
|
||||
fcntl_stub.flock = lambda *_a, **_kw: None
|
||||
fcntl_stub.fcntl = lambda *_a, **_kw: 0
|
||||
sys.modules['fcntl'] = fcntl_stub
|
||||
|
||||
# Minimal env so src/config/app_config.py doesn't sys.exit on missing config
|
||||
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
|
||||
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
|
||||
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://local-stub')
|
||||
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'local-stub')
|
||||
@@ -83,22 +83,30 @@ def test_landing_no_login_redirect_for_anonymous():
|
||||
|
||||
|
||||
def test_hero_has_h1_with_grad_text_accent():
|
||||
"""Hero H1 contains grad-text span on the brand tagline."""
|
||||
"""Hero H1 (round 3) contains the brand wordmark with grad-text accent on 'IA'.
|
||||
|
||||
Round 3 replaces the old tagline ('sans risquer votre permis') with the canonical
|
||||
DictIA wordmark + the H2 phrase 'Transcription IA locale en 2 minutes'.
|
||||
"""
|
||||
client = app.test_client()
|
||||
body = client.get('/').data.decode('utf-8')
|
||||
assert 'id="hero-title"' in body, "Missing hero-title id on H1"
|
||||
assert 'grad-text' in body, "Missing grad-text class somewhere"
|
||||
assert 'sans risquer votre permis' in body, "Missing key brand tagline"
|
||||
# New canonical brand H2 phrase (cyan/grad on key claim)
|
||||
assert 'Transcription IA locale en 2' in body, "Missing canonical H2 phrase"
|
||||
# Hero word-staggered reveal hook on the wordmark
|
||||
assert 'hero-h1-word' in body, "Missing word-staggered reveal class"
|
||||
|
||||
|
||||
def test_hero_has_dual_cta():
|
||||
"""Hero has both primary (Réserver une démo) and ghost (Voir les tarifs) CTAs."""
|
||||
"""Hero (round 3) has primary (Réserver une démo) and ghost (Voir les forfaits) CTAs."""
|
||||
client = app.test_client()
|
||||
body = client.get('/').data.decode('utf-8')
|
||||
assert 'href="/contact"' in body
|
||||
assert 'href="/tarifs"' in body
|
||||
assert 'Réserver une démo' in body or 'Réserver une démo' in body
|
||||
assert 'Voir les tarifs' in body
|
||||
# Round 3 canonical wording: 'Voir les forfaits' (matches dictia.ca/solutions/dictai)
|
||||
assert 'Voir les forfaits' in body, "Round 3 secondary CTA must say 'Voir les forfaits'"
|
||||
|
||||
|
||||
def test_hero_has_cosmic_orbs_background():
|
||||
@@ -121,25 +129,28 @@ def test_hero_has_social_proof_microcopy():
|
||||
|
||||
|
||||
def test_hero_has_staggered_animations():
|
||||
"""Hero elements use tc-fade-in-up with staggered delays."""
|
||||
"""Hero (round 3) elements use tc-fade-in-up with staggered delays — canonical cadence.
|
||||
|
||||
Round 3 staggers : 0 (back-link), 75 (eyebrow), 200 (3-step flow),
|
||||
280 (H2 phrase), 360 (sub), 440 (stats), 520 (CTAs), 600 (social proof).
|
||||
"""
|
||||
client = app.test_client()
|
||||
body = client.get('/').data.decode('utf-8')
|
||||
assert 'animate-tc-fade-in-up' in body, "Missing fade-in animation"
|
||||
assert 'animation-delay: 0ms' in body
|
||||
assert 'animation-delay: 75ms' in body
|
||||
assert 'animation-delay: 150ms' in body
|
||||
assert 'animation-delay: 300ms' in body
|
||||
assert 'animation-delay: 400ms' in body
|
||||
for delay in ['0ms', '75ms', '200ms', '280ms', '360ms', '440ms', '520ms', '600ms']:
|
||||
assert f'animation-delay: {delay}' in body, f"Missing staggered delay {delay}"
|
||||
assert 'animation-fill-mode: backwards' in body, \
|
||||
"Missing animation-fill-mode (causes flash before delay fires)"
|
||||
|
||||
|
||||
def test_hero_eyebrow_has_brand_messaging():
|
||||
"""Hero eyebrow declares the 3 brand pillars."""
|
||||
"""Hero eyebrow declares the 3 brand pillars (round 3 uses OQLF NBSP : LOI 25)."""
|
||||
client = app.test_client()
|
||||
body = client.get('/').data.decode('utf-8')
|
||||
assert 'TRANSCRIPTION IA' in body
|
||||
assert 'CONFORME LOI 25' in body
|
||||
# OQLF-conformant : non-breaking space before "25" (NBSP entity)
|
||||
assert 'CONFORME LOI 25' in body or 'CONFORME LOI 25' in body, \
|
||||
"Missing 'CONFORME LOI 25' eyebrow (with or without NBSP)"
|
||||
assert 'QU' in body # Either QUÉBEC or QUÉBEC
|
||||
|
||||
|
||||
@@ -665,31 +676,32 @@ def test_testimonials_use_personas_not_fake_names():
|
||||
assert name not in body, f"Forbidden fabricated testimonial name: {name}"
|
||||
|
||||
|
||||
def test_faq_section_with_7_questions():
|
||||
"""FAQ section present with 7 questions."""
|
||||
def test_faq_section_with_10_questions():
|
||||
"""FAQ section (round 3) present with 10 canonical questions from dictai-page-content.tsx."""
|
||||
client = app.test_client()
|
||||
body = client.get('/').data.decode('utf-8')
|
||||
assert 'faq-title' in body
|
||||
# 7 panel IDs (loop.index is 1-indexed)
|
||||
for i in range(1, 8):
|
||||
# 10 panel IDs (loop.index is 1-indexed)
|
||||
for i in range(1, 11):
|
||||
assert f'id="faq-panel-{i}"' in body, f"Missing FAQ panel {i}"
|
||||
# Question topic anchors
|
||||
topics = ['Loi', 'Cloud et DictIA on-premise', 'fran', 'audiences', 'formats',
|
||||
'résilie', 'AGPL']
|
||||
# Round 3 canonical topic anchors (sourced from dictai-page-content.tsx)
|
||||
topics = ['Comment fonctionne la transcription', 'formats audio', '1 heure d',
|
||||
'confidentielle', 'Teams Copilot', 'Otter.ai', 'Barreau du Qu',
|
||||
'Clio Manage', 'connaissances techniques', 'open source']
|
||||
for topic in topics:
|
||||
assert topic in body, f"FAQ missing topic anchor: {topic}"
|
||||
|
||||
|
||||
def test_faq_alpine_accordion_bindings():
|
||||
"""FAQ uses Alpine.js x-data + @click + :aria-expanded for accessible accordion."""
|
||||
"""FAQ uses Alpine.js x-data + @click + :aria-expanded for accessible accordion (10 items)."""
|
||||
client = app.test_client()
|
||||
body = client.get('/').data.decode('utf-8')
|
||||
# 7 x-data="{ open: false }" instances
|
||||
assert body.count('x-data="{ open: false }"') == 7, \
|
||||
"FAQ must have 7 independent Alpine accordion items"
|
||||
# 10 x-data="{ open: false }" instances (round 3 enrichment)
|
||||
assert body.count('x-data="{ open: false }"') == 10, \
|
||||
"FAQ must have 10 independent Alpine accordion items"
|
||||
# Each toggle button has @click and :aria-expanded
|
||||
assert body.count('@click="open = !open"') == 7
|
||||
assert body.count(':aria-expanded="open.toString()"') == 7
|
||||
assert body.count('@click="open = !open"') == 10
|
||||
assert body.count(':aria-expanded="open.toString()"') == 10
|
||||
# Use built-in x-transition (NOT x-collapse plugin which is not bundled)
|
||||
assert 'x-collapse' not in body, "Must NOT use x-collapse plugin (not loaded — use x-transition)"
|
||||
assert 'x-transition.opacity' in body, "FAQ panels must use built-in x-transition"
|
||||
@@ -720,7 +732,8 @@ def test_faq_jsonld_schema_present():
|
||||
assert parsed['@context'] == 'https://schema.org'
|
||||
assert parsed['@type'] == 'FAQPage'
|
||||
assert isinstance(parsed['mainEntity'], list)
|
||||
assert len(parsed['mainEntity']) == 7, "FAQPage must contain exactly 7 questions"
|
||||
# Round 3: enriched to 10 canonical questions from dictai-page-content.tsx
|
||||
assert len(parsed['mainEntity']) == 10, "FAQPage must contain exactly 10 questions (round 3)"
|
||||
for q in parsed['mainEntity']:
|
||||
assert q['@type'] == 'Question'
|
||||
assert q['acceptedAnswer']['@type'] == 'Answer'
|
||||
@@ -729,18 +742,25 @@ def test_faq_jsonld_schema_present():
|
||||
|
||||
|
||||
def test_cta_final_section():
|
||||
"""CTA final section with mailto pré-inscription + ghost button to #tarifs."""
|
||||
"""CTA final (round 3) — primary démo gratuite + mailto pré-inscription + ghost button to #tarifs."""
|
||||
client = app.test_client()
|
||||
body = client.get('/').data.decode('utf-8')
|
||||
assert 'cta-title' in body
|
||||
assert 'pré-inscription' in body or 'pré-inscription' in body
|
||||
# Round 3 wording reinforced: "Prêt à protéger vos données" + démo gratuite
|
||||
assert 'prot' in body and 'donn' in body, "Missing 'protéger vos données' headline"
|
||||
assert 'démo gratuite' in body or 'démo gratuite' in body, \
|
||||
"Round 3 primary CTA must say 'démo gratuite'"
|
||||
# Pré-inscription wording (any case) preserved as secondary path
|
||||
assert 'pré-inscription' in body or 'pré-inscription' in body \
|
||||
or 'Pré-inscription' in body or 'Pré-inscription' in body, \
|
||||
"Pré-inscription wording must be preserved"
|
||||
# mailto with subject
|
||||
assert 'href="mailto:info@dictia.ca?subject=Pr%C3%A9-inscription%20DictIA"' in body or \
|
||||
'href="mailto:info@dictia.ca?subject=Pré-inscription%20DictIA"' in body, \
|
||||
"CTA must have mailto with subject prefilled"
|
||||
# Anchor link to existing #tarifs section
|
||||
assert 'href="#tarifs"' in body, "Secondary CTA must anchor to pricing"
|
||||
# Ghost variant button
|
||||
# Ghost variant button still in use (mailto + #tarifs)
|
||||
assert 'border-white/[0.08]' in body # ghost button class
|
||||
|
||||
|
||||
@@ -839,12 +859,16 @@ def test_round2_no_external_js_libs_added():
|
||||
|
||||
|
||||
def test_round2_preserves_existing_sections():
|
||||
"""Round 2 inserts must NOT remove Hero / Pipeline / Hub / Bento / Comparatif / Conformité."""
|
||||
"""Round 2 + 3 inserts must NOT remove Hero / Pipeline / Hub / Bento / Comparatif / Conformité.
|
||||
|
||||
NOTE: round 3 replaced the hero copy ('sans risquer votre permis' → canonical wordmark
|
||||
+ 'Transcription IA locale en 2 minutes'). The hero ID + pipeline are still required.
|
||||
"""
|
||||
client = app.test_client()
|
||||
body = client.get('/').data.decode('utf-8')
|
||||
# Hero (round 0)
|
||||
# Hero (round 3 canonical hero replaces round 0)
|
||||
assert 'hero-title' in body
|
||||
assert 'sans risquer votre permis' in body
|
||||
assert 'Transcription IA locale en 2' in body, "Round 3 hero canonical phrase missing"
|
||||
# Pipeline (round 1) — auto-advance + 4 nodes
|
||||
assert 'pipeline-title' in body
|
||||
assert 'Du fichier au résumé' in body
|
||||
@@ -880,7 +904,8 @@ def test_routes_passes_testimonials_and_faq_to_template():
|
||||
assert hasattr(routes, 'TESTIMONIALS'), "Module must define TESTIMONIALS list"
|
||||
assert hasattr(routes, 'FAQ'), "Module must define FAQ list"
|
||||
assert len(routes.TESTIMONIALS) == 3, "Must have 3 placeholder testimonials"
|
||||
assert len(routes.FAQ) == 7, "Must have 7 FAQ entries"
|
||||
# Round 3: enriched FAQ from 7 to 10 canonical questions (sourced from dictai-page-content.tsx)
|
||||
assert len(routes.FAQ) == 10, "Must have 10 FAQ entries (round 3)"
|
||||
# Each testimonial must NOT contain a 'quote' field (no fabricated quotes pre-launch)
|
||||
for t in routes.TESTIMONIALS:
|
||||
assert 'quote' not in t, "Pre-launch testimonials must not contain quote fields"
|
||||
|
||||
Reference in New Issue
Block a user