"""Pruebas del laboratorio. LAB_DIR dice dónde está el código: lab o solution.

Cada prueba compila un main propio con los archivos de esa carpeta, y lo corre con
sanitizers: una fuga o un acceso inválido hace fallar la prueba, aunque el resultado
parezca correcto.
"""
import os
import pathlib
import re
import resource
import subprocess
import tempfile
import unittest

HERE = pathlib.Path(__file__).parent
LAB_DIR = os.environ.get("LAB_DIR", "solution")
SOURCE = HERE / LAB_DIR
HEADERS = HERE / "lab"
FLAGS = ["-Wall", "-Wextra", "-std=c17", "-g", "-fsanitize=address,undefined"]
VALGRIND_FLAGS = ["-Wall", "-Wextra", "-std=c17", "-g", "-O0"]
# Con este límite, un archivo sin cerrar se nota después de pocas lecturas. Los sanitizers
# no lo reportan como fuga, porque la biblioteca estándar conserva una lista de archivos abiertos.
OPEN_FILES_LIMIT = 32

# Hace fallar la llamada número fail_at a malloc, calloc o realloc. Solo para estas pruebas.
FAILING_ALLOCATOR = """
#include <stdlib.h>
void *__real_malloc(size_t size);
void *__real_calloc(size_t count, size_t size);
void *__real_realloc(void *block, size_t size);
static int calls = 0;
static int fail_at = 0;
static int next_fails(void) { calls++; return calls == fail_at; }
void *__wrap_malloc(size_t size) { return next_fails() ? NULL : __real_malloc(size); }
void *__wrap_calloc(size_t count, size_t size) {
    return next_fails() ? NULL : __real_calloc(count, size);
}
void *__wrap_realloc(void *block, size_t size) {
    return next_fails() ? NULL : __real_realloc(block, size);
}
"""
WRAP = ["-Wl,--wrap=malloc", "-Wl,--wrap=calloc", "-Wl,--wrap=realloc"]


def short_report(text):
    """Deja lo útil de un informe de los sanitizers: el tipo de error y las líneas propias."""
    if "ERROR:" not in text and "runtime error" not in text:
        return text.strip()
    useful = [line for line in text.splitlines()
              if "ERROR:" in line or "SUMMARY:" in line or "runtime error" in line
              or re.search(r"\b(driver|image|filters|str_tools|int_array)\.c:\d+", line)]
    return "\n".join(line.strip() for line in useful[:12])


def limit_open_files():
    resource.setrlimit(resource.RLIMIT_NOFILE, (OPEN_FILES_LIMIT, OPEN_FILES_LIMIT))


def compile_and_run(main_source, sources, files=None, extra=(), stdin=None, setup=None):
    with tempfile.TemporaryDirectory() as name:
        folder = pathlib.Path(name)
        driver = folder / "driver.c"
        driver.write_text(main_source, encoding="utf-8")
        for file_name, content in (files or {}).items():
            (folder / file_name).write_text(content, encoding="utf-8")
        binary = folder / "driver"
        built = subprocess.run(
            ["gcc", *FLAGS, f"-I{HEADERS}", str(driver), *map(str, sources), *extra, "-o", str(binary)],
            text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
        )
        if built.returncode != 0:
            raise AssertionError(f"No compila:\n{built.stdout}")
        done = subprocess.run(
            [str(binary)], cwd=folder, text=True, encoding="utf-8", input=stdin, preexec_fn=setup,
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60,
        )
        return done.stdout, done.returncode


class LabTest(unittest.TestCase):
    maxDiff = None

    def run_clean(self, source, sources, files=None, extra=(), context="", setup=None):
        """Corre el programa y exige que termine bien, sin informes de los sanitizers."""
        out, code = compile_and_run(source, sources, files, extra, setup=setup)
        if code != 0:
            self.fail(f"{context}\nEl programa falló:\n{short_report(out)}")
        return out

    def same(self, got, wanted, context):
        if got != wanted:
            self.fail(f"{context}\n  esperado: {wanted}\n  obtuvo:   {got}")


