"""
Las piezas compartidas del rediseño, defendidas por su marcado.

Dos cosas distintas se comprueban acá.

La primera es el **invariante de forma**: un estado se distingue con icono y
palabra, no con color (RT-04). La prueba es la captura en escala de grises, y su
traducción a una aserción es que dos estados de la misma familia nunca compartan
dibujo y que la palabra esté siempre en el marcado, sin depender de que alguien
abra un tooltip.

La segunda es una **red de contención**: los tres badges de estado pasaron a
rendir a través de `StatusBadge` y tienen diez consumidores que no se pueden
enterar del cambio. Lo que se fija acá es que cada uno de los veinte estados
siga diciendo la misma palabra, con el mismo dibujo y en la misma familia que
antes. Si mañana alguien simplifica el mapa de estados «porque son todos
iguales», esto es lo que lo frena.

Se ejercita el TypeScript real en Node. Todo se pide de una sola vez: Node tarda
más en arrancar que en contestar.
"""

import re

import pytest

from tests.frontend.harness import call, needs_frontend, phrase, render, resolve

ACCESS = '@/components/AccessStateBadge'
BATCH = '@/components/BatchStateBadge'
CODE_CHIP = '@/components/CodeChip'
COVERAGE = '@/components/CoverageStateBadge'
EMPTY_STATE = '@/components/EmptyState'
LABEL_VALUE = '@/components/LabelValue'
METRIC_CARD = '@/components/MetricCard'
PAGE_INTRO = '@/components/PageIntro'
SECTION = '@/components/Section'
SECTION_CARD = '@/components/SectionCard'

pytestmark = needs_frontend

#: Los diez estados de cobertura, con la fecha que los hace afirmables.
COVERAGE_STATES = [
    'UNKNOWN',
    'INDEXED',
    'CRAWLED_NOT_INDEXED',
    'DISCOVERED_NOT_INDEXED',
    'DUPLICATE_CANONICAL',
    'EXCLUDED_NOINDEX',
    'BLOCKED_ROBOTS',
    'FETCH_ERROR',
    'REDIRECT',
    'OTHER_NOT_INDEXED',
]

BATCH_STATES = ['QUEUED', 'RUNNING', 'COMPLETED', 'PARTIAL', 'FAILED']

ACCESS_STATES = [
    'AWAITING_ACCESS',
    'OPERATIONAL',
    'ACCESS_LOST',
    'ACCESS_REVOKED',
    'SUSPENDED',
]

FETCHED_AT = '2026-08-18T03:00:00+00:00'

