# Mutations the read tests must catch. One row each: file | exact text | replacement | why
# `python3 .github/scripts/run_tests.py --mutants --only read` applies each row to a scratch copy
# of read/ and fails when the tests still pass. The text must occur exactly once in the file.
gwt_boe.py | PACE = {'start': 6.0, 'floor': 6.0, 'ceil': 120.0} | PACE = {'start': 6.0, 'floor': 3.0, 'ceil': 120.0} | the BOE gate never goes faster than one request every 6 s, even after quiet hours
gwt_boe.py | ('dato[4]', '"%s"' % phrase.replace('"', '')) | ('dato[4]', phrase) | the BOE phrase is always sent in quotes, so the site reads it as one name
gwt_boe.py | return bool(_TOTAL.search(p) or 'No se han encontrado' in p or 'no ha encontrado' in p) | return True | a BOE page that is not a results page (form, captcha) is a STOP, never zero results
gwt_boe.py | return '%s/gazeta/dias/%s/pdfs/%s' % (HOST, date.replace('-', '/'), href.rsplit('/', 1)[-1]) | return HOST + href | BOE act PDFs come from /gazeta/dias/, never the /datos/pdfs/ robots.txt disallows
gwt_read.py | page=lambda p: p.startswith('%PDF')) | page=lambda p: bool(p)) | an HTML page at a BOE PDF URL is a STOP and is never cached
gwt_read.py | and not c.get('implausible')][:n]: | ][:n]: | --pdf-context skips acts --born already ruled out
gwt_boe.py | return re.compile(r'(?<!\w)%s(?!\w)' % re.escape(_fold(phrase))) | return re.compile(re.escape(_fold(phrase))) | "Ana Zorvalo" is not found inside "Juana Zorvalo"
gwt_boe.py | if True in said: | if said[:1] == [True]: | one occurrence with the tree's parents makes the act consistent
gwt_boe.py | return False if said and all(x is False for x in said) else None | return False if False in said else None | other parents exclude an act only when every occurrence writes other parents
gwt_boe.py | [_fold(x).split()[0] for x in w] == [_fold(x).split()[0] for x in found] | [_fold(x) for x in w] == [_fold(x) for x in found] | a tree's "José María" matches an act's "José"
gwt_boe.py | if c.get('implausible') or c.get('same_parents') is False: | if c.get('implausible'): | other parents mark an act excluded
gwt_boe.py | (r['year'] < born or r['year'] > born + 100) | (r['year'] < born + 14 or r['year'] > born + 100) | --born keeps an act that names a child (orphans in pension lists)
gwt_boe.py | (r['year'] < born or r['year'] > born + 100) | (r['year'] < born) | --born rules out an act over 100 years after the birth
gwt_read.py | str(a.pdf_context), str(a.born), str(a.parents)] | str(a.pdf_context)] | a rerun with other --born or --parents is written to its own file
gwt_read.py | gwt_reader.search_key('\n'.join([first] + words), words) | gwt_reader.search_key(first, words) | a long name rerun with other --parents still gets its own file
gwt_read.py | max(1, min(a.pages, 5)) | max(1, a.pages) | --pages never reads more than 5 pages
gwt_read.py | n = max(0, min(getattr(a, 'pdf_context', 0), 10)) | n = max(0, getattr(a, 'pdf_context', 0)) | --pdf-context never fetches more than 10 PDFs
gwt_insee.py | return y or None, mo > 12 or d > 31 | return y or None, False | an INSEE date with month over 12 or day over 31 is kept and marked malformed, never silent
gwt_insee.py | if not m: | if False: | an INSEE date that is not eight digits stops the run with exit 2, never a crash or a skip
gwt_insee.py | 'malformed': [] if star else ['name']} | 'malformed': []} | an INSEE name with no "*" is kept whole and marked malformed
gwt_insee.py | odd += bool(rec['malformed']) | pass | each file's count of lines with a value out of INSEE's rules reaches the envelope, matched or not
gwt_insee.py | if sex not in SEXES: | if False: | an INSEE sex that is not 1 or 2 stops the run (a shifted line is caught here)
gwt_insee.py | if len(line) < MIN_LENGTH: | if False: | an INSEE line too short to hold the death date stops the run
gwt_insee.py | rec = parse_line(line) | rec = parse_line(line) if surname_matches(line.split('*')[0], wanted) else {'surname': ''} | every INSEE line is checked, not only the lines a --surname matches
gwt_insee.py | return any(have[i:j] in wanted for i in range(len(have)) for j in range(i + 1, len(have) + 1)) | return any(' '.join(w) in ' '.join(have) for w in wanted) | an INSEE --surname matches whole words: ORTEGA never matches ORTEGAS
gwt_insee.py | return cp850, 'cp850' | return latin1, 'latin-1' | a non-UTF-8 INSEE line whose Latin-1 reading has no more accented letters is read as CP850 (PARIS 12°)
gwt_insee.py | if _plausible(latin1) > _plausible(cp850): | if False: | a Latin-1 INSEE line (0xF1 ñ, 0xB0 °) is read as Latin-1, not CP850
gwt_insee.py | return y or None | return y | an INSEE year 0000 is no year, never year 0
gwt_read.py | return src['search'](a, None)       # offline: no gate, no cache, no fetcher | pass | gwt-read insee never builds a gate, a cache or a fetcher
gwt_insee.py | if len(line) > MAX_LENGTH: | if False: | two INSEE records glued on one line, or a CR-only file, stop the run instead of losing every record after the first
gwt_insee.py | line = line.removeprefix('\ufeff') | pass | a UTF-8 byte order mark does not shift the first INSEE line
gwt_insee.py | if not lines: | if False: | an empty INSEE file (a failed download) is an error, never a clean "no results"
gwt_read.py | return EXIT_ERROR      # a file that cannot be read is an error, never "no results" | return EXIT_EMPTY | an unreadable INSEE file exits 1, so a caller never logs it as "searched, none found"
gwt_insee.py | h.update(raw) | pass | the envelope lists each INSEE file's real sha256, so a download can be checked against it
gwt_insee.py | wanted = {tuple(fold(s)) for s in surnames if fold(s)} | wanted = {tuple(fold(s))[:1] for s in surnames if fold(s)} | an INSEE --surname of several words keeps only lines that hold all of them
gwt_reader.py | if _matches(spec.get('negative_markers'), page): | if True: | a page with no row and without the site's own "no results" words (a skeleton) is unknown, a STOP, never "none"
gwt_reader.py | if _matches(spec.get('result_markers'), page): | if False: | a page with a row of results classifies as hits
gwt_reader.py | return lambda page: classify(page, spec) != 'unknown' | return lambda page: True | a source's page check turns an unknown page into a STOP (exit 3)
gwt_reader.py | if verdict == 'blocked' and on_403 is not None and on_403(state['body']) == 'restricted': | if False: | a 403 the source shows is one record's access is "restricted", and the gate never hears of it
gwt_read.py | return 'restricted' if perms and 'ThemisPrmAnyone' not in perms else None | return 'restricted' if perms else None | a FamilySearch 403 whose permissions include ThemisPrmAnyone is still a block
gwt_read.py | expect=is_record, refresh=True) | expect=is_record) | the control record after a 403 is read again, never replayed from the cache
gwt_read.py | if control:          # cmd_record has refused a control equal to the record | if False: | with --control-ark, a 403 whose control record answers is restricted, not a block
gwt_read.py | ok = code in (EXIT_OK, EXIT_EMPTY) and n >= ctl['min_count'] | ok = code in (EXIT_OK, EXIT_EMPTY) | a canary that reads less than its minimum makes the run void
gwt_read.py | ns.refresh, ns.out = True, tmp | ns.refresh, ns.out = False, tmp | a canary is read without the cache: a cached canary proves nothing
gwt_read.py | c = run_controls(src, src['_search_parser'], a, R, 'before') | c = 0 | a short canary before a search means no search
gwt_read.py | if when in ('after', 'both') and code in (EXIT_OK, EXIT_EMPTY): | if False: | --controls after runs the canaries after the search and voids it when one is short
gwt_read.py | if lab in ('EVENT_PARISH_ORIG', 'CP_MAR_PLACE_ORIG') or (lab.startswith('EVENT_PLACE') and lab.endswith('_ORIG')): | if lab in ('EVENT_PARISH_ORIG', 'CP_MAR_PLACE_ORIG') or lab.startswith('EVENT_PLACE'): | place_orig keeps only the place as the index typed it, not the standard place
gwt_read.py | if bad or src['id'] in out: | if bad: | a source module cannot take an id another source already has
gwt_read.py | if _ARTIFACT_DENIED in text.lower(): | if False: | a FamilySearch 403 saying "Access to artifact denied" is that record's access ("restricted"), not a 24 h block
gwt_reader.py | self.gate.denied(self.lane, 'HTTP 403 %s' % url[:120])     # logged; no pause, no block | pass | a restricted record is still logged in the gate log as a denial, with no pause
gwt_read.py | if e.verdict == 'blocked': | if True: | a 403 whose control read fails with a 5xx, a 429 or the wrong shape stays a 24 h block, never an exit 1 that tells the gate nothing
gwt_read.py | except Paused: | except ZeroDivisionError: | a 403 whose control read meets a paused gate stays a 24 h block, never an exit 6 that records nothing
gwt_read.py | if control and ark_id(control) in (None, ark): | if False: | a --control-ark that is not a 1:1 record ark, or is the record itself, exits 64 instead of being ignored
gwt_read.py | if e.verdict == 'blocked': | if False: | a control that is refused too blocks the gate once, not twice (a second hit would triple the gap again)
gwt_read.py | 'controls': [],      # no canary on genae | 'controls': [{'argv': ['--surname', 'X'], 'min_count': 5}], | genae gets no automated canary searches: its robots.txt disallows the search pages
gwt_read.py | a.refresh = True    # the canaries prove the site works now, so the search they bracket is read now too | pass | a search bracketed by canaries is read fresh, never a cached zero the canaries say nothing about
gwt_read.py | if before.get(name) != mtime: | if True: | a voided search removes only the files it wrote, never the other files in --out
gwt_read.py | os.remove(os.path.join(folder, name)) | pass | a voided search leaves no envelope or candidates file that reads as "none"
gwt_read.py | cache.forget(method, url, headers, data) | pass | a voided search leaves no cached answer, so the next run asks the site again
gwt_read.py | if when in ('after', 'both') and code in (EXIT_OK, EXIT_EMPTY): | if when in ('after', 'both'): | the after-canaries run only when the search ended with rows or none, never after a STOP
gwt_read.py | if lab in ('EVENT_PARISH_ORIG', 'CP_MAR_PLACE_ORIG') or | if lab in () or | place_orig keeps the parish and the marriage place a collection types in their own fields
gwt_read.py | if lab in _PLACE_IN_JSON and t.lstrip().startswith('{'): | if False: | place_orig reads the typed place some collections keep only inside EXT_DATA or FS_UNSUPPORTED JSON
gwt_read.py | if t and t not in found: | if t: | place_orig lists each typed place once, even when the record repeats it
gwt_read.py | undo()      # a canary that was stopped proved nothing either: the run is void all the same | pass | a search whose after-canary hits a block or a captcha leaves no file or cached answer that reads as "none"
gwt_reader.py | if verdict == 'blocked' and on_403 is not None | if on_403 is not None | only a 403 is asked whether it is restricted: a 429 on a record stays a wall, even with --control-ark
gwt_reader.py | and isinstance(c.get('min_count'), int) and c['min_count'] > 0): | and isinstance(c.get('min_count'), int)): | a canary must want at least one row: a minimum of 0 passes on any page and proves nothing
gwt_read.py | return v if isinstance(v, str) and v else None | return v | place_orig keeps only text from the JSON fields, never a list or a number
gwt_reader.py | if not (isinstance(c, dict) and isinstance(c.get('argv'), list) and c['argv'] | if not (isinstance(c, dict) and isinstance(c.get('argv'), list) | a canary must name at least one search option: an empty search is not a canary
gwt_read.py | if not isinstance(t, str) or not t: | if not t: | place_orig keeps only text, never an object a field holds
gwt_read.py | R.cache.store = store | pass | a void forgets only the answers its search cached, never the canaries' own