def image_sources():
    return [SOURCE / "image.c", HERE / "lab" / "filters.c"]


def image_main(body):
    return ('#include <stdio.h>\n#include "filters.h"\n'
            "static void dump(const struct image *img) {\n"
            '    if (img == NULL) { printf("NULL\\n"); return; }\n'
            '    printf("%d %d %d", img->width, img->height, img->max_value);\n'
            "    for (int i = 0; i < img->width * img->height; i++) {\n"
            '        printf(" %d", img->pixels[i]);\n    }\n    printf("\\n");\n}\n'
            "int main(void) {\n" + body + "\nreturn 0;\n}\n")


def pgm(width, height, max_value, values):
    numbers = " ".join(map(str, values))
    return f"P2\n{width} {height}\n{max_value}\n{numbers}\n"


@unittest.skipUnless((SOURCE / "image.c").exists(), f"no está {LAB_DIR}/image.c")
class Images(LabTest):
    def load(self, content, context):
        """Carga content con image_load. Devuelve lo que escribe dump, sin espacios al final."""
        body = 'struct image *img = image_load("in.pgm"); dump(img); image_free(img);'
        return self.run_clean(image_main(body), image_sources(), {"in.pgm": content},
                              context=context).strip()

    def test_new_fills_every_pixel(self):
        body = "struct image *img = image_new(3, 2, 200, 7); dump(img); image_free(img);"
        out = self.run_clean(image_main(body), image_sources(),
                             context="image_new(3, 2, 200, 7)")
        self.same(out.strip(), "3 2 200 7 7 7 7 7 7", "image_new(3, 2, 200, 7)")

    def test_new_rejects_bad_data(self):
        cases = ["0, 2, 255, 0", "3, 0, 255, 0", "-3, 2, 255, 0", "3, -2, 255, 0", "3, 2, 0, 0",
                 "3, 2, 256, 0", "3, 2, 255, 256", "3, 2, 255, -1"]
        for case in cases:
            with self.subTest(args=case):
                body = f"dump(image_new({case}));"
                out = self.run_clean(image_main(body), image_sources(),
                                     context=f"image_new({case})")
                self.same(out.strip(), "NULL", f"image_new({case}) debe devolver NULL")

    def test_new_rejects_a_size_that_does_not_fit(self):
        # width * height no cabe en un int. Hay que notarlo antes de multiplicar.
        body = "dump(image_new(50000, 50000, 255, 0));"
        out = self.run_clean(image_main(body), image_sources(),
                             context="image_new(50000, 50000, 255, 0)")
        self.same(out.strip(), "NULL", "image_new(50000, 50000, 255, 0) debe devolver NULL")

    def test_free_accepts_null(self):
        self.run_clean(image_main("image_free(NULL);"), image_sources(), context="image_free(NULL)")

    def test_load_small_image_with_comment(self):
        content = "P2\n# un comentario\n3 2\n255\n0 10 20\n30 40 50\n"
        self.same(self.load(content, "imagen de 3 x 2"), "3 2 255 0 10 20 30 40 50",
                  "imagen de 3 x 2 con un comentario")

    def test_load_keeps_the_rows_in_order(self):
        # No es cuadrada: descubre si se confundieron width y height.
        content = pgm(2, 3, 9, [1, 2, 3, 4, 5, 6])
        self.same(self.load(content, "imagen de 2 x 3"), "2 3 9 1 2 3 4 5 6", "imagen de 2 x 3")

    def test_load_larger_than_the_old_limit(self):
        width, height = 300, 200
        values = [(x * 7 + y * 11) % 256 for y in range(height) for x in range(width)]
        expected = " ".join(map(str, [width, height, 255, *values]))
        got = self.load(pgm(width, height, 255, values), "imagen de 300 x 200")
        if got != expected:
            self.fail("La imagen de 300 x 200 no se cargó bien: sus píxeles no coinciden.")

    def test_load_the_range_image(self):
        body = ('struct image *img = image_load("' + str(HERE / "lab/images/range.pgm") + '");\n'
                'if (img != NULL) { printf("%d %d\\n", img->width, img->height); }\n'
                "image_free(img);")
        out = self.run_clean(image_main(body), image_sources(), context="lab/images/range.pgm")
        self.same(out.strip(), "96 28", "ancho y alto de lab/images/range.pgm")

    def test_load_failures_release_everything(self):
        cases = {
            "no es P2": "P5\n2 1\n255\n0 0\n",
            "archivo vacío": "",
            "sin alto": "P2\n2\n",
            "ancho 0": pgm(0, 1, 255, []),
            "max_value mayor que 255": pgm(1, 1, 256, [0]),
            "faltan píxeles": "P2\n3 2\n255\n1 2 3 4\n",
            "píxel mayor que max_value": pgm(2, 1, 9, [1, 10]),
            "una letra entre los píxeles": "P2\n2 1\n255\n1 x\n",
            "el tamaño no cabe en un int": "P2\n50000 50000\n255\n1\n",
            "un número que no cabe en un int": "P2\n99999999999 1\n255\n1\n",
        }
        for label, content in cases.items():
            with self.subTest(label):
                self.same(self.load(content, label), "NULL", f"{label}: debe devolver NULL")

    def test_load_missing_file(self):
        body = 'dump(image_load("no_existe.pgm"));'
        out = self.run_clean(image_main(body), image_sources(), context="archivo que no existe")
        self.same(out.strip(), "NULL", "un archivo que no existe debe dar NULL")

    def test_load_closes_the_file(self):
        body = ("for (int i = 0; i < 100; i++) {\n"
                '    struct image *good = image_load("good.pgm");\n'
                '    if (good == NULL) { printf("falló la lectura %d\\n", i); return 1; }\n'
                "    image_free(good);\n"
                '    image_free(image_load("bad.pgm"));\n'
                "}")
        self.run_clean(image_main(body), image_sources(),
                       {"good.pgm": pgm(1, 1, 9, [1]), "bad.pgm": "P2\n2 1\n9\n1\n"},
                       context="100 lecturas seguidas: ¿se cierra el archivo en todos los caminos?",
                       setup=limit_open_files)

    def test_save_and_load_again(self):
        body = ("const int values[] = {1, 2, 3, 4, 5, 6};\n"
                "struct image *img = image_from_values(3, 2, 9, values);\n"
                'if (img == NULL || !image_save(img, "out.pgm")) { return 1; }\n'
                'struct image *again = image_load("out.pgm");\n'
                "dump(again);\n"
                "image_free(again);\nimage_free(img);")
        out = self.run_clean(image_main(body), image_sources(), context="guardar y volver a cargar")
        self.same(out.strip(), "3 2 9 1 2 3 4 5 6", "guardar y volver a cargar")

    def test_filters_work_on_any_size(self):
        body = ("struct image *img = image_new(70, 3, 255, 200);\n"
                "if (img == NULL) { return 1; }\n"
                "img->pixels[0] = 10;\n"
                "flip_horizontal(img);\ninvert(img);\n"
                'printf("%d %d\\n", img->pixels[69], img->pixels[0]);\n'
                "image_free(img);")
        out = self.run_clean(image_main(body), image_sources(), context="filtros en 70 x 3")
        self.same(out.strip(), "245 55", "flip_horizontal e invert en una imagen de 70 x 3")

    def out_of_memory(self, call, context):
        source = FAILING_ALLOCATOR + image_main(
            "int nulls = 0;\n"
            "for (fail_at = 1; ; fail_at++) {\n"
            "    calls = 0;\n"
            f"    struct image *img = {call};\n"
            "    if (img == NULL) { nulls++; }\n"
            "    image_free(img);\n"
            "    if (calls < fail_at) { break; }\n"
            "}\n"
            'printf("%d\\n", nulls);')
        out = self.run_clean(source, image_sources(), {"in.pgm": pgm(2, 1, 9, [1, 2])}, WRAP,
                             context=f"{context}, cuando malloc devuelve NULL")
        numbers = re.findall(r"^\d+$", out, re.M)
        if not numbers or int(numbers[-1]) < 1:
            self.fail(f"{context}: debe devolver NULL cuando malloc devuelve NULL.")

    def test_new_when_there_is_no_memory(self):
        self.out_of_memory("image_new(2, 1, 9, 0)", "image_new")

    def test_load_when_there_is_no_memory(self):
        self.out_of_memory('image_load("in.pgm")', "image_load")