REQUESTS = {
    **{
        f'coverage_{state}': render(
            COVERAGE, 'CoverageStateBadge', state=state, fetchedAt=FETCHED_AT
        )
        for state in COVERAGE_STATES
    },
    **{
        f'coverage_label_{state}': call(COVERAGE, 'coverageStateLabel', state)
        for state in COVERAGE_STATES
    },
    **{
        f'coverage_help_{state}': call(COVERAGE, 'coverageStateHelp', state)
        for state in COVERAGE_STATES
    },
    'coverage_without_date': render(
        COVERAGE, 'CoverageStateBadge', state='INDEXED', fetchedAt=None
    ),
    'coverage_unknown_without_date': render(
        COVERAGE, 'CoverageStateBadge', state='UNKNOWN', fetchedAt=None
    ),
    **{f'batch_{state}': render(BATCH, 'BatchStateBadge', state=state) for state in BATCH_STATES},
    **{f'batch_label_{state}': call(BATCH, 'batchStateLabel', state) for state in BATCH_STATES},
    **{f'batch_help_{state}': call(BATCH, 'batchStateHelp', state) for state in BATCH_STATES},
    'batch_unknown_state': render(BATCH, 'BatchStateBadge', state='SOMETHING_NEW'),
    'batch_with_reason': render(
        BATCH, 'BatchStateBadge', state='PARTIAL', reason='Se agotó el cupo del día'
    ),
    **{
        f'access_{state}': render(ACCESS, 'AccessStateBadge', state=state)
        for state in ACCESS_STATES
    },
    **{
        f'access_stale_{state}': render(ACCESS, 'AccessStateBadge', state=state, canOperate=False)
        for state in ACCESS_STATES
    },
    **{f'access_label_{state}': call(ACCESS, 'accessStateLabel', state) for state in ACCESS_STATES},
    **{f'access_help_{state}': call(ACCESS, 'accessStateHelp', state) for state in ACCESS_STATES},
    'access_help_without_credential': call(ACCESS, 'accessStateHelp', 'OPERATIONAL', False),
    'empty_state': render(
        EMPTY_STATE,
        'EmptyState',
        title='Todavía no hay lotes',
        description='Cuando corra el primer ciclo, acá vas a ver qué hizo.',
    ),
    'section': render(
        SECTION, 'Section', title={'text': 'Cobertura del sitio'}, children='contenido'
    ),
    'section_hidden_title': render(
        SECTION, 'Section', title={'text': 'Filtros', 'isHidden': True}, children='contenido'
    ),
    'section_nested': render(
        SECTION,
        'Section',
        title={'text': 'Cobertura del sitio', 'level': 'h3'},
        children='contenido',
    ),
    'section_destructive': render(
        SECTION,
        'Section',
        title={'text': 'Zona de peligro'},
        tone='destructive',
        description='Lo que se hace acá no se deshace.',
        children='contenido',
    ),
    # Las ranuras se piden con marcadores y no con contenido plausible: lo que se
    # comprueba es que la tarjeta ponga en su lugar lo que le den, sin mirarlo.
    'card_full': render(
        SECTION_CARD,
        'SectionCard',
        title={'text': 'Preferences'},
        description='Language and theme.',
        actions='ACTIONS',
        footer='FOOTER',
        children='CONTENT',
    ),
    'card_bare': render(
        SECTION_CARD, 'SectionCard', title={'text': 'Preferences'}, children='CONTENT'
    ),
    'card_only_title': render(SECTION_CARD, 'SectionCard', title={'text': 'Preferences'}),
    'card_nested': render(
        SECTION_CARD,
        'SectionCard',
        title={'text': 'Preferences', 'level': 'h3'},
        children='CONTENT',
    ),
    'card_only_actions': render(
        SECTION_CARD,
        'SectionCard',
        title={'text': 'Preferences'},
        actions='ACTIONS',
        children='CONTENT',
    ),
    'page_intro': render(PAGE_INTRO, 'PageIntro', items=['primero', 'segundo']),
    'page_intro_empty': render(PAGE_INTRO, 'PageIntro'),
    'metric_card': render(
        METRIC_CARD, 'MetricCard', label='URLs con dato', value='1.240', denominator='de 3.100'
    ),
    'metric_card_unknown': render(
        METRIC_CARD,
        'MetricCard',
        label='Sin consultar todavía',
        value='1.860',
        tone='unknown',
    ),
    'description_list': render(
        LABEL_VALUE,
        'DescriptionList',
        items=[
            {'term': 'Propiedad', 'value': 'sc-domain:ejemplo.test'},
            {'term': 'URLs', 'value': '3.100'},
        ],
    ),
    'description_list_aligned': render(
        LABEL_VALUE,
        'DescriptionList',
        items=[{'term': 'URLs', 'value': '3.100', 'align': 'end'}],
    ),
    'code_chip': render(CODE_CHIP, 'CodeChip', value='sc-domain:ejemplo.test'),
}


@pytest.fixture(scope='module')
def screen() -> dict:
    return resolve(REQUESTS)


def icons(markup: str) -> list[str]:
    """Los dibujos que hay en el marcado, por su nombre en el juego de iconos."""
    return re.findall(r'lucide lucide-([a-z0-9-]+)', markup)


def tone(markup: str) -> str | None:
    """La familia visual que le tocó al badge."""
    found = re.search(r'data-tone="([a-z]+)"', markup)
    return found.group(1) if found else None


def text(markup: str) -> str:
    """El marcado sin etiquetas: lo que queda es lo que se lee."""
    return re.sub(r'<[^>]+>', '', markup)


