V3 finalizes the radii pass to a fully brutalist/Swiss visual language: - Buttons (CTAs, submit, secondary, ghost, OAuth provider tiles): rounded-none (0px) - Form inputs (text/email/password/select/textarea/code-entry): rounded-none (0px) - Checkboxes: rounded-none (0px) — was rounded-sm - Small icon tiles (w-10 h-10 / w-12 h-12 grad-bg squares): rounded-none (0px) - Inline code blocks (totp recovery <pre>, secret <code>): rounded-none (0px) - Cards (pricing, bento, content panels, modals, prev/next nav): rounded (4px) — was rounded-lg - Alert / flash boxes: rounded (4px) — was rounded-lg - Pills, badges, status chips, ordres pros avatars, decorative cosmic orbs: rounded-full preserved - Legal _layout.html inline <style> blockquote/pre/code/draft-callout: border-radius 0 — was 4px Updated tests/test_marketing_landing_template.py assertions: - bento icon assertion: "grad-bg rounded " -> "grad-bg rounded-none " - pricing recommended frame: "rounded-lg" -> "rounded" (with strict trailing-char match to avoid rounded-none false positive) Verification: 18/18 legal tests pass, 58/58 marketing landing tests pass, 5/5 root redirect tests pass. Two pre-existing failures in test_marketing_secondary_pages (SOC 2 hedge text + gitea.innova-ai.ca URL) are unrelated to this radii pass.
760 lines
36 KiB
Python
760 lines
36 KiB
Python
"""Verify the marketing landing template renders correctly."""
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
|
|
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
|
|
|
|
from src.app import app # noqa: E402
|
|
|
|
|
|
def test_landing_renders_template_not_inline_html():
|
|
"""GET / renders templates/marketing/landing.html (not inline HTML from Phase 1)."""
|
|
client = app.test_client()
|
|
response = client.get('/', follow_redirects=False)
|
|
assert response.status_code == 200
|
|
body = response.data.decode('utf-8')
|
|
# Phase 2 template hallmarks
|
|
assert '<!DOCTYPE html>' in body, "Missing DOCTYPE — base.html not rendering"
|
|
assert 'lang="fr-CA"' in body, "Missing lang=fr-CA"
|
|
assert '/static/css/marketing.css' in body, "Missing marketing.css link"
|
|
assert '/static/fonts/Inter-Variable.woff2' in body, "Missing Inter font preload"
|
|
assert '/static/js/alpine.min.js' in body, "Missing Alpine.js script"
|
|
|
|
|
|
def test_landing_has_canonical_url():
|
|
"""OG + canonical metadata present."""
|
|
client = app.test_client()
|
|
response = client.get('/')
|
|
body = response.data.decode('utf-8')
|
|
assert 'rel="canonical"' in body
|
|
assert 'og:type' in body
|
|
assert 'og:locale' in body and 'fr_CA' in body
|
|
assert 'twitter:card' in body
|
|
|
|
|
|
def test_landing_has_glassmorphism_header():
|
|
"""FlexiHub-style header present (navy + backdrop-blur)."""
|
|
client = app.test_client()
|
|
response = client.get('/')
|
|
body = response.data.decode('utf-8')
|
|
assert 'bg-brand-navy/[0.97]' in body or 'bg-brand-navy' in body
|
|
assert 'backdrop-blur-xl' in body
|
|
assert 'border-white/[0.045]' in body, "Missing FlexiHub-style 0.045 border opacity"
|
|
|
|
|
|
def test_landing_has_main_nav():
|
|
"""Main nav has 5 links: Fonctionnalités, Conformité, Tarifs, Blog, Contact."""
|
|
client = app.test_client()
|
|
response = client.get('/')
|
|
body = response.data.decode('utf-8')
|
|
for link in ['/fonctionnalites', '/conformite', '/tarifs', '/blog', '/contact']:
|
|
assert f'href="{link}"' in body, f"Missing nav link: {link}"
|
|
|
|
|
|
def test_landing_has_login_and_signup_ctas():
|
|
"""Login + Signup CTAs present in header."""
|
|
client = app.test_client()
|
|
response = client.get('/')
|
|
body = response.data.decode('utf-8')
|
|
assert 'href="/login"' in body
|
|
assert 'href="/signup"' in body
|
|
assert 'Démarrer' in body or 'Démarrer' in body
|
|
|
|
|
|
def test_landing_footer_has_legal_links():
|
|
"""Footer placeholder includes legal links (full footer in A-2.7)."""
|
|
client = app.test_client()
|
|
response = client.get('/')
|
|
body = response.data.decode('utf-8')
|
|
assert '/legal/conditions' in body
|
|
assert '/legal/confidentialite' in body
|
|
assert 'info@dictia.ca' in body, "Missing canonical email info@dictia.ca"
|
|
assert 'Inverness' in body, "Missing Inverness QC address"
|
|
|
|
|
|
def test_landing_no_login_redirect_for_anonymous():
|
|
"""Anonymous user GET / must see template (regression check from B-1.3)."""
|
|
client = app.test_client()
|
|
response = client.get('/', follow_redirects=False)
|
|
assert response.status_code == 200, \
|
|
f"Expected 200, got {response.status_code} — possibly login_required regression"
|
|
|
|
|
|
def test_hero_has_h1_with_grad_text_accent():
|
|
"""Hero H1 contains grad-text span on the brand tagline."""
|
|
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"
|
|
|
|
|
|
def test_hero_has_dual_cta():
|
|
"""Hero has both primary (Réserver une démo) and ghost (Voir les tarifs) 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
|
|
|
|
|
|
def test_hero_has_cosmic_orbs_background():
|
|
"""Hero has 3 radial gradient orbs (FlexiHub signature)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# Look for the 3 orb opacities (16% blue, 7% cyan, 11% green)
|
|
assert 'rgba(0,98,255,0.16)' in body, "Missing primary blue orb"
|
|
assert 'rgba(0,189,216,0.07)' in body, "Missing cyan orb"
|
|
assert 'rgba(0,200,150,0.11)' in body, "Missing green accent orb"
|
|
|
|
|
|
def test_hero_has_social_proof_microcopy():
|
|
"""Hero has defensible social proof: 9 ordres pros + waitlist + launch date."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert '9 ordres professionnels' in body, "Missing factual ordres pros count"
|
|
assert 'Pré-inscription' in body or 'Pré-inscription' in body, "Missing waitlist mention"
|
|
assert 'Lancement printemps 2026' in body, "Missing launch date"
|
|
|
|
|
|
def test_hero_has_staggered_animations():
|
|
"""Hero elements use tc-fade-in-up with staggered delays."""
|
|
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
|
|
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."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'TRANSCRIPTION IA' in body
|
|
assert 'CONFORME LOI 25' in body
|
|
assert 'QU' in body # Either QUÉBEC or QUÉBEC
|
|
|
|
|
|
def test_trust_bar_has_9_ordres_pros():
|
|
"""Trust bar lists all 9 canonical Quebec ordres pros (matches dictia.ca)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
for ordre in ['Barreau', 'Chambre des notaires', 'CPA Québec', 'ChAD', 'OACIQ', 'CMQ', 'OIIQ', 'OPQ', 'OEQ']:
|
|
assert ordre in body, f"Missing ordre pro: {ordre}"
|
|
# Note: OPPQ deliberately removed (ambiguous abbrev — replaced with OPQ for Pharmaciens)
|
|
|
|
|
|
def test_trust_bar_has_eyebrow_factual_phrasing():
|
|
"""Trust bar avoids false-endorsement language (LPC art. 219 / Competition Act s. 52)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'MAPP' in body and '9 ORDRES PROFESSIONNELS' in body, "Missing factual eyebrow"
|
|
# Forbidden marketing phrases that imply official endorsement we don't have
|
|
forbidden = [
|
|
'CERTIFIÉ PAR',
|
|
'CERTIFIE PAR',
|
|
'ENDOSSÉ PAR',
|
|
'APPROUVÉ PAR',
|
|
'RECONNU PAR',
|
|
'AVALISÉ PAR',
|
|
]
|
|
body_upper = body.upper()
|
|
for phrase in forbidden:
|
|
assert phrase not in body_upper, f"Forbidden marketing claim found: {phrase}"
|
|
|
|
|
|
def test_trust_bar_has_4_kpis_with_grad_text():
|
|
"""Trust bar has 4 KPI metrics rendered with grad-text (NBSP per OQLF typography)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert '~5 min' in body
|
|
# OQLF: non-breaking space before %/$ via entity
|
|
assert '95 %+' in body, "Missing NBSP-separated 95%+ KPI"
|
|
assert '0 $' in body, "Missing NBSP-separated 0$ KPI"
|
|
assert '100 %' in body, "Missing NBSP-separated 100% KPI"
|
|
# Verify grad-text on KPI numbers
|
|
assert 'grad-text mb-2' in body, "Missing grad-text on KPI numbers"
|
|
|
|
|
|
def test_trust_bar_has_methodology_footnote():
|
|
"""95%+ claim has a defensible methodology footnote (LPC art. 219 hygiene)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# Verifiable wording: no specific hour count, methodology available on request
|
|
assert 'méthodologie disponible sur demande' in body or 'méthodologie disponible sur demande' in body
|
|
assert 'audio professionnel québécois' in body or 'audio professionnel québécois' in body
|
|
assert 'info@dictia.ca' in body
|
|
|
|
|
|
def test_pas_probleme_section_present():
|
|
"""Problème section (P of PAS frame) is present after trust bar."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'PROBL' in body and 'TRANSCRIPTION CLOUD' in body, "Missing Problème eyebrow"
|
|
assert 'violent la Loi 25' in body or 'violent la Loi 25' in body, \
|
|
"Missing legal-risk H2 anchor phrase"
|
|
assert 'Cloud Act' in body, "Missing Cloud Act card"
|
|
assert 'biom' in body and 'Loi 25' in body, "Missing Loi 25 biometric card"
|
|
assert 'Sanctions disciplinaires' in body, "Missing sanctions disciplinaires card"
|
|
|
|
|
|
def test_pas_solution_section_present():
|
|
"""Solution section (S of PAS frame) is present after Problème."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'LA SOLUTION' in body and 'DICTIA' in body, "Missing Solution eyebrow"
|
|
assert 'Conforme' in body and 'par design' in body, "Missing solution H2"
|
|
assert 'WhisperX' in body, "Missing WhisperX mention"
|
|
assert 'Mistral 7B' in body, "Missing Mistral 7B mention"
|
|
assert 'OVH Beauharnois' in body, "Missing Quebec hosting mention"
|
|
|
|
|
|
def test_pas_solution_3_pillars_with_check_icon():
|
|
"""Solution has 3 pillars: 100% local, Conforme Loi 25, Précision FR-CA."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert '100 %' in body and 'local' in body, "Missing 100% local pillar"
|
|
assert 'Conforme Loi 25' in body or 'Conforme Loi 25' in body, "Missing Conforme Loi 25 pillar"
|
|
assert 'Précision FR-CA' in body or 'Précision FR-CA' in body, "Missing Précision FR-CA pillar"
|
|
assert 'AGPL v3' in body, "Missing AGPL v3 transparency mention"
|
|
|
|
|
|
def test_pas_uses_wcag_safe_text_opacity():
|
|
"""PAS section text uses /70 opacity (WCAG AA compliant), not /40 or /50."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# Text on white surface in problem cards must use /70 minimum
|
|
# Check the problem card paragraph text uses navy/70 not navy/40 or /50
|
|
assert 'text-brand-navy/70 leading-relaxed' in body or 'text-brand-navy/70 mb-3' in body
|
|
# No regression to /40 in this section
|
|
# (Other sections may use /40 for decorative text — we just verify the new content uses /70)
|
|
|
|
|
|
def test_bento_section_present():
|
|
"""Bento features section is present after Solution section."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'FONCTIONNALIT' in body, "Missing Fonctionnalités eyebrow"
|
|
assert 'bento-title' in body, "Missing bento section anchor"
|
|
assert "rien que vous n'ayez besoin" in body, "Missing bento H2 differentiator"
|
|
|
|
|
|
def test_bento_has_6_features():
|
|
"""Bento grid renders 6 distinct feature cards."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
for feature in ['WhisperX', 'Diarisation', 'Mistral 7B', 'RAG local', 'DOCX, PDF, SRT', 'Outlook, Teams']:
|
|
assert feature in body, f"Missing bento feature: {feature}"
|
|
# Watermark numbers 01..06
|
|
for n in ['01', '02', '03', '04', '05', '06']:
|
|
assert f'>{n}<' in body, f"Missing bento watermark number {n}"
|
|
# Card 04 must use French Q&R, not English Q&A — primary identifier check
|
|
assert 'Q&R' in body or 'Q&R' in body, "Card 04 must use French Q&R, not Q&A"
|
|
|
|
|
|
def test_bento_uses_flexihub_styling():
|
|
"""Bento uses FlexiHub spec: max-w-[1060px], gap-[1.5px], bg-brand-navy2, /[0.04] watermark."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'max-w-[1060px]' in body, "Missing FlexiHub bento container width 1060px"
|
|
assert 'gap-[1.5px]' in body, "Missing FlexiHub ultrafin separator gap"
|
|
assert 'bg-brand-navy2' in body, "Missing dark card background"
|
|
assert 'text-white/[0.04]' in body, "Missing FlexiHub watermark opacity"
|
|
assert 'grad-bg rounded-none ' in body, "Missing gradient icon corner (V3 brutalist square)"
|
|
|
|
|
|
def test_bento_responsive_grid():
|
|
"""Bento grid responsive: 1 col mobile, 2 cols sm, 3 cols md+."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3' in body, \
|
|
"Missing responsive grid breakpoints (1/2/3 cols)"
|
|
|
|
|
|
def test_bento_uses_wcag_safe_text_on_dark():
|
|
"""Bento card descriptions use text-white/70 (WCAG AA on bg-brand-navy2)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'text-white/70' in body, "Missing WCAG-safe /70 text opacity on dark cards"
|
|
|
|
|
|
def test_bento_renders_nbsp_entities_not_escaped():
|
|
"""Card 01 '95 %+' NBSP must render as a non-breaking space, not as literal ' ' text.
|
|
|
|
Regression guard: if the bento macro stops piping description through `| safe`,
|
|
Jinja autoescape will double-escape ' ' to '&nbsp;' and users see the
|
|
raw entity. The HTML response must contain the literal '95 %+' once
|
|
(single escape), never '95&nbsp;%+'.
|
|
"""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert '95 %+' in body, "NBSP entity should appear single-escaped in card 01"
|
|
assert '95&nbsp;' not in body, "NBSP entity must not be double-escaped (missing | safe?)"
|
|
# Q&R card title: French ampersand must survive as & in HTML, not &amp;
|
|
assert 'Q&R' in body, "Q&R title should appear single-escaped"
|
|
assert 'Q&amp;R' not in body, "Q&R title must not be double-escaped"
|
|
|
|
|
|
def test_pricing_section_present():
|
|
"""Pricing section is present after bento section, with eyebrow + H2 + tax disclaimer."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'pricing-title' in body, "Missing pricing section anchor"
|
|
assert 'Choisissez votre formule' in body, "Missing pricing H2"
|
|
# Tax disclaimer must be visible (LPC art. 219 — total cost transparency)
|
|
assert 'TPS' in body and 'TVQ' in body, "Missing tax disclaimer (TPS/TVQ)"
|
|
|
|
|
|
def test_pricing_3_tiers_with_canonical_amounts():
|
|
"""Pricing has 3 tiers: DictIA 8 (3450/173), DictIA 16 (5750/201), DictIA Cloud (0/369)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# Names
|
|
for name in ['DictIA 8', 'DictIA 16', 'DictIA Cloud']:
|
|
assert name in body, f"Missing pricing tier: {name}"
|
|
# Canonical prices with NBSP per OQLF
|
|
assert '3 450 $' in body, "Missing DictIA 8 setup price"
|
|
assert '173 $' in body, "Missing DictIA 8 monthly price"
|
|
assert '5 750 $' in body, "Missing DictIA 16 setup price"
|
|
assert '201 $' in body, "Missing DictIA 16 monthly price"
|
|
assert '369 $' in body, "Missing DictIA Cloud monthly price (canonical 369$)"
|
|
|
|
|
|
def test_pricing_recommended_tier_is_dictia_16():
|
|
"""DictIA 16 is the visually-recommended tier (RECOMMANDÉ badge + grad-bg frame)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'RECOMMAND' in body, "Missing RECOMMANDÉ badge"
|
|
# The recommended tier wraps in grad-bg p-[1.5px] rounded FlexiHub style (V3 brutalist 4px card frame)
|
|
assert 'grad-bg p-[1.5px] rounded"' in body or 'grad-bg p-[1.5px] rounded ' in body, \
|
|
"Missing FlexiHub gradient frame on recommended tier (rounded 4px)"
|
|
|
|
|
|
def test_pricing_cta_uses_reserver_pre_launch_wording():
|
|
"""CTAs say 'Réserver' not 'Choisir' — pre-launch LPC art. 219 hygiene."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
for slug in ['dictia-8', 'dictia-16', 'dictia-cloud']:
|
|
assert f'href="/checkout/{slug}"' in body, f"Missing checkout link for {slug}"
|
|
assert 'Réserver DictIA 8' in body or 'Réserver DictIA 8' in body, "CTA must use 'Réserver' wording (pre-launch)"
|
|
|
|
|
|
def test_pricing_features_use_safe_filter_no_double_escape():
|
|
"""Pricing card features piped through | safe — ' ' must render single-escaped, not double."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# GPU sizes use NBSP
|
|
assert 'GPU 8 Go RTX' in body, "GPU 8 Go feature missing or NBSP double-escaped"
|
|
assert 'GPU 16 Go RTX' in body, "GPU 16 Go feature missing or NBSP double-escaped"
|
|
# Q&R card must use French Q&R, not English Q&A
|
|
assert 'Q&R' in body, "DictIA 16 must mention Q&R (French), not Q&A (English)"
|
|
assert 'Q&A' not in body, "Must use French Q&R consistently — no English Q&A"
|
|
# Loi 25 with NBSP
|
|
assert 'Conforme Loi 25' in body, "Conforme Loi 25 must use NBSP"
|
|
# SLA must be hedged ('visé') not absolute claim
|
|
assert 'SLA visé 99,9' in body, "SLA must be hedged 'visé' (pre-launch LPC art. 219 hygiene)"
|
|
# Negative: NO double-escape
|
|
assert '&nbsp;' not in body, "NBSP must not be double-escaped — | safe missing on pricing macro?"
|
|
|
|
|
|
def test_pricing_uses_wcag_safe_text_on_white():
|
|
"""Pricing card text uses text-brand-navy/70 or /80 minimum (WCAG AA on white)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# No regression to weak opacities like /40 or /50 in pricing area
|
|
assert 'text-brand-navy/70' in body
|
|
# The features list uses /80 in our impl
|
|
assert 'text-brand-navy/80' in body, "Feature text should use /80 for WCAG AA"
|
|
|
|
|
|
def test_roi_calculator_present_with_alpine_bindings():
|
|
"""ROI calculator section present with Alpine.js bindings + transparent hypotheses footnote."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'CALCULATEUR ROI' in body
|
|
assert 'roi-title' in body, "ROI calculator must have aria-labelledby anchor"
|
|
assert 'x-data="roiCalculator()"' in body
|
|
# Three sliders with x-model.number for type coercion
|
|
assert 'x-model.number="users"' in body
|
|
assert 'x-model.number="hours"' in body
|
|
assert 'x-model.number="rate"' in body
|
|
# Live output bindings
|
|
assert 'x-text="savings' in body
|
|
assert 'payback === null' in body, "Payback display must use null sentinel branch"
|
|
assert "moins d\\'un mois" in body or 'moins d'un mois' in body or "moins d'un mois" in body, \
|
|
"Payback display must offer 'moins d'un mois' branch"
|
|
# Transparent hypothesis footnote — LPC art. 219 hygiene
|
|
assert '80 %' in body and 'jours ouvrables' in body, "Missing transparent hypothesis footnote"
|
|
# Sliders accessible (aria-label on each input)
|
|
assert 'aria-label="Nombre d' in body
|
|
|
|
|
|
def test_roi_calculator_script_loaded():
|
|
"""roi_calculator.js loaded via {% block scripts %} (deferred after Alpine.js)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert '/static/js/roi_calculator.js' in body, "ROI script must be referenced"
|
|
# Must come AFTER alpine.min.js in the document order
|
|
alpine_pos = body.find('alpine.min.js')
|
|
roi_pos = body.find('roi_calculator.js')
|
|
assert alpine_pos != -1 and roi_pos != -1
|
|
assert alpine_pos < roi_pos, "Alpine.js must load before roi_calculator.js"
|
|
|
|
|
|
def test_roi_calculator_sliders_capped_defensibly():
|
|
"""Sliders capped: users<=25, hours<=4 (LPC art. 219 hygiene — no $35M screenshots)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# Users slider max must be 25, not 50
|
|
assert 'x-model.number="users"' in body
|
|
assert 'max="25"' in body, "Users slider must cap at 25 (was 50 — too aggressive for marketing claim)"
|
|
# Hours slider max must be 4, not 8
|
|
assert 'x-model.number="hours"' in body
|
|
assert 'max="4"' in body, "Hours slider must cap at 4 (was 8 — too aggressive)"
|
|
|
|
|
|
def test_roi_savings_paragraph_has_aria_live():
|
|
"""Savings <p> must announce updates to screen readers on slider change (aria-live polite)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# The savings paragraph (the headline number) must be a polite live region
|
|
assert 'aria-live="polite"' in body
|
|
assert 'aria-atomic="true"' in body
|
|
# Verify it's on the savings line, not somewhere unrelated
|
|
# (The savings p is the only element with text-5xl in the section)
|
|
assert 'text-5xl font-black grad-text' in body
|
|
|
|
|
|
def test_pricing_cta_url_no_double_slash():
|
|
"""pricing_card uses cta_url.rstrip('/') so href never has '//' (regression guard)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# All 3 CTAs use the default cta_url='/checkout' (no trailing slash) — so /checkout/dictia-X
|
|
for slug in ['dictia-8', 'dictia-16', 'dictia-cloud']:
|
|
assert f'href="/checkout/{slug}"' in body, f"Missing single-slash href for {slug}"
|
|
assert f'href="/checkout//{slug}"' not in body, f"Double-slash regression for {slug}"
|
|
|
|
|
|
def test_footer_has_4_columns_with_aria_labels():
|
|
"""Full footer has 4 columns (Brand, Produit, Légal, Compte) with proper landmarks."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# Footer landmark with accessible name
|
|
assert 'footer-heading' in body, "Footer must have an accessible heading"
|
|
assert 'aria-label="Produit"' in body
|
|
assert 'aria-label="L' in body and 'gal"' in body # Légal (handles entity-encoded é)
|
|
assert 'aria-label="Compte"' in body
|
|
# Address with tel + mailto
|
|
assert '<address' in body
|
|
assert 'href="tel:+15819968471"' in body
|
|
assert 'href="mailto:info@dictia.ca"' in body
|
|
|
|
|
|
def test_footer_links_complete():
|
|
"""Footer must link to all 4 product pages, 5 legal pages, and account flows."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# Product
|
|
for url in ['/fonctionnalites', '/tarifs', '/conformite', '/blog']:
|
|
assert f'href="{url}"' in body, f"Footer missing product link {url}"
|
|
# Legal
|
|
for url in ['/legal/conditions', '/legal/confidentialite', '/legal/cookies',
|
|
'/legal/remboursement', '/legal/accessibilite']:
|
|
assert f'href="{url}"' in body, f"Footer missing legal link {url}"
|
|
# Account
|
|
for url in ['/login', '/signup', '/contact']:
|
|
assert f'href="{url}"' in body, f"Footer missing account link {url}"
|
|
# External AGPL source link
|
|
assert 'gitea.innova-ai.ca/Innova-AI/dictia-public' in body
|
|
assert 'rel="noopener"' in body, "External links must use rel=noopener"
|
|
|
|
|
|
def test_footer_external_link_screen_reader_hint():
|
|
"""External link to Gitea has sr-only hint indicating new tab opens."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# The "↗" arrow should be aria-hidden + sr-only fallback
|
|
assert "s'ouvre dans un nouvel onglet" in body or "s'ouvre dans un nouvel onglet" in body, \
|
|
"External link must announce new tab opening to screen readers"
|
|
|
|
|
|
def test_footer_uses_wcag_safe_text_on_dark():
|
|
"""Footer text uses text-white/70 minimum (WCAG AA on bg-brand-navy2)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# No regression to weaker /40 or /50 in footer area
|
|
# Check the footer block specifically
|
|
footer_start = body.find('<footer')
|
|
footer_end = body.find('</footer>') + len('</footer>')
|
|
footer_html = body[footer_start:footer_end]
|
|
assert 'text-white/70' in footer_html, "Footer text must use /70 opacity for WCAG AA"
|
|
# Negative regression
|
|
assert 'text-white/40' not in footer_html, "Footer must not regress to /40 opacity"
|
|
assert 'text-white/50' not in footer_html, "Footer must not regress to /50 opacity"
|
|
|
|
|
|
def test_comparatif_section_present():
|
|
"""Comparatif section is present after Pricing with table + sourcing footnote."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'comparatif-title' in body
|
|
assert 'COMPARATIF' in body
|
|
assert 'DictIA face aux solutions cloud' in body
|
|
# Sourcing footnote (LPC art. 219 hygiene)
|
|
assert 'sources publiques' in body, "Must disclose sources for competitor claims"
|
|
assert '2026-04-27' in body, "Must date the comparison"
|
|
# Trademark disclaimer
|
|
assert 'marques déposées' in body or 'marques déposées' in body, \
|
|
"Trademark disclaimer required for competitor names"
|
|
|
|
|
|
def test_comparatif_table_has_4_competitors_and_6_criteria():
|
|
"""Comparatif table lists DictIA + 3 competitors over 6 criteria rows."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# Column headers
|
|
for col in ['DictIA', 'MS Teams Premium', 'Otter.ai Business', 'Whisper local']:
|
|
assert col in body, f"Comparatif missing column: {col}"
|
|
# 6 criteria (extract by their distinctive phrasing)
|
|
criteria_keywords = [
|
|
'Conforme Loi', # row 1
|
|
'Souveraineté hors Cloud Act', # row 2 (renamed)
|
|
'Large-v3 fine-tun', # row 3
|
|
'Diarisation jusqu', # row 4 (renamed)
|
|
'mensuel par utilisateur', # row 5 (renamed)
|
|
'Audit trail' # row 6
|
|
]
|
|
for kw in criteria_keywords:
|
|
assert kw in body, f"Comparatif missing criterion containing: {kw}"
|
|
|
|
|
|
def test_comparatif_uses_responsive_overflow_scroll():
|
|
"""Comparatif table wraps in overflow-x-auto for narrow viewports + has accessible caption."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'overflow-x-auto' in body
|
|
# Caption is sr-only but mandatory for table accessibility
|
|
assert '<caption class="sr-only">' in body
|
|
# Scope attributes on column and row headers
|
|
assert 'scope="col"' in body
|
|
assert 'scope="row"' in body
|
|
|
|
|
|
def test_conformite_section_present():
|
|
"""Conformité forteresse section is present with 4 pillar cards."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'conformite-title' in body
|
|
assert 'CONFORMIT' in body and 'FORTERESSE' in body
|
|
# Soft hedge: "conçue avec" (not "certifiée par")
|
|
assert 'conçue avec' in body or 'conçue avec' in body, \
|
|
"Must use soft hedge 'conçue avec' (LPC art. 219)"
|
|
|
|
|
|
def test_conformite_4_pillars():
|
|
"""Conformité has 4 pillars: hébergement, Loi 25, Cadre IA, AGPL."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
pillar_keywords = [
|
|
'OVH Beauharnois', # pillar 1
|
|
'LPRPSP', # pillar 2 (Loi 25 reference)
|
|
'LGGRI', # pillar 3 (Cadre IA reference)
|
|
'AGPL' # pillar 4
|
|
]
|
|
for kw in pillar_keywords:
|
|
assert kw in body, f"Conformité missing pillar reference: {kw}"
|
|
# Soft hedges (LPC art. 219)
|
|
assert 'Mapp' in body, "Must use 'Mappé' (not 'Certifié')"
|
|
# Citation/contact for verification
|
|
assert 'info@dictia.ca' in body
|
|
# SOC 2 claim must be hedged ('selon le périmètre') not absolute
|
|
assert 'selon le périmètre' in body or 'selon le périmètre' in body, \
|
|
"SOC 2 claim must be hedged — see code-review I-3"
|
|
# ISO 27001 reference is OK (verifiable from OVH compliance page)
|
|
assert 'ISO' in body and '27001' in body, "Reference to ISO 27001 expected"
|
|
|
|
|
|
def test_conformite_uses_wcag_safe_text_on_dark():
|
|
"""Conformité card text uses text-white/80 minimum on bg-brand-navy."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# Within the conformite section block specifically
|
|
section_start = body.find('id="conformite-title"')
|
|
# Find next </section>
|
|
section_end = body.find('</section>', section_start)
|
|
section_html = body[section_start:section_end]
|
|
assert 'text-white/80' in section_html or 'text-white/70' in section_html, \
|
|
"Conformité must use /70+ on dark for WCAG AA"
|
|
|
|
|
|
def test_no_unverifiable_competitor_claims():
|
|
"""Comparatif must NOT contain unhedged percentage claims about competitors (LPC art. 219)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# Forbidden patterns: bold quantitative claims like '5 stars', '100% accurate', 'X% precision'
|
|
# We allow our own '95%+' (already hedged with methodology footnote elsewhere)
|
|
forbidden_phrases = [
|
|
'Otter.ai a 100', # No claims about Otter accuracy
|
|
'Teams a 99', # No claims about Teams accuracy
|
|
'50% moins cher', # No comparative pricing without verification
|
|
]
|
|
for phrase in forbidden_phrases:
|
|
assert phrase not in body, f"Forbidden competitive claim: {phrase}"
|
|
|
|
|
|
def test_comparatif_check_marks_consistently_mean_good():
|
|
"""Each ✓ in the table should mark a 'good' outcome for that column.
|
|
Regression guard against the inverted-Cloud-Act-row bug.
|
|
Specifically: DictIA cell of every row must contain ✓ (DictIA wins on every criterion).
|
|
"""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# The 'Souveraineté hors Cloud Act' row must have ✓ for DictIA (after rename)
|
|
assert 'Souveraineté hors Cloud Act' in body
|
|
# And must NOT have the legacy inverted form
|
|
assert 'Exposé au Cloud Act' not in body, "Row 2 must be reworded to positive convention"
|
|
# Specifically check Teams gets ✗ for the territoriality criterion (was ⚠ before)
|
|
assert '✗ Soumis Cloud Act' in body, "Teams must show ✗ for non-Loi-25-compliant transfer"
|
|
|
|
|
|
def test_testimonials_section_present_with_placeholder_cards():
|
|
"""Témoignages section present with 3 placeholder cards (no fabricated quotes)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
assert 'testimonials-title' in body
|
|
assert 'Premiers cabinets pilotes' in body
|
|
# 3 personas
|
|
assert 'Cabinet juridique pilote' in body
|
|
assert 'Cabinet CPA pilote' in body
|
|
assert 'Municipalit' in body and 'pilote' in body
|
|
# No fabricated quotes — must say "Témoignage à venir"
|
|
assert 'Témoignage à venir' in body or 'Témoignage à venir' in body, \
|
|
"Pre-launch testimonials must show 'Témoignage à venir' (LPC art. 219)"
|
|
# Expected publication months
|
|
assert 'Mai 2026' in body
|
|
assert 'Juin 2026' in body
|
|
|
|
|
|
def test_testimonials_use_personas_not_fake_names():
|
|
"""Testimonials must NOT contain fabricated names (Me Tremblay, Mme Bouchard, etc.)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
forbidden_names = ['Me Tremblay', 'Mme Bouchard', 'M. Lefebvre',
|
|
'Cabinet Pilote A', 'Cabinet Pilote B', 'Municipalité Pilote C']
|
|
for name in forbidden_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."""
|
|
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):
|
|
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']
|
|
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."""
|
|
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"
|
|
# Each toggle button has @click and :aria-expanded
|
|
assert body.count('@click="open = !open"') == 7
|
|
assert body.count(':aria-expanded="open.toString()"') == 7
|
|
# 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"
|
|
# aria-controls links button to panel
|
|
assert 'aria-controls="faq-panel-1"' in body
|
|
|
|
|
|
def test_faq_jsonld_schema_present():
|
|
"""FAQ section embeds Schema.org FAQPage JSON-LD for SEO/GEO."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
# Inline JSON-LD script
|
|
assert '<script type="application/ld+json">' in body
|
|
assert '"@type": "FAQPage"' in body
|
|
assert '"@type": "Question"' in body
|
|
assert '"@type": "Answer"' in body
|
|
# NBSP must be normalized to plain space in JSON-LD (Google would otherwise parse ' ')
|
|
assert ' ' not in body[body.find('"FAQPage"'):body.find('</script>', body.find('"FAQPage"'))], \
|
|
"JSON-LD must not contain raw ' ' entities — strip them server-side"
|
|
|
|
# M-1 hardening: actually parse the JSON-LD to catch malformed JSON regressions
|
|
import json
|
|
import re
|
|
match = re.search(r'<script type="application/ld\+json">(.*?)</script>',
|
|
body, re.DOTALL)
|
|
assert match, "JSON-LD block not found"
|
|
parsed = json.loads(match.group(1))
|
|
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"
|
|
for q in parsed['mainEntity']:
|
|
assert q['@type'] == 'Question'
|
|
assert q['acceptedAnswer']['@type'] == 'Answer'
|
|
assert q['name'].strip(), "Question name must not be empty"
|
|
assert q['acceptedAnswer']['text'].strip(), "Answer text must not be empty"
|
|
|
|
|
|
def test_cta_final_section():
|
|
"""CTA final section with 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
|
|
# 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
|
|
assert 'border-white/[0.08]' in body # ghost button class
|
|
|
|
|
|
def test_cta_final_uses_safe_pre_launch_wording():
|
|
"""CTA must use 'Réservez votre pré-inscription' not 'Achetez maintenant' (pre-launch)."""
|
|
client = app.test_client()
|
|
body = client.get('/').data.decode('utf-8')
|
|
forbidden = ['Achetez maintenant', 'Acheter maintenant', 'Acheter immédiatement',
|
|
'Acheter dès aujourd', 'Disponible immédiatement']
|
|
for phrase in forbidden:
|
|
assert phrase not in body, f"Forbidden pre-launch CTA: {phrase}"
|
|
|
|
|
|
def test_routes_passes_testimonials_and_faq_to_template():
|
|
"""marketing.routes.landing() must pass testimonials and faq to render_template."""
|
|
# Import the module to verify the data lists exist
|
|
from src.marketing import routes
|
|
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"
|
|
# 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"
|
|
assert 'persona' in t and 'placeholder_label' in t and 'expected' in t
|
|
# Each FAQ entry must have 'q' and 'a'
|
|
for item in routes.FAQ:
|
|
assert 'q' in item and 'a' in item
|