def str_main(body):
    return ('#include <stdio.h>\n#include <stdlib.h>\n#include <string.h>\n#include "str_tools.h"\n'
            "int main(void) {\n" + body + "\nreturn 0;\n}\n")


@unittest.skipUnless((SOURCE / "str_tools.c").exists(), f"no está {LAB_DIR}/str_tools.c")
class Strings(LabTest):
    def result_of(self, call):
        body = (f"char *result = {call};\n"
                'if (result == NULL) { printf("NULL\\n"); return 0; }\n'
                'printf("[%s]\\n", result);\nfree(result);')
        return self.run_clean(str_main(body), [SOURCE / "str_tools.c"], context=call).strip()

    def test_duplicate(self):
        for text in ("Lima", "", "a", "con ñ y tilde á"):
            with self.subTest(text=text):
                call = f'str_duplicate("{text}")'
                self.same(self.result_of(call), f"[{text}]", call)

    def test_duplicate_is_a_new_block(self):
        body = ('char original[] = "Lima";\nchar *copy = str_duplicate(original);\n'
                "if (copy == NULL) { return 1; }\n"
                "if (copy == original) { return 2; }\n"
                "copy[0] = 'K';\n"
                'printf("%s %s\\n", original, copy);\nfree(copy);')
        out = self.run_clean(str_main(body), [SOURCE / "str_tools.c"],
                             context="str_duplicate debe devolver otro bloque")
        self.same(out.strip(), "Lima Kima", "cambiar la copia no debe cambiar el original")

    def test_concat(self):
        cases = [("Puno", " y Tacna"), ("", "Ica"), ("Ica", ""), ("", "")]
        for first, second in cases:
            with self.subTest(first=first, second=second):
                call = f'str_concat("{first}", "{second}")'
                self.same(self.result_of(call), f"[{first}{second}]", call)

    def test_concat_with_itself_and_keeps_the_arguments(self):
        body = ('char word[] = "ab";\nchar *joined = str_concat(word, word);\n'
                "if (joined == NULL) { return 1; }\n"
                'printf("%s %s\\n", word, joined);\nfree(joined);')
        out = self.run_clean(str_main(body), [SOURCE / "str_tools.c"],
                             context="str_concat(word, word)")
        self.same(out.strip(), "ab abab", "str_concat(word, word)")

    def test_no_memory(self):
        for call in ('str_duplicate("Lima")', 'str_concat("Li", "ma")'):
            with self.subTest(call=call):
                source = FAILING_ALLOCATOR + str_main(
                    "fail_at = 1;\n"
                    f"char *result = {call};\n"
                    'printf("%s\\n", result == NULL ? "NULL" : "otra");\n'
                    "free(result);")
                out = self.run_clean(source, [SOURCE / "str_tools.c"], extra=WRAP,
                                     context=f"{call}, cuando malloc devuelve NULL")
                self.same(out.strip(), "NULL", f"{call} debe devolver NULL si no hay memoria")


# Lo que debe escribir cada programa de la cacería ya corregido.
HUNT_OUTPUT = {
    "longest_word": "CAJAMARCA\n",
    "seat_list": "primero: 1, último: 103\n",
    "route_backup": "ruta: Kallao, respaldo: Callao\n",
    "grade_histogram": "7: 1\n11: 1\n14: 2\n20: 2\n",
    "passing_groups": "grupos que llegan a 30: 1\n",
}


@unittest.skipUnless((SOURCE / "hunt").exists(), f"no está {LAB_DIR}/hunt")
class Hunt(LabTest):
    def check_with_sanitizers(self, name):
        source = (SOURCE / "hunt" / f"{name}.c").read_text(encoding="utf-8")
        out, code = compile_and_run(source, [])
        if code != 0 or "ERROR:" in out or "runtime error" in out:
            self.fail(f"{name}.c aún tiene un error:\n{short_report(out)}")
        self.same(out, HUNT_OUTPUT[name], f"lo que escribe {name}.c")

    def check_with_valgrind(self, name):
        with tempfile.TemporaryDirectory() as folder:
            binary = pathlib.Path(folder) / name
            subprocess.run(
                ["gcc", *VALGRIND_FLAGS, str(SOURCE / "hunt" / f"{name}.c"), "-o", str(binary)],
                check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            )
            done = subprocess.run(
                ["valgrind", "-q", "--leak-check=full", "--error-exitcode=9", str(binary)],
                text=True, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                timeout=60,
            )
        if done.returncode != 0:
            lines = [line for line in done.stdout.splitlines() if "==" in line][:6]
            self.fail(f"valgrind todavía reporta errores en {name}.c:\n" + "\n".join(lines))

    def test_longest_word(self):
        self.check_with_sanitizers("longest_word")
        self.check_with_valgrind("longest_word")

    def test_seat_list(self):
        self.check_with_sanitizers("seat_list")
        self.check_with_valgrind("seat_list")

    def test_route_backup(self):
        self.check_with_sanitizers("route_backup")
        self.check_with_valgrind("route_backup")

    def test_grade_histogram(self):
        self.check_with_sanitizers("grade_histogram")
        self.check_with_valgrind("grade_histogram")

    def test_passing_groups(self):
        self.check_with_sanitizers("passing_groups")
        self.check_with_valgrind("passing_groups")


def array_main(body):
    return ('#include <stdio.h>\n#include "int_array.h"\n'
            "int main(void) {\n" + body + "\nreturn 0;\n}\n")


@unittest.skipUnless((SOURCE / "int_array.c").exists(), f"no está {LAB_DIR}/int_array.c")
class GrowingArray(LabTest):
    def read(self, content, context):
        body = ('struct int_array *a = int_array_read_file("in.txt");\n'
                'if (a == NULL) { printf("NULL\\n"); return 0; }\n'
                'printf("%zu", a->count);\n'
                'for (size_t i = 0; i < a->count; i++) { printf(" %d", a->items[i]); }\n'
                'printf("\\n");\nint_array_free(a);')
        return self.run_clean(array_main(body), [SOURCE / "int_array.c"],
                              {"in.txt": content}, context=context).strip()

    def test_new_is_empty(self):
        body = ("struct int_array *a = int_array_new();\n"
                "if (a == NULL) { return 1; }\n"
                'printf("%zu\\n", a->count);\nint_array_free(a);')
        out = self.run_clean(array_main(body), [SOURCE / "int_array.c"], context="int_array_new")
        self.same(out.strip(), "0", "un arreglo nuevo tiene count en 0")

    def test_free_accepts_null(self):
        self.run_clean(array_main("int_array_free(NULL);"), [SOURCE / "int_array.c"],
                       context="int_array_free(NULL)")

    def test_push_keeps_everything(self):
        body = ("struct int_array *a = int_array_new();\n"
                "if (a == NULL) { return 1; }\n"
                "for (int i = 0; i < 1000; i++) { if (!int_array_push(a, i * 3)) { return 2; } }\n"
                "long sum = 0;\n"
                "for (size_t i = 0; i < a->count; i++) {\n"
                "    if (a->items[i] != (int) i * 3) { return 3; }\n"
                "    sum += a->items[i];\n}\n"
                'printf("%zu %ld\\n", a->count, sum);\nint_array_free(a);')
        out = self.run_clean(array_main(body), [SOURCE / "int_array.c"], context="1000 int_array_push")
        self.same(out.strip(), "1000 1498500", "count y suma después de 1000 int_array_push")

    def test_capacity_doubles(self):
        source = FAILING_ALLOCATOR + array_main(
            "struct int_array *a = int_array_new();\n"
            "if (a == NULL) { return 1; }\n"
            "calls = 0;\n"
            "for (int i = 0; i < 10000; i++) { if (!int_array_push(a, i)) { return 2; } }\n"
            'printf("%d\\n", calls);\nint_array_free(a);')
        out = self.run_clean(source, [SOURCE / "int_array.c"], extra=WRAP, context="10000 int_array_push")
        calls = int(out.strip())
        if calls > 20:
            self.fail(f"10000 int_array_push hicieron {calls} reservas. "
                      "Si la capacidad se duplica, bastan unas 15.")

    def test_push_without_memory_keeps_the_data(self):
        source = FAILING_ALLOCATOR + array_main(
            "for (fail_at = 1; ; fail_at++) {\n"
            "    calls = 0;\n"
            "    struct int_array *a = int_array_new();\n"
            "    int pushed = 0;\n"
            "    if (a != NULL) {\n"
            "        for (int i = 0; i < 100; i++) {\n"
            "            size_t before = a->count;\n"
            "            if (int_array_push(a, i)) { pushed++; }\n"
            '            else if (a->count != before) { printf("cambió count\\n"); return 3; }\n'
            "        }\n"
            "        for (int i = 0; i < pushed; i++) {\n"
            '            if (a->items[i] < 0) { printf("dato perdido\\n"); return 3; }\n'
            "        }\n"
            "    }\n"
            "    int_array_free(a);\n"
            "    if (calls < fail_at) { break; }\n"
            "}\n"
            'printf("listo\\n");')
        out = self.run_clean(source, [SOURCE / "int_array.c"], extra=WRAP,
                             context="int_array_push cuando realloc devuelve NULL")
        self.assertIn("listo", out)

    def test_read_small_file(self):
        self.same(self.read("12\n-5\n40\n", "tres líneas"), "3 12 -5 40", "archivo de tres líneas")

    def test_read_last_line_without_newline(self):
        self.same(self.read("1\n2", "sin salto al final"), "2 1 2", "archivo sin salto de línea al final")

    def test_read_empty_file(self):
        self.same(self.read("", "archivo vacío"), "0", "un archivo vacío da un arreglo vacío")

    def test_read_long_file(self):
        values = [i * 2 - 50000 for i in range(50000)]
        content = "".join(f"{v}\n" for v in values)
        expected = " ".join(map(str, [len(values), *values]))
        if self.read(content, "50000 líneas") != expected:
            self.fail("El archivo de 50000 líneas no se leyó bien.")

    def test_read_bad_files_release_everything(self):
        cases = {
            "una letra": "1\nx\n3\n",
            "dos números en una línea": "1\n2 3\n",
            "un número y algo más": "4 hola\n",
            "una línea vacía": "1\n\n2\n",
            "muchas líneas y luego una mala": "1\n" * 500 + "mal\n",
        }
        for label, content in cases.items():
            with self.subTest(label):
                self.same(self.read(content, label), "NULL", f"{label}: debe devolver NULL")

    def test_read_closes_the_file(self):
        body = ("for (int i = 0; i < 100; i++) {\n"
                '    struct int_array *good = int_array_read_file("good.txt");\n'
                '    if (good == NULL) { printf("falló la lectura %d\\n", i); return 1; }\n'
                "    int_array_free(good);\n"
                '    int_array_free(int_array_read_file("bad.txt"));\n'
                "}")
        self.run_clean(array_main(body), [SOURCE / "int_array.c"],
                       {"good.txt": "1\n2\n", "bad.txt": "1\nmal\n"},
                       context="100 lecturas seguidas: ¿se cierra el archivo en todos los caminos?",
                       setup=limit_open_files)

    def test_read_missing_file(self):
        body = 'printf("%s\\n", int_array_read_file("no_existe.txt") == NULL ? "NULL" : "otra");'
        out = self.run_clean(array_main(body), [SOURCE / "int_array.c"], context="archivo que no existe")
        self.same(out.strip(), "NULL", "un archivo que no existe debe dar NULL")

    def test_read_without_memory_releases_everything(self):
        source = FAILING_ALLOCATOR + array_main(
            "int nulls = 0;\n"
            "for (fail_at = 1; ; fail_at++) {\n"
            "    calls = 0;\n"
            '    struct int_array *a = int_array_read_file("in.txt");\n'
            "    if (a == NULL) { nulls++; }\n"
            "    int_array_free(a);\n"
            "    if (calls < fail_at) { break; }\n"
            "}\n"
            'printf("%d\\n", nulls);')
        content = "".join(f"{i}\n" for i in range(100))
        out = self.run_clean(source, [SOURCE / "int_array.c"], {"in.txt": content}, WRAP,
                             context="int_array_read_file cuando no hay memoria")
        if int(out.strip().splitlines()[-1]) < 1:
            self.fail("int_array_read_file debe devolver NULL cuando no hay memoria.")

    def test_sum_file(self):
        source = (HERE / "lab" / "sum_file.c").read_text(encoding="utf-8")
        out, code = compile_and_run(source, [SOURCE / "int_array.c"], {"in.txt": "5\n-2\n10\n"},
                                    stdin="in.txt\n")
        if code != 0:
            self.fail(f"sum_file falló:\n{short_report(out)}")
        self.same(out.strip(), "3 enteros, suma 13", "lo que escribe sum_file")