#: Un contenedor que se rindió sin nada adentro. Ocupa lugar y no muestra nada.
EMPTY_BOX = re.compile(r'<div[^>]*>\s*</div>')


def slots(markup: str) -> list[str]:
    """Las regiones de una tarjeta que llegaron a rendirse, en el orden en que salieron."""
    return re.findall(r'data-slot="(card[a-z-]*)"', markup)


class TestAStateIsSaidWithAnIconAndAWord:
    """
    RT-04, que es la regla que sobrevive a la captura en escala de grises.

    Un estado que se comunica sólo por color no se comunica: ni para quien no
    distingue el verde del rojo, ni en una captura pegada en un ticket, ni en un
    monitor mal calibrado.
    """

    @pytest.mark.parametrize('state', COVERAGE_STATES + BATCH_STATES + ACCESS_STATES)
    def test_every_state_draws_exactly_one_icon(self, screen, state):
        family = (
            'coverage'
            if state in COVERAGE_STATES
            else ('batch' if state in BATCH_STATES else 'access')
        )
        drawn = icons(screen[f'{family}_{state}'])

        assert len(drawn) == 1, (
            f'«{state}» dibujó {len(drawn)} iconos y tiene que dibujar exactamente uno.'
        )

    @pytest.mark.parametrize('state', COVERAGE_STATES + BATCH_STATES + ACCESS_STATES)
    def test_the_word_is_readable_without_opening_anything(self, screen, state):
        """
        La ayuda se mudó de `title=` a un `Tooltip`, y eso sólo es una mejora si
        la palabra sigue estando a la vista sin abrirlo. Un badge cuyo
        significado vive adentro del tooltip es RT-02 otra vez, con otra caja.
        """
        family = (
            'coverage'
            if state in COVERAGE_STATES
            else ('batch' if state in BATCH_STATES else 'access')
        )
        label = screen[f'{family}_label_{state}']

        assert label in text(screen[f'{family}_{state}']), (
            f'La palabra de «{state}» no está en el marcado: el badge queda siendo un color.'
        )

    @pytest.mark.parametrize('state', COVERAGE_STATES + BATCH_STATES + ACCESS_STATES)
    def test_the_icon_comes_before_the_word(self, screen, state):
        """
        Icono primero, palabra después, siempre en ese orden.

        Es lo que hace que una columna de badges se lea como una columna y no
        como una lista de cosas alineadas de casualidad.
        """
        family = (
            'coverage'
            if state in COVERAGE_STATES
            else ('batch' if state in BATCH_STATES else 'access')
        )
        markup = screen[f'{family}_{state}']
        label = screen[f'{family}_label_{state}']

        assert markup.index('<svg') < markup.index(label), (
            f'«{state}» puso la palabra antes del icono.'
        )

    @pytest.mark.parametrize(
        ('family', 'states'),
        [('coverage', COVERAGE_STATES), ('batch', BATCH_STATES), ('access', ACCESS_STATES)],
    )
    def test_no_two_states_of_a_family_share_a_drawing(self, screen, family, states):
        """
        El invariante entero de la escala de grises, en una línea.

        Dos estados con el mismo icono y distinto color son, sin color, el mismo
        estado. Es la razón por la que `OTHER_NOT_INDEXED` no reusa el icono de
        `FETCH_ERROR` y por la que `PARTIAL` no reusa la tilde de `COMPLETED`.
        """
        drawn = [icons(screen[f'{family}_{state}'])[0] for state in states]

        assert len(set(drawn)) == len(drawn), (
            f'Dos estados de «{family}» comparten dibujo: {drawn}. En escala de '
            'grises son el mismo estado.'
        )


