Files
dictia-public/tests/test_marketing_landing_template.py
Allison 0d69fcd034 feat(marketing): A-2.7a footer 4-col + comparatif table + conformité 4 pillars
- Replace placeholder _footer.html with full 4-column footer (Brand, Produit,
  Légal, Compte) — added aria-labelledby landmark, sr-only headings, address
  block, tel: link, external-link new-tab announcements, NBSP per OQLF
- Add Comparatif section: DictIA vs MS Teams vs Otter.ai vs Whisper local
  on 6 criteria. Sourced (politiques publiques + grilles officielles 2026-04-27),
  trademark disclaimer, responsive table with sr-only caption + scope attrs
- Add Conformité forteresse section: 4 pillars (OVH Beauharnois, mapped Loi 25
  LPRPSP refs, compatible Cadre IA LGGRI, AGPL v3 verifiable Gitea link)
- Soft hedges throughout: 'mappé', 'conçu avec', 'compatible' (no 'certifié',
  'endossé', 'reconnu' — LPC art. 219 / Competition Act s. 52 hygiene)
- 11 new tests covering footer landmarks, comparatif sourcing, conformité
  hedges, WCAG /70 contrast, and forbidden competitive claim regressions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:21:39 -04:00

604 lines
28 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&eacute;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&eacute;server une d&eacute;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&eacute;-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&Eacute;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 &nbsp; entity
assert '95&nbsp;%+' in body, "Missing NBSP-separated 95%+ KPI"
assert '0&nbsp;$' in body, "Missing NBSP-separated 0$ KPI"
assert '100&nbsp;%' 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&eacute;thodologie disponible sur demande' in body or 'méthodologie disponible sur demande' in body
assert 'audio professionnel qu&eacute;b&eacute;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, "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&nbsp;%' in body and 'local' in body, "Missing 100% local pillar"
assert 'Conforme Loi&nbsp;25' in body or 'Conforme Loi&#160;25' in body, "Missing Conforme Loi 25 pillar"
assert 'Précision FR-CA' in body or 'Pr&eacute;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&amp;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-[0.5rem]' in body, "Missing gradient icon corner"
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 '&nbsp;' text.
Regression guard: if the bento macro stops piping description through `| safe`,
Jinja autoescape will double-escape '&nbsp;' to '&amp;nbsp;' and users see the
raw entity. The HTML response must contain the literal '95&nbsp;%+' once
(single escape), never '95&amp;nbsp;%+'.
"""
client = app.test_client()
body = client.get('/').data.decode('utf-8')
assert '95&nbsp;%+' in body, "NBSP entity should appear single-escaped in card 01"
assert '95&amp;nbsp;' not in body, "NBSP entity must not be double-escaped (missing | safe?)"
# Q&R card title: French ampersand must survive as &amp; in HTML, not &amp;amp;
assert 'Q&amp;R' in body, "Q&R title should appear single-escaped"
assert 'Q&amp;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&nbsp;450&nbsp;$' in body, "Missing DictIA 8 setup price"
assert '173&nbsp;$' in body, "Missing DictIA 8 monthly price"
assert '5&nbsp;750&nbsp;$' in body, "Missing DictIA 16 setup price"
assert '201&nbsp;$' in body, "Missing DictIA 16 monthly price"
assert '369&nbsp;$' 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-[20px] FlexiHub style
assert 'grad-bg p-[1.5px] rounded-[20px]' in body, "Missing FlexiHub gradient frame on recommended tier"
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&eacute;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 — '&nbsp;' must render single-escaped, not double."""
client = app.test_client()
body = client.get('/').data.decode('utf-8')
# GPU sizes use NBSP
assert 'GPU 8&nbsp;Go RTX' in body, "GPU 8 Go feature missing or NBSP double-escaped"
assert 'GPU 16&nbsp;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&amp;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&nbsp;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 '&amp;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&#39;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&nbsp;%' 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&#39;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&eacute;pos&eacute;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
'Cloud Act', # row 2
'Large-v3 fine-tun', # row 3 (escaped or raw)
'Diarisation', # row 4
'utilisateur/mois', # row 5
'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&ccedil;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
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}"