Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

741 lines
28 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for tools/libindex.py -- the library full-text index.
`libindex.py` is host CPython, so it is loaded the way `test_scrub.py` and
`test_knowledge_drift.py` load their tools: put `tools/` on `sys.path` and
import it. It lives here rather than beside `tools/` because this is where
every other test of a `tools/` module already lives, and one `python -m
pytest` from the repo root has to reach all of them.
WHAT IS WORTH TESTING HERE
---------------------------
Not "does FTS5 work". Two things:
* **The tokenizer decision.** The whole value of the index rests on `#4`
not being the same token as `4`, on `f'c` surviving as one word, on an
inch mark staying attached to its number, and on a decimal like `7.5`
matching without also matching a page that merely has a 7 and a 5 on it.
Each of those is a separate assertion below, because each is a separate
way the tokenizer choice could be wrong.
* **Incremental re-ingest.** Other agents write notes into the library
while a build is running, so `build` is re-run constantly and every
re-run has to be cheap AND correct: unchanged content skipped, changed
content replaced (not doubled), a moved file recognised as the same
document, a vanished file's rows removed -- and a build given one `--root`
must not delete the rows belonging to a root it was not asked about.
Plus the error discipline: "0 hits" and "no index built yet" are different
sentences and one must never be printed for the other.
Every PDF here is built with pymupdf into `tmp_path` and every note is
written there too. Nothing reads the real library, and nothing writes into
the working directory. No client, job or producer name appears; the
documents are invented and the marks are placeholders.
"""
# pylint: disable=import-error,invalid-name,redefined-outer-name
import os
import sqlite3
import sys
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
TOOLS = os.path.join(ROOT, 'tools')
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
libindex = pytest.importorskip('libindex')
pymupdf = pytest.importorskip('pymupdf')
# ---------------------------------------------------------------------------
# fixtures / helpers
# ---------------------------------------------------------------------------
def make_pdf(path, pages):
"""A tiny real PDF: one page per string, '' meaning no text layer."""
doc = pymupdf.open()
for text in pages:
page = doc.new_page()
if text:
page.insert_text((72, 72), text, fontsize=11)
doc.save(str(path))
doc.close()
return str(path)
class Run:
"""One CLI invocation: its exit code and every line it printed."""
def __init__(self, code, lines):
self.code = code
self.lines = lines
self.text = '\n'.join(lines)
def __repr__(self): # pragma: no cover - debug
return 'Run({0})\n{1}'.format(self.code, self.text)
def run(*argv):
lines = []
code = libindex.main(list(argv), out=lambda *a: lines.append(
' '.join(str(x) for x in a)))
return Run(code, lines)
@pytest.fixture()
def lib(tmp_path):
"""An empty stand-in library: a drop folder with a notes/ subfolder."""
root = tmp_path / 'lib'
(root / 'notes').mkdir(parents=True)
return root
@pytest.fixture()
def db(tmp_path):
return str(tmp_path / 'index.sqlite')
def rows(db_path, sql, args=()):
con = sqlite3.connect(db_path)
try:
return con.execute(sql, args).fetchall()
finally:
con.close()
def one(db_path, sql, args=()):
return rows(db_path, sql, args)[0][0]
# ---------------------------------------------------------------------------
# availability
# ---------------------------------------------------------------------------
def test_fts5_is_available_on_this_interpreter():
# The tool refuses rather than falling back to LIKE, so if this ever goes
# false the refusal is the correct behaviour -- but on CPython 3.12 for
# Windows it is true, and the rest of this file assumes it.
assert libindex.fts5_available() is True
def test_the_refusal_sentence_names_fts5_and_does_not_offer_a_fallback():
assert 'FTS5' in libindex.NO_FTS5
assert 'LIKE' not in libindex.NO_FTS5
# ---------------------------------------------------------------------------
# building
# ---------------------------------------------------------------------------
def test_build_indexes_every_page_and_every_note(lib, db):
make_pdf(lib / 'guide.pdf', ['page one text', 'page two text'])
(lib / 'notes' / 'M4-guide.md').write_text(
'# guide\n\nextracted facts live here\n', encoding='utf-8')
r = run('build', '--root', str(lib), '--db', db)
assert r.code == 0
assert one(db, 'SELECT COUNT(*) FROM docs') == 1
assert one(db, 'SELECT COUNT(*) FROM pages') == 2
assert one(db, 'SELECT COUNT(*) FROM notes') == 1
assert one(db, 'SELECT pages FROM docs') == 2
# The identity is the content hash, matching the ledger's convention.
assert len(one(db, 'SELECT hash FROM docs')) == 64
def test_a_page_with_no_text_layer_is_stored_empty_and_still_text_layer(
lib, db):
# Not skipped, and not marked with some other method: we DID look at it,
# with the text-layer method, and it had nothing. A later per-page vision
# pass is what would give it a different method value.
make_pdf(lib / 'scan.pdf', ['readable page', '', ''])
run('build', '--root', str(lib), '--db', db)
assert one(db, 'SELECT COUNT(*) FROM pages') == 3
methods = {m for (m,) in rows(db, 'SELECT DISTINCT method FROM pages')}
assert methods == {libindex.METHOD_TEXT_LAYER}
assert one(db, "SELECT COUNT(*) FROM pages WHERE TRIM(text) = ''") == 2
def test_status_reports_the_empty_text_pages_rather_than_hiding_them(lib, db):
make_pdf(lib / 'scan.pdf', ['readable', '', ''])
run('build', '--root', str(lib), '--db', db)
r = run('status', '--db', db)
assert r.code == 0
assert 'docs: 1' in r.text
assert 'pages: 3' in r.text
assert '2 with an empty text layer' in r.text
assert 'notes: 0' in r.text
assert 'built:' in r.text
def test_a_pdf_that_cannot_be_read_is_named_and_not_silently_dropped(lib, db):
(lib / 'broken.pdf').write_bytes(b'this is not a pdf at all')
make_pdf(lib / 'fine.pdf', ['good page'])
r = run('build', '--root', str(lib), '--db', db)
assert r.code == 0
assert 'COULD NOT READ' in r.text
assert 'broken.pdf' in r.text
# and the readable one still got in
assert one(db, 'SELECT COUNT(*) FROM docs') == 1
# not recorded as a document, so a later fix is picked up rather than
# being treated as already-known
r2 = run('build', '--root', str(lib), '--db', db)
assert 'COULD NOT READ' in r2.text
def test_notes_are_taken_from_the_notes_folder_and_not_from_the_ledger(
lib, db):
(lib / 'LEDGER.md').write_text('| # | sha256 | file |\n', encoding='utf-8')
(lib / 'RUNBOOK.md').write_text('how to drain the folder\n',
encoding='utf-8')
(lib / 'notes' / 'M4-guide.md').write_text('a real note\n',
encoding='utf-8')
run('build', '--root', str(lib), '--db', db)
got = {os.path.basename(p) for (p,) in rows(db, 'SELECT relpath FROM notes')}
assert got == {'M4-guide.md'}
# ---------------------------------------------------------------------------
# doc_ref parsing
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('name,expected', [
('M1-brd-memo.md', 'M1'),
('M11a-details-p001-085.md', 'M11a'),
('D2-datasheet.md', 'D2'),
('H1-drawing-set.md', 'H1'),
('m3-lowercase.md', 'M3'),
('notes-about-things.md', None),
('README.md', None),
])
def test_note_doc_ref_is_the_ledger_id_or_nothing(name, expected):
# NULL, not '' -- "this note names no ledger row" and "this note names
# the empty row" are not the same claim.
assert libindex.note_doc_ref(name) == expected
def test_doc_ref_lands_in_the_row_and_is_null_when_absent(lib, db):
(lib / 'notes' / 'M4-guide.md').write_text('one\n', encoding='utf-8')
(lib / 'notes' / 'scratch-thoughts.md').write_text('two\n',
encoding='utf-8')
run('build', '--root', str(lib), '--db', db)
got = dict((os.path.basename(p), ref) for p, ref in
rows(db, 'SELECT relpath, doc_ref FROM notes'))
assert got['M4-guide.md'] == 'M4'
assert got['scratch-thoughts.md'] is None
# ---------------------------------------------------------------------------
# the tokenizer decision
# ---------------------------------------------------------------------------
@pytest.fixture()
def shapes(lib, db):
"""One library holding the four token shapes that motivated the choice."""
make_pdf(lib / 'shapes.pdf', [
"reinforce with #4 bars where f'c is 5000 psi",
'plate mark P316 sits at 7.5 inches from the edge',
'a bare 4 appears here, and a 7 and a 5 far apart',
'slab is 8" thick at the bearing',
])
run('build', '--root', str(lib), '--db', db)
return db
def hits(db_path, query, *extra):
r = run('search', query, '--db', db_path, *extra)
assert r.code == 0, r.text
pages = [int(line.split('p.')[1].split()[0])
for line in r.lines if line.strip().startswith('[page]')]
return r, pages
def test_a_bar_size_is_not_the_same_token_as_a_bare_number(shapes):
# This is the whole reason '#' is a token character. Without it, every
# page with a 4 on it answers a query for #4.
_, p = hits(shapes, '#4')
assert p == [1]
_, p = hits(shapes, '4')
assert p == [3]
def test_concrete_strength_survives_its_apostrophe(shapes):
_, p = hits(shapes, "f'c")
assert p == [1]
def test_an_inch_mark_stays_attached_to_its_number(shapes):
_, p = hits(shapes, '8"')
assert p == [4]
def test_a_plate_mark_needs_no_special_handling(shapes):
_, p = hits(shapes, 'P316')
assert p == [2]
def test_a_decimal_matches_its_page_and_not_a_scattered_seven_and_five(shapes):
# '.' is deliberately NOT a token character (a trailing sentence period
# would glue itself on). The decimal is handled as an adjacent phrase
# instead, which is why page 3 -- which has a 7 and a 5, far apart --
# must not match.
r, p = hits(shapes, '7.5')
assert p == [2]
# It goes to FTS5 as a quoted PHRASE -- the quoting is what turns the
# decimal into an adjacency requirement instead of a parse error.
assert 'query: "7.5"' in r.text
# ---------------------------------------------------------------------------
# thousands-separator equivalence (the 7690 / 7,690 defect)
# ---------------------------------------------------------------------------
# Pinned BEFORE this fix existed, so a future change to the decimal path
# cannot silently regress it: '.' is not a tokenchar, a decimal is still
# handled purely by adjacent-phrase quoting, and none of that machinery may
# be disturbed by the comma-equivalence feature below.
@pytest.mark.parametrize('query,expr', [
('3.5', '"3.5"'),
('4.0', '"4.0"'),
('0.6', '"0.6"'),
('7.5', '"7.5"'),
])
def test_decimal_match_expr_is_pinned_and_unchanged_by_the_comma_fix(
query, expr):
assert libindex.match_expr(query) == expr
@pytest.fixture()
def commas(lib, db):
"""A library that prints the SAME fact only with a thousands separator.
This is the actual shape of the defect: `ALP Catalog 2024` prints
`7,690` -- never the bare digits -- on two pages and in a note, and the
2026-09-01 incident was a query for the bare `7690` coming back 0 hits
although the fact was sitting right there. `16,000` sits beside it on
the same page (a different capacity, from CLAUDE.md section 3's own
contradiction story) so the fix can be proven in both digit-count
directions without inventing numbers foreign to this corpus.
"""
make_pdf(lib / 'ALPCatalog2024.pdf', [
'irrelevant cover page',
'erection anchor capacity 8" 6,030 4,010 7,690 tension rating',
'a different table entirely: 16,000 lb working load, QL185G',
])
(lib / 'notes' / 'ALP-2024-erection-anchors.md').write_text(
'| size | tension |\n| 8T | 7,690 |\n| 8T-12T | 16,000 |\n',
encoding='utf-8')
run('build', '--root', str(lib), '--db', db)
return db
def test_bare_digits_find_a_number_the_corpus_only_prints_with_a_comma(
commas):
# This is the exact defect: before the fix this returned 0 hits even
# though the fact is on page 2 and in the note.
r, p = hits(commas, '7690')
assert r.code == 0
assert p == [2]
assert '[note]' in r.text
assert '0 hits' not in r.text
def test_comma_digits_still_find_a_number_the_corpus_prints_bare(lib, db):
# The reverse direction: a source that prints the number WITHOUT a
# separator must still answer a comma-typed query.
make_pdf(lib / 'bare.pdf', ['working load 16000 lbs rated'])
run('build', '--root', str(lib), '--db', db)
_, p = hits(db, '16,000')
assert p == [1]
def test_16000_and_16_comma_000_return_the_same_hits(commas):
a = hits(commas, '16000')[1]
b = hits(commas, '16,000')[1]
assert a == b == [3]
def test_7690_and_7_comma_690_return_the_same_hits(commas):
a = hits(commas, '7690')[1]
b = hits(commas, '7,690')[1]
assert a == b == [2]
def test_a_part_number_is_not_treated_as_a_grouped_integer(lib, db):
make_pdf(lib / 'parts.pdf', [
"the QL589G anchor and control number 25008-40085, bar mark SR5602"])
run('build', '--root', str(lib), '--db', db)
for query in ('QL589G', '25008-40085', 'SR5602'):
_, p = hits(db, query)
assert p == [1], query
@pytest.mark.parametrize('query,expr', [
('7690', '("7690" OR "7,690")'),
('7,690', '("7690" OR "7,690")'),
('16000', '("16000" OR "16,000")'),
('16,000', '("16000" OR "16,000")'),
])
def test_match_expr_expands_a_grouped_integer_both_ways(query, expr):
assert libindex.match_expr(query) == expr
@pytest.mark.parametrize('query', [
'QL589G', '25008-40085', 'SR5602', '#4', '123', "f'c", '8"',
])
def test_match_expr_does_not_expand_non_numeric_or_short_chunks(query):
expr = libindex.match_expr(query)
assert ' OR ' not in expr
def test_a_multi_word_query_is_and_not_a_phrase(shapes):
_, p = hits(shapes, 'plate edge')
assert p == [2]
def test_a_quoted_query_is_one_phrase(shapes):
_, p = hits(shapes, '"plate mark P316"')
assert p == [2]
_, p = hits(shapes, '"P316 plate mark"') # wrong order, no phrase
assert p == []
def test_underscores_and_hyphens_stay_separators_so_recall_survives(lib, db):
make_pdf(lib / 'ids.pdf', ['the ANCHOR_FACE_4T detail on a wall-panel'])
run('build', '--root', str(lib), '--db', db)
for query in ('anchor', 'wall', 'panel', 'ANCHOR_FACE_4T'):
_, p = hits(db, query)
assert p == [1], query
def test_a_prefix_star_still_reaches_fts5(shapes):
_, p = hits(shapes, 'reinforc*')
assert p == [1]
def test_cjk_is_indexed_and_printable_on_a_cp1252_console(lib, db):
# The encoding trap from CLAUDE.md section 7: a Chinese hit must come back
# as a hit, not as a UnicodeEncodeError that looks like a crash.
(lib / 'notes' / 'M9-mixed.md').write_text(
u'plate P316 -- 应力极限 is the governing limit\n', encoding='utf-8')
run('build', '--root', str(lib), '--db', db)
r = run('search', u'应力极限', '--db', db)
assert r.code == 0
assert '1 hit(s)' in r.text
assert 'M9-mixed.md' in r.text
# ---------------------------------------------------------------------------
# match_expr, at the unit level
# ---------------------------------------------------------------------------
@pytest.mark.parametrize('query,expr', [
('7.5', '"7.5"'),
("f'c", '"f\'c"'),
('#4', '"#4"'),
('8"', '"8"""'),
('shear wall', '"shear" "wall"'),
('"shear wall"', '"shear wall"'),
('beam*', '"beam"*'),
(' spaced out ', '"spaced" "out"'),
])
def test_match_expr_quotes_every_chunk(query, expr):
assert libindex.match_expr(query) == expr
def test_match_expr_passes_raw_through_untouched():
assert libindex.match_expr('a OR b', raw=True) == 'a OR b'
def test_match_expr_refuses_an_empty_query():
with pytest.raises(libindex.QueryError):
libindex.match_expr(' ')
def test_raw_lets_an_or_query_through(shapes):
_, p = hits(shapes, 'P316 OR bearing', '--raw')
assert sorted(p) == [2, 4]
# ---------------------------------------------------------------------------
# error discipline: could not judge vs judged and found nothing
# ---------------------------------------------------------------------------
def test_zero_hits_and_no_index_are_different_sentences(lib, db):
make_pdf(lib / 'guide.pdf', ['something entirely else'])
run('build', '--root', str(lib), '--db', db)
found_nothing = run('search', 'unobtainium', '--db', db)
assert found_nothing.code == 0
assert '0 hits' in found_nothing.text
assert 'no index built yet' not in found_nothing.text
missing = run('search', 'unobtainium', '--db', db + '.nope')
assert missing.code == 1
assert 'no index built yet' in missing.text
assert '0 hits' not in missing.text
def test_status_names_documents_left_behind_by_an_earlier_different_root(
tmp_path, db):
# This is the `roots:` line's own version of the "0 hits" defect: a build
# naming one root never touches rows from an EARLIER build that named a
# different one (by design -- see `build`'s docstring), so the database
# ends up holding more than the last build's roots describe. `status`
# must say so rather than silently reporting only the newest root list,
# which is exactly the shape of the real incident (four PDFs indexed
# from outside `knowledge/library` and `knowledge/MC`, `status` still
# naming only those two).
old_root = tmp_path / 'elsewhere'
old_root.mkdir()
make_pdf(old_root / 'outsider.pdf', ['a document from another root'])
run('build', '--root', str(old_root), '--db', db)
make_pdf(lib_dir(tmp_path) / 'insider.pdf', ['a document in the real root'])
run('build', '--root', str(lib_dir(tmp_path)), '--db', db)
r = run('status', '--db', db)
assert r.code == 0
assert one(db, 'SELECT COUNT(*) FROM docs') == 2
assert 'OUTSIDE the roots' in r.text
assert 'outsider.pdf' in r.text
def lib_dir(tmp_path):
d = tmp_path / 'reallib'
(d / 'notes').mkdir(parents=True, exist_ok=True)
return d
def test_status_says_nothing_extra_when_everything_is_under_the_roots(
lib, db):
make_pdf(lib / 'guide.pdf', ['content'])
run('build', '--root', str(lib), '--db', db)
r = run('status', '--db', db)
assert 'OUTSIDE the roots' not in r.text
def test_status_without_an_index_says_so_rather_than_printing_zeroes(db):
r = run('status', '--db', db + '.nope')
assert r.code == 1
assert 'no index built yet' in r.text
assert 'docs:' not in r.text
def test_a_broken_raw_query_is_a_refusal_not_zero_hits(shapes):
r = run('search', 'AND OR', '--db', shapes, '--raw')
assert r.code == 1
assert 'could not run that query' in r.text
assert '0 hits' not in r.text
# ---------------------------------------------------------------------------
# --doc filter
# ---------------------------------------------------------------------------
def test_doc_filter_accepts_a_hash_prefix_or_a_path_substring(lib, db):
make_pdf(lib / 'alpha.pdf', ['shared word alpha'])
make_pdf(lib / 'beta.pdf', ['shared word beta'])
run('build', '--root', str(lib), '--db', db)
r = run('search', 'shared', '--db', db)
assert '2 hit(s)' in r.text
r = run('search', 'shared', '--db', db, '--doc', 'alpha')
assert '1 hit(s)' in r.text
assert 'alpha.pdf' in r.text and 'beta.pdf' not in r.text
digest = one(db, "SELECT hash FROM docs WHERE relpath LIKE '%beta.pdf'")
r = run('search', 'shared', '--db', db, '--doc', digest[:10])
assert '1 hit(s)' in r.text
assert 'beta.pdf' in r.text
def test_kind_narrows_to_pages_or_notes(lib, db):
make_pdf(lib / 'alpha.pdf', ['shared word'])
(lib / 'notes' / 'M1-alpha.md').write_text('shared word\n',
encoding='utf-8')
run('build', '--root', str(lib), '--db', db)
assert '2 hit(s)' in run('search', 'shared', '--db', db).text
assert '[note]' not in run('search', 'shared', '--db', db,
'--kind', 'pages').text
assert '[page]' not in run('search', 'shared', '--db', db,
'--kind', 'notes').text
# ---------------------------------------------------------------------------
# incremental re-ingest
# ---------------------------------------------------------------------------
def test_a_second_build_changes_nothing_and_duplicates_nothing(lib, db):
make_pdf(lib / 'guide.pdf', ['page one', 'page two'])
(lib / 'notes' / 'M4-guide.md').write_text('note\n', encoding='utf-8')
run('build', '--root', str(lib), '--db', db)
before = (one(db, 'SELECT COUNT(*) FROM docs'),
one(db, 'SELECT COUNT(*) FROM pages'),
one(db, 'SELECT COUNT(*) FROM notes'))
r = run('build', '--root', str(lib), '--db', db)
assert 'docs: 0 new, 1 unchanged, 0 moved, 0 removed' in r.text
assert 'pages: +0, -0' in r.text
assert 'notes: 0 new, 0 changed, 1 unchanged, 0 removed' in r.text
after = (one(db, 'SELECT COUNT(*) FROM docs'),
one(db, 'SELECT COUNT(*) FROM pages'),
one(db, 'SELECT COUNT(*) FROM notes'))
assert before == after
# and the FTS side did not double either
assert one(db, 'SELECT COUNT(*) FROM pages_fts') == before[1]
def test_a_moved_file_is_the_same_document(lib, db):
make_pdf(lib / 'guide.pdf', ['page one', 'page two'])
run('build', '--root', str(lib), '--db', db)
digest = one(db, 'SELECT hash FROM docs')
(lib / 'sub').mkdir()
os.replace(str(lib / 'guide.pdf'), str(lib / 'sub' / 'guide.pdf'))
r = run('build', '--root', str(lib), '--db', db)
assert 'docs: 0 new, 1 unchanged, 1 moved, 0 removed' in r.text
assert one(db, 'SELECT COUNT(*) FROM docs') == 1
assert one(db, 'SELECT COUNT(*) FROM pages') == 2
assert one(db, 'SELECT hash FROM docs') == digest
assert one(db, 'SELECT relpath FROM docs').endswith('sub/guide.pdf')
def test_changed_bytes_replace_the_old_document_rather_than_joining_it(
lib, db):
make_pdf(lib / 'guide.pdf', ['the original wording'])
run('build', '--root', str(lib), '--db', db)
old = one(db, 'SELECT hash FROM docs')
make_pdf(lib / 'guide.pdf', ['the revised wording', 'and a new page'])
r = run('build', '--root', str(lib), '--db', db)
assert 'docs: 1 new, 0 unchanged, 0 moved, 1 removed' in r.text
assert one(db, 'SELECT COUNT(*) FROM docs') == 1
assert one(db, 'SELECT COUNT(*) FROM pages') == 2
assert one(db, 'SELECT hash FROM docs') != old
_, p = hits(db, 'revised')
assert p == [1]
assert hits(db, 'original')[1] == []
def test_a_changed_note_is_reindexed_not_appended(lib, db):
note = lib / 'notes' / 'M4-guide.md'
note.write_text('the original claim\n', encoding='utf-8')
run('build', '--root', str(lib), '--db', db)
note.write_text('the corrected claim\n', encoding='utf-8')
r = run('build', '--root', str(lib), '--db', db)
assert 'notes: 0 new, 1 changed, 0 unchanged, 0 removed' in r.text
assert one(db, 'SELECT COUNT(*) FROM notes') == 1
assert '1 hit(s)' in run('search', 'corrected', '--db', db).text
assert '0 hits' in run('search', 'original', '--db', db).text
def test_a_vanished_file_loses_its_rows_and_the_count_is_printed(lib, db):
make_pdf(lib / 'keep.pdf', ['stays put'])
make_pdf(lib / 'drop.pdf', ['goes away', 'second page'])
(lib / 'notes' / 'M1-drop.md').write_text('note\n', encoding='utf-8')
run('build', '--root', str(lib), '--db', db)
os.remove(str(lib / 'drop.pdf'))
os.remove(str(lib / 'notes' / 'M1-drop.md'))
r = run('build', '--root', str(lib), '--db', db)
assert 'docs: 0 new, 1 unchanged, 0 moved, 1 removed' in r.text
assert 'pages: +0, -2' in r.text
assert 'notes: 0 new, 0 changed, 0 unchanged, 1 removed' in r.text
assert one(db, 'SELECT COUNT(*) FROM docs') == 1
assert one(db, 'SELECT COUNT(*) FROM pages') == 1
assert one(db, 'SELECT COUNT(*) FROM notes') == 0
assert '0 hits' in run('search', 'goes away', '--db', db).text
def test_a_build_of_one_root_does_not_delete_another_roots_rows(tmp_path, db):
a = tmp_path / 'a'
b = tmp_path / 'b'
a.mkdir()
b.mkdir()
make_pdf(a / 'a.pdf', ['alpha content'])
make_pdf(b / 'b.pdf', ['bravo content'])
run('build', '--root', str(a), '--root', str(b), '--db', db)
assert one(db, 'SELECT COUNT(*) FROM docs') == 2
# Re-run naming only one root. The other root's document was not looked
# at, which is not the same as it having vanished.
r = run('build', '--root', str(a), '--db', db)
assert 'docs: 0 new, 1 unchanged, 0 moved, 0 removed' in r.text
assert one(db, 'SELECT COUNT(*) FROM docs') == 2
assert '1 hit(s)' in run('search', 'bravo', '--db', db).text
def test_two_paths_with_identical_bytes_are_one_document(lib, db):
make_pdf(lib / 'first.pdf', ['identical content'])
(lib / 'copy').mkdir()
import shutil
shutil.copyfile(str(lib / 'first.pdf'), str(lib / 'copy' / 'second.pdf'))
r = run('build', '--root', str(lib), '--db', db)
assert 'duplicate content, not indexed twice' in r.text
assert one(db, 'SELECT COUNT(*) FROM docs') == 1
assert one(db, 'SELECT COUNT(*) FROM pages') == 1
def test_a_missing_root_is_reported_and_is_not_a_reason_to_delete(lib, db):
make_pdf(lib / 'guide.pdf', ['content'])
run('build', '--root', str(lib), '--db', db)
r = run('build', '--root', str(lib / 'nope'), '--db', db)
assert 'root not found, skipped' in r.text
assert one(db, 'SELECT COUNT(*) FROM docs') == 1
def test_rebuild_starts_from_empty(lib, db):
make_pdf(lib / 'guide.pdf', ['content here'])
run('build', '--root', str(lib), '--db', db)
r = run('build', '--root', str(lib), '--db', db, '--rebuild')
assert 'dropped every table' in r.text
assert 'docs: 1 new' in r.text
assert one(db, 'SELECT COUNT(*) FROM docs') == 1
assert one(db, 'SELECT COUNT(*) FROM pages') == 1
def test_an_index_from_an_older_schema_is_wiped_rather_than_half_read(lib, db):
make_pdf(lib / 'guide.pdf', ['content here'])
run('build', '--root', str(lib), '--db', db)
con = sqlite3.connect(db)
con.execute("UPDATE meta SET value = '0' WHERE key = 'schema_version'")
con.commit()
con.close()
# search refuses; it does not report 0 hits against a schema it cannot read
r = run('search', 'content', '--db', db)
assert r.code == 1
assert 'another schema version' in r.text
assert '0 hits' not in r.text
r = run('build', '--root', str(lib), '--db', db)
assert 'rebuilding from scratch' in r.text
assert one(db, 'SELECT COUNT(*) FROM docs') == 1
# ---------------------------------------------------------------------------
# defaults / plumbing
# ---------------------------------------------------------------------------
def test_the_default_db_sits_inside_the_gitignored_library_folder():
assert libindex.DEFAULT_DB.replace('\\', '/').endswith(
'knowledge/library/index.sqlite')
def test_the_default_roots_are_the_two_document_drops():
got = [libindex.relpath(r) for r in libindex.DEFAULT_ROOTS]
assert got == ['knowledge/library', 'knowledge/MC']
def test_no_subcommand_prints_help_and_fails():
assert run().code == 1
def test_search_prints_the_expression_it_ran(shapes):
r = run('search', 'P316', '--db', shapes)
assert 'query: "P316"' in r.text