class TestTheThreeBadgesKeepSayingWhatTheyUsedToSay:
    """
    Los diez consumidores no se pueden enterar de que el adentro cambió.

    Los tres badges pasaron a rendir a través de `StatusBadge`, pero el
    conocimiento del dominio —qué palabra, qué dibujo, qué familia— sigue siendo
    de ellos. Esto es lo que fija que siga siéndolo.
    """

    def test_unknown_is_not_a_negative_state(self, screen):
        """
        `UNKNOWN` significa que todavía no le preguntamos a Google.

        Agruparlo con las que sí están sin indexar —ámbar— o con las que
        fallaron —rojo— es afirmar algo que nadie comprobó. Tiene familia
        propia: borde punteado, sin relleno, y un reloj.
        """
        markup = screen['coverage_UNKNOWN']

        assert tone(markup) == 'unknown', (
            'Un estado sin consultar quedó en una familia que afirma algo sobre él.'
        )
        assert icons(markup) == ['clock']

    def test_partial_is_neither_completed_nor_failed(self, screen):
        """
        Un lote cortado por cuota dejó URLs sin consultar.

        Si en la lista se ve igual que uno terminado, la persona lee una foto
        parcial del sitio como si fuera el sitio entero. Y si se ve igual que uno
        que falló, se lee como roto algo que sigue mañana.
        """
        partial = screen['batch_PARTIAL']

        assert tone(partial) == 'attention'
        assert tone(screen['batch_COMPLETED']) == 'positive'
        assert tone(screen['batch_FAILED']) == 'critical'
        assert icons(partial) != icons(screen['batch_COMPLETED']), (
            'Un lote parcial y uno completado comparten dibujo.'
        )

    def test_indexed_is_the_only_positive_coverage_state(self, screen):
        """
        Nueve de los diez estados no son un sí de Google.

        El día que alguien agregue uno nuevo y lo pinte de verde «porque suena
        bien», esto lo frena.
        """
        positives = [
            state for state in COVERAGE_STATES if tone(screen[f'coverage_{state}']) == 'positive'
        ]

        assert positives == ['INDEXED'], f'Se presentan como positivos: {positives}.'

    def test_a_state_with_data_and_without_a_date_is_shown_as_a_defect(self, screen):
        """
        El par estado + fecha no se puede separar (RT-02).

        No sabemos de cuándo es el dato; disimularlo o inventarle una fecha sería
        justo la clase de afirmación que este producto no puede hacer.
        """
        markup = screen['coverage_without_date']

        assert phrase('coverage.missingFetchedAt') in text(markup)
        assert tone(markup) == 'critical'

    def test_a_state_never_consulted_does_not_need_a_date(self, screen):
        """La contracara: «sin consultar todavía» no tiene fecha que faltar."""
        markup = screen['coverage_unknown_without_date']

        assert phrase('coverage.missingFetchedAt') not in text(markup)
        assert tone(markup) == 'unknown'

    def test_without_a_credential_no_domain_is_presented_as_operational(self, screen):
        """
        RT-18: el estado del dominio describe su propiedad; el de la cuenta, si
        tenemos con qué consultarla.

        «Operativo» es una afirmación sobre ahora, y sin credencial no hay ningún
        ahora que podamos afirmar.
        """
        stale = screen['access_stale_OPERATIONAL']

        assert 'Operativo' not in text(stale), (
            'Sin credencial que sirva, un dominio se siguió presentando como operativo.'
        )
        assert tone(stale) == 'unknown'

    @pytest.mark.parametrize('state', ['ACCESS_LOST', 'ACCESS_REVOKED', 'SUSPENDED'])
    def test_a_state_we_cannot_confirm_keeps_its_word_and_lowers_its_tone(self, screen, state):
        """
        Los demás estados describen algo que pasó y siguen siendo ciertos.

        Conservan su palabra y su dibujo —reescribirlos diría que el estado es
        otro— y bajan el tono, porque un rojo de alarma sobre algo que hoy no
        podemos confirmar apura una acción que no va a cambiar nada.
        """
        stale = screen[f'access_stale_{state}']

        assert screen[f'access_label_{state}'] in text(stale)
        assert icons(stale) == icons(screen[f'access_{state}'])
        assert tone(stale) == 'unknown'

    @pytest.mark.parametrize('state', COVERAGE_STATES + BATCH_STATES + ACCESS_STATES)
    def test_every_state_still_has_its_help_text(self, screen, state):
        """
        La ayuda se mudó de contenedor, no se perdió: es lo que explica qué
        significa un estado que la persona ve por primera vez.
        """
        family = (
            'coverage'
            if state in COVERAGE_STATES
            else ('batch' if state in BATCH_STATES else 'access')
        )

        assert screen[f'{family}_help_{state}'].strip(), f'«{state}» se quedó sin ayuda.'

    def test_an_unrecognised_batch_state_is_still_drawn_as_a_state(self, screen):
        """
        Un estado que no está en el mapa igual se dibuja con icono y palabra.

        Un badge que sólo trae una sigla no se distingue de ningún otro, que es
        exactamente lo que RT-04 prohíbe.
        """
        markup = screen['batch_unknown_state']

        assert 'SOMETHING_NEW' in text(markup)
        assert len(icons(markup)) == 1

    def test_the_reason_of_a_batch_does_not_get_into_the_badge(self, screen):
        """
        El motivo reemplaza al texto de ayuda y no a la palabra.

        Un badge con una oración adentro rompe la fila de la tabla; donde el
        motivo tiene que verse es en `BatchStateHeadline`.
        """
        assert phrase('batch.PARTIAL.label') in text(screen['batch_with_reason'])
        assert phrase('batch.reason.partial.quota') not in text(screen['batch_with_reason'])


class TestTheTitlesAreRealHeadings:
    """
    El arreglo de raíz: había vistas con títulos visibles y perfectos que medían
    cero encabezados, porque el dibujo estaba bien y la semántica no existía.
    """

    def test_a_section_titles_itself_with_an_h2(self, screen):
        markup = screen['section']

        assert '<h2' in markup, 'Una `Section` no rindió ningún encabezado.'
        assert 'Cobertura del sitio' in text(markup)

    def test_a_section_points_its_region_at_its_own_title(self, screen):
        """
        `aria-labelledby` es lo que hace que la sección tenga nombre para quien
        navega por regiones. Un `<section>` sin nombre no aparece en esa lista.
        """
        markup = screen['section']
        labelled = re.search(r'aria-labelledby="([^"]+)"', markup)

        assert labelled, 'La sección no declaró de dónde sale su nombre.'
        assert f'id="{labelled.group(1)}"' in markup

    def test_a_nested_section_lowers_its_level_without_redrawing_it(self, screen):
        """
        El nivel dice dónde cae en el índice de encabezados, no cuánto pesa: un
        `h3` adentro de una sección se ve igual que el `h2` de la de al lado.
        Redibujarlo sería inventar una jerarquía visual que el contenido no tiene.
        """
        markup = screen['section_nested']

        assert '<h3' in markup and '<h2' not in markup
        assert 'font-heading' in markup, 'El encabezado bajó de nivel y perdió su dibujo.'

    def test_every_id_it_emits_says_where_it_came_from(self, screen):
        """
        Un id de `useId` es «_R_0_» y no dice de dónde salió, así que encontrar
        en el inspector la sección que produjo un nodo obligaba a recorrer el
        árbol hacia arriba. Con el prefijo, buscar `section-` los devuelve todos.
        """
        for name in ('section', 'section_hidden_title'):
            for found in re.findall(r'id="([^"]+)"', screen[name]):
                assert found.startswith('section-'), (
                    f'«{name}» emitió el id «{found}», que no dice de qué pieza es.'
                )

    def test_a_section_can_be_found_even_without_a_heading(self, screen):
        """
        Con el título escondido no hay `<h2>` que delate a la sección, así que el
        id va también en la región: si no, la única pieza que no se puede
        encontrar en el inspector sería justo la que no dibuja su nombre.
        """
        assert re.search(r'<section id="section-[^"]+-region"', screen['section_hidden_title'])

    def test_a_hidden_title_is_not_rendered_at_all(self, screen):
        """
        No se esconde con `sr-only`: no se rinde.

        Un encabezado invisible sigue siendo un hijo del flex y cobra su parte
        del `gap` aunque no dibuje nada, así que dejaba un hueco arriba del
        primer contenido visible que no separaba nada de nada.
        """
        markup = screen['section_hidden_title']

        assert '<h2' not in markup, 'El encabezado escondido volvió a rendirse.'
        assert 'sr-only' not in markup
        assert 'justify-between' not in markup, (
            'El encabezado invisible volvió a envolverse en una fila.'
        )

    def test_a_section_without_a_heading_keeps_its_name_in_an_attribute(self, screen):
        """
        La región no se queda sin nombre: el título pasa a `aria-label`.

        Se cede aparecer en el índice de encabezados y se conserva ser una región
        con nombre, que es lo que permite saltar de sección en sección. Es el
        precio de una región que a la vista no tiene título, y por eso
        `titleHidden` es la excepción y no el modo normal de usar la pieza.
        """
        markup = screen['section_hidden_title']

        assert 'aria-label="Filtros"' in markup
        assert 'aria-labelledby' not in markup, (
            'Quedaron los dos nombres declarados y `aria-labelledby` gana: '
            'el `aria-label` no lo lee nadie.'
        )

    def test_a_section_draws_no_box_of_its_own(self, screen):
        """
        Una `Section` no tiene borde ni fondo. El borde es de la `Card` y del
        contenedor de `DataTable`; dárselo también a la sección es lo que produce
        las cajas anidadas.
        """
        for name in ('section', 'section_destructive'):
            assert 'rounded-lg border' not in screen[name]
            assert 'rounded-xl border' not in screen[name]

    def test_an_empty_state_titles_itself_with_an_h3(self, screen):
        """
        Antes era un `<p>` en negrita: se veía como un título y no existía como
        tal. Es un bloque con nombre adentro de una sección, así que le
        corresponde un T4 de verdad.
        """
        markup = screen['empty_state']

        assert '<h3' in markup
        assert 'Todavía no hay lotes' in text(markup)

    def test_a_metric_card_makes_its_label_the_heading_and_not_the_figure(self, screen):
        """
        La excepción declarada, y la única del contrato.

        T2 nunca es una etiqueta de encabezado: una cifra no nombra nada, es el
        valor de algo que ya tiene nombre. Por eso el `h3` va en la etiqueta y el
        número se queda en un `<div>`.
        """
        markup = screen['metric_card']
        heading = re.search(r'<h3[^>]*>(.*?)</h3>', markup, re.DOTALL)

        assert heading, 'La tarjeta de métrica no rindió ningún encabezado.'
        assert 'URLs con dato' in text(heading.group(1))
        assert '1.240' not in text(heading.group(1)), (
            'La cifra quedó como encabezado de la tarjeta.'
        )


class TestTheStandardCard:
    """
    `SectionCard`, que es la contracara de `Section`: una agrupa sin caja y la
    otra dibuja la caja.

    Nace porque la convención sola no se sostuvo. Con la `Card` cruda había
    veintidós usos en nueve archivos, y de veintidós títulos catorce eran un
    `<div>` en vez de un encabezado; la descripción repetía las mismas dos clases
    en cuatro archivos y el contenido se espaciaba de seis maneras distintas.
    Esto es lo que hace que no vuelva a pasar.
    """

    def test_the_card_titles_itself_with_a_real_heading(self, screen):
        """La deuda que la pieza salda de una vez: catorce títulos que eran `<div>`."""
        markup = screen['card_full']

        assert '<h2' in markup, 'La tarjeta no rindió ningún encabezado.'
        assert 'Preferences' in text(markup)

    def test_a_nested_card_lowers_its_level_instead_of_redrawing_it(self, screen):
        """
        El nivel depende de dónde se monta la tarjeta, así que lo declara quien la
        monta. Lo que no cambia es el dibujo: un T4 no se escribe a mano.
        """
        markup = screen['card_nested']

        assert '<h3' in markup and '<h2' not in markup
        assert 'font-heading' in markup, 'El encabezado bajó de nivel y perdió su dibujo.'

    def test_the_card_points_its_group_at_its_own_title(self, screen):
        """
        Sin nombre accesible, una tarjeta es una caja anónima que no se puede
        anunciar ni saltear. Es `group` y no `region` a propósito: cinco tarjetas
        en una pantalla producirían cinco puntos de referencia, que es cómo la
        lista de puntos de referencia deja de servir.
        """
        markup = screen['card_full']
        labelled = re.search(r'aria-labelledby="([^"]+)"', markup)

        assert labelled, 'La tarjeta no declaró de dónde sale su nombre.'
        assert f'id="{labelled.group(1)}"' in markup
        assert 'role="group"' in markup

    def test_the_heading_and_the_actions_share_one_row(self, screen):
        """El encabezado es una fila: el texto de un lado, las acciones del otro."""
        markup = screen['card_full']

        assert 'flex-row' in markup and 'gap-3' in markup
        assert markup.index('Preferences') < markup.index('ACTIONS'), (
            'Las acciones quedaron antes del título.'
        )

    def test_every_slot_gets_what_it_was_given_without_reading_it(self, screen):
        """
        Las ranuras son ciegas. La tarjeta reserva el lugar y no pregunta qué va
        adentro: que sean dos botones, un menú o una fecha lo decide quien la usa.
        Es la diferencia entre una pieza reusable y una que hay que ampliar cada
        vez que aparece un control nuevo.
        """
        rendered = text(screen['card_full'])

        for slot in ('ACTIONS', 'CONTENT', 'FOOTER'):
            assert slot in rendered, f'La ranura «{slot}» no llegó al marcado.'

    def test_the_slots_keep_their_places(self, screen):
        """Encabezado, contenido y pie, en ese orden y sin nada en el medio."""
        assert slots(screen['card_full']) == [
            'card',
            'card-header',
            'card-title',
            'card-description',
            'card-content',
            'card-footer',
        ]

    @pytest.mark.parametrize(
        'name', ['card_full', 'card_bare', 'card_only_title', 'card_only_actions']
    )
    def test_every_card_shows_its_title(self, screen, name):
        """
        **Toda tarjeta lleva título y el título se ve.** Es la regla, no una
        forma habitual.

        `Section` tiene `titleHidden` porque agrupa cosas que a veces no
        necesitan encabezado a la vista —una región de filtros—; una tarjeta no,
        porque una tarjeta ya es una caja con borde propio, y una caja con borde
        y sin nombre obliga a deducir qué contiene mirando lo que hay adentro.
        Por eso `title` es obligatorio y no hay manera de esconderlo.
        """
        markup = screen[name]

        assert 'card-header' in slots(markup), f'«{name}» rindió una tarjeta sin encabezado.'
        assert 'sr-only' not in markup, f'«{name}» escondió el título de una tarjeta.'
        assert 'Preferences' in text(markup)

    @pytest.mark.parametrize(
        'name', ['card_full', 'card_bare', 'card_only_title', 'card_only_actions']
    )
    def test_no_variant_renders_an_empty_box(self, screen, name):
        """
        Una ranura sin contenido no se rinde vacía: no se rinde.

        No es prolijidad. La `Card` reacciona a lo que tiene adentro
        —`has-data-[slot=card-footer]` le saca el relleno de abajo cuando hay
        pie—, así que un pie vacío rendido por las dudas le cambia el relleno a la
        tarjeta entera y le deja una franja con borde y fondo sin nada escrito. Lo
        mismo, en chico, con el contenedor de acciones: no dibuja nada y cobra su
        `gap` igual.
        """
        assert not EMPTY_BOX.search(screen[name]), (
            f'«{name}» rindió un contenedor vacío que ocupa lugar sin mostrar nada.'
        )

    def test_a_card_with_only_a_title_renders_only_a_title(self, screen):
        """La contracara del test anterior, dicha por las regiones que faltan."""
        assert slots(screen['card_only_title']) == ['card', 'card-header', 'card-title']

    def test_a_card_without_a_footer_or_a_description_renders_neither(self, screen):
        assert slots(screen['card_bare']) == ['card', 'card-header', 'card-title', 'card-content']

    def test_every_id_it_emits_says_where_it_came_from(self, screen):
        """
        Lo mismo que en `Section`, y con el prefijo propio: un id de `useId` es
        «_R_0_» y no dice de qué pieza salió. Con el prefijo, buscar
        `section-card-` en el inspector devuelve todas las tarjetas.
        """
        for found in re.findall(r'id="([^"]+)"', screen['card_full']):
            assert found.startswith('section-card-'), (
                f'La tarjeta emitió el id «{found}», que no dice de qué pieza es.'
            )

    def test_the_card_is_the_one_that_draws_the_box(self, screen):
        """
        La otra mitad de la regla que `Section` sostiene desde el otro lado: el
        borde es de la tarjeta. Por eso mismo no se anidan.
        """
        assert 'data-slot="card"' in screen['card_bare']
        assert 'rounded-xl' in screen['card_bare']