class YourTests(LabTest):
    """Comprueba que sus archivos de prueba cubren lo pedido y que pasan."""
    CASES = {
        "image_tests.c": (["image_new", "image_free", "image_load"], ["image.c"]),
        "str_tests.c": (["str_duplicate", "str_concat"], ["str_tools.c"]),
        "array_tests.c": (["int_array_push", "int_array_read_file"], ["int_array.c"]),
    }

    def check(self, file_name):
        path = SOURCE / file_name
        if not path.exists():
            self.skipTest(f"no está {LAB_DIR}/{file_name}")
        names, others = self.CASES[file_name]
        text = re.sub(r"//[^\n]*", "", path.read_text(encoding="utf-8"))
        for name in names:
            self.assertRegex(text, rf"\b{name}\(", f"{file_name} no prueba {name}")
        with tempfile.TemporaryDirectory() as folder:
            binary = pathlib.Path(folder) / "tests"
            built = subprocess.run(
                ["gcc", *FLAGS, f"-I{HEADERS}", str(path), *[str(SOURCE / o) for o in others],
                 "-o", str(binary)],
                text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            )
            if built.returncode != 0:
                self.fail(f"{file_name} no compila:\n{built.stdout}")
            done = subprocess.run(
                [str(binary)], cwd=HERE, text=True, encoding="utf-8",
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60,
            )
        if done.returncode != 0:
            self.fail(f"{file_name} falla:\n{short_report(done.stdout)}")

    def test_image_tests(self):
        self.check("image_tests.c")

    def test_str_tests(self):
        self.check("str_tests.c")

    def test_array_tests(self):
        self.check("array_tests.c")


if __name__ == "__main__":
    unittest.main()