class TestTheFiguresAreDrawnAsFigures:
    def test_the_value_of_a_metric_keeps_its_size_inside_a_small_card(self, screen):
        """
        Una tarjeta `size="sm"` baja el título a 14 px por su cuenta, y esa regla
        gana por orden de generación: sin forzarla, T2 se rendía como T4 y la
        cifra que contesta la pregunta de la vista quedaba del tamaño de una
        etiqueta.
        """
        markup = screen['metric_card']

        assert 'text-2xl!' in markup, 'La cifra de la métrica no quedó en T2.'
        assert 'tabular-nums' in markup

    def test_a_metric_without_data_is_a_variant_and_not_another_card(self, screen):
        """
        «Sin consultar todavía» dibujado aparte es lo que hace que un dato que no
        tenemos se lea como un dato malo (RT-03).
        """
        markup = screen['metric_card_unknown']

        assert 'outline-dashed' in markup
        assert 'clock' in icons(markup)

    def test_a_group_of_pairs_is_a_real_description_list(self, screen):
        """
        Que sea una `<dl>` y no una pila de `<div>` es lo que hace que un lector
        de pantalla anuncie los pares y pueda saltar de término en término.
        """
        markup = screen['description_list']

        assert '<dl' in markup and '<dt' in markup and '<dd' in markup

    def test_a_pair_is_not_pushed_to_the_right_for_holding_a_number(self, screen):
        """
        Antes existía `numeric`, que decía **qué era el dato** y de ahí deducía
        una alineación. Quien la marcaba pensaba «esto es un número», no «quiero
        esto contra el margen derecho», y en una lista de dos columnas terminaba
        con el término a la izquierda, el valor a la derecha y un hueco en el
        medio que hay que cruzar con la vista para aparearlos.

        Una cifra, por ser cifra, no se alinea a ningún lado.
        """
        assert 'text-right' not in screen['description_list'], (
            'Un par volvió a alinearse solo por tener un número adentro.'
        )

    def test_a_screen_that_does_want_it_can_still_ask(self, screen):
        """
        La alineación sigue disponible: lo que cambió es que se pide diciendo
        qué se quiere, no describiendo el dato.
        """
        assert 'text-right' in screen['description_list_aligned']


class TestTheSmallPiecesDoNotLie:
    def test_a_literal_value_is_never_translated(self, screen):
        """
        Un traductor automático que le cambie una letra a `sc-domain:ejemplo.test`
        deja a la persona pegando un identificador que no existe, sin ninguna
        señal de que pasó algo.
        """
        markup = screen['code_chip']

        assert 'translate="no"' in markup
        assert 'sc-domain:ejemplo.test' in text(markup)

    def test_the_separators_of_the_intro_are_not_read_aloud(self, screen):
        """
        El punto separa a la vista. Un lector de pantalla ya recibe cada metadato
        como un elemento aparte, y leerle «punto» entre cada uno es ruido.
        """
        markup = screen['page_intro']

        assert 'aria-hidden' in markup
        assert re.search(r'aria-hidden="true"[^>]*>·', markup), (
            'El separador visual se anuncia como si fuera contenido.'
        )

    def test_an_intro_with_nothing_to_say_occupies_nothing(self, screen):
        """Una línea de estado vacía es un hueco que descoloca al resto."""
        assert screen['page_intro_empty'] == ''
