core: fts5-тест проверяет регистронезависимость кириллицы

This commit is contained in:
2026-10-02 01:09:40 +03:00
parent 700508dc8e
commit d62a26f5b1
5 changed files with 201 additions and 1 deletions
+26
View File
@@ -0,0 +1,26 @@
Проект: /root/WORK/memo (Kotlin/JVM). Заказ крошечный: усилить ОДИН тест.
Проблема (доказана мутационной проверкой): тест `fts5FindsCyrillic` в
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt ПРОХОДИТ даже если токенизатор в тесте
заменить с `unicode61` на `ascii` — то есть тест не проверяет то, ради чего он написан
(регистронезависимый поиск по кириллице). Проверено на живом SQLite: с `ascii` строчный запрос
«траефик» НЕ находит строку «Траефик» (0 совпадений), с `unicode61` — находит.
Что сделать — ровно это, в файле memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt:
1. В тесте `fts5FindsCyrillic` (тело метода, сигнатуру и имя не менять):
- таблица создаётся с `tokenize='unicode61'` (оставить как есть);
- ВСТАВЛЯЕМОЕ значение сделать с заглавной буквой: `"Траефик внутри"` вместо `"внутренний домен траефик"`;
- после существующей проверки добавить ещё две проверки в том же тесте:
а) запрос СТРОЧНЫМИ `"траефик"` находит ровно 1 строку и её rowid == 1;
б) запрос ЗАГЛАВНЫМИ `"Траефик"` тоже находит ровно 1 строку и её rowid == 1.
- Проверки делать отдельным запросом каждым, не одним.
2. Ничего больше в файле не менять. Формулировки ассертов писать по-русски, как в файле.
3. Прогнать `./gradlew :memo-core:test --rerun-tasks` и добиться зелёного результата.
4. Закоммитить: `git add -A && git commit -m "core: fts5-тест проверяет регистронезависимость кириллицы"`.
СТРОГИЕ ЗАПРЕТЫ:
- Не выводить план текстом; сразу правь файл.
- Не трогать боевой код (memo-core/src/main/**), только тестовый файл.
- Не менять другие тесты, не переименовывать их.
- Не добавлять зависимости.
+47
View File
@@ -0,0 +1,47 @@
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по чанкеру markdown.
Создать РОВНО ОДИН новый файл: memo-core/src/main/kotlin/memo/core/Chunker.kt
и РОВНО ОДИН новый тестовый файл: memo-core/src/test/kotlin/memo/core/ChunkerTest.kt
## Chunker.kt
package memo.core
data class Chunk(
val heading: String, // текст заголовка без решёток, обрезанный по краям
val line: Int, // 1-based номер строки, ГДЕ СТОИТ ЗАГОЛОВОК; 1, если заголовка нет
val text: String, // текст раздела без строки-заголовка
val ord: Int, // порядковый номер чанка в файле, с 0
)
fun chunkMarkdown(text: String): List<Chunk>
Правила:
- Разделитель — строка, начинающаяся с одного, двух или трёх символов '#' и следующего за ними пробела
(заголовки '#'..'###'). Более длинные последовательности решёток заголовками НЕ считаются.
- Заголовок попадает в field heading, его строка — в field line. В text строки-заголовки не входят
(text обрезается по краям; пустые строки в начале и конце удаляются).
- Текст ДО первого заголовка даёт чанк с heading = "" и line = 1 (только если он непустой после обрезки).
- Пустые чанки (text пустой после trim) НЕ возвращаются.
- Боковые ограничения по размеру: если text длиннее 4000 символов, он режется на несколько чанков
по 4000 символов с перекрытием 600 символов (хвост предыдущего повторяется в начале следующего);
все части одного раздела имеют ОДИН И ТОТ ЖЕ heading и line, ord растёт.
- Никаких зависимостей; чистый Kotlin.
## ChunkerTest.kt — ровно 5 тестов, имена ровно такие
1. `splitsByHeadingLevels` — вход с '# A', '## B', '### C' → 3 чанка, heading = ["A","B","C"], line = [1,3,5], ord = [0,1,2].
2. `textBeforeFirstHeadingBecomesChunkWithEmptyHeading` — ввод «преамбула\n# Заголовок\nтело» → 2 чанка, первый: heading "»", line 1; второй heading "Заголовок".
3. `fourHashesIsNotAHeading` — строка '#### не заголовок' остаётся частью текста предыдущего раздела.
4. `emptySectionsAreDropped` — ввод с двумя заголовками подряд без текста между ними → 1 чанк.
5. `longSectionIsSplitWithOverlap` — раздел из 6000 символов 'а' → 2 чанка, длина первого 4000,
длина второго 2600, и последние 600 символов первого равны первым 600 символам второго (перекрытие).
После: `./gradlew :memo-core:test --rerun-tasks` — все тесты (4 старых + 5 новых) зелёные.
Коммит: `git add -A && git commit -m "core: чанкер markdown по заголовкам с перекрытием"`.
СТРОГИЕ ЗАПРЕТЫ:
- Не выводить план текстом; сразу создавай файлы.
- Не трогать существующие файлы (Embedder.kt, Db.kt, CoreSmokeTest.kt, build.gradle.kts) и любые другие.
- Не добавлять зависимости.
- Не создавать другие файлы.
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Мутационная проверка: ломаем боевой код и смотрим, падает ли нужный тест.
Формат e2e/mutations.tsv: file<TAB>old<TAB>new<TAB>must_fail_test
Прогон: python3 e2e/mutation_check.py --gradle ./gradlew
Выход 0 — все мутации убиты (тесты ловят поломку). 1 — есть выжившие (дыра в покрытии).
"""
import argparse
import glob
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
def run_tests(gradle: str) -> dict[str, str]:
"""Возвращает {test_name: status}, где status = PASS/FAIL."""
subprocess.run([gradle, "--quiet", "test", "--rerun-tasks"],
capture_output=True, text=True, timeout=1800)
status: dict[str, str] = {}
for f in glob.glob("**/build/test-results/test/*.xml", recursive=True):
try:
root = ET.parse(f).getroot()
except ET.ParseError:
continue
for tc in root.iter("testcase"):
failed = any(c.tag in ("failure", "error") for c in tc)
status[tc.get("name")] = "FAIL" if failed else "PASS"
return status
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--gradle", default="./gradlew")
ap.add_argument("--mutations", default="e2e/mutations.tsv")
a = ap.parse_args()
rows = []
for line in open(a.mutations, encoding="utf-8"):
line = line.rstrip("\n")
if not line or line.startswith("#"):
continue
f, old, new, must_fail = line.split("\t")
rows.append((f, old.replace("\\n", "\n"), new.replace("\\n", "\n"), must_fail))
print("=== базовая линия (без мутаций) ===")
base = run_tests(a.gradle)
print(f"тестов найдено: {len(base)}; упавших: {[k for k, v in base.items() if v == 'FAIL']}")
survivors = []
for f, old, new, must_fail in rows:
backup = f + ".bak"
shutil.copy2(f, backup)
try:
src = open(f, encoding="utf-8").read()
assert old in src, f"МУТАЦИЯ НЕ ПРИМЕНИЛАСЬ (нет фрагмента) в {f}: {old[:60]!r}"
open(f, "w", encoding="utf-8").write(src.replace(old, new, 1))
st = run_tests(a.gradle)
got = st.get(must_fail, "НЕ НАЙДЕН")
killed = got == "FAIL"
print(f"{'УБИТА ' if killed else 'ВЫЖИЛА'} | {must_fail} -> {got} | {f}: {old[:50]} -> {new[:30]}")
if not killed:
survivors.append((must_fail, f, old, new))
finally:
shutil.move(backup, f)
print("\n=== восстановление ===")
st = run_tests(a.gradle)
print(f"после отката упавших: {[k for k, v in st.items() if v == 'FAIL']}")
if survivors:
print("\nВЫЖИВШИЕ МУТАЦИИ (дыры в покрытии):")
for t, f, old, new in survivors:
print(f" - {t}: {f} `{old[:60]}` -> `{new[:40]}`")
return 1
print("\nВСЕ МУТАЦИИ УБИТЫ")
return 0
if __name__ == "__main__":
sys.exit(main())
+5
View File
@@ -0,0 +1,5 @@
# file<TAB>old<TAB>new<TAB>must_fail_test
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt CREATE VIRTUAL TABLE v USING vec0(embedding float[4]) CREATE VIRTUAL TABLE v USING vec0(embedding float[8]) vec0KnnRoundTrip
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt floatArrayOf(0.1f, 0.9f, 0.0f, 0.0f) floatArrayOf(0.0f, 0.0f, 0.0f, 1.0f) vec0KnnRoundTrip
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt tokenize='unicode61' tokenize='ascii' fts5FindsCyrillic
memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt "SELECT a FROM t" "SELECT a+1 FROM t" ksqliteSmoke
1 # file<TAB>old<TAB>new<TAB>must_fail_test
2 memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt CREATE VIRTUAL TABLE v USING vec0(embedding float[4]) CREATE VIRTUAL TABLE v USING vec0(embedding float[8]) vec0KnnRoundTrip
3 memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt floatArrayOf(0.1f, 0.9f, 0.0f, 0.0f) floatArrayOf(0.0f, 0.0f, 0.0f, 1.0f) vec0KnnRoundTrip
4 memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt tokenize='unicode61' tokenize='ascii' fts5FindsCyrillic
5 memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt SELECT a FROM t SELECT a+1 FROM t ksqliteSmoke
@@ -93,7 +93,7 @@ class CoreSmokeTest {
val insert = conn.prepare("INSERT INTO docs(rowid, text) VALUES (?, ?)") val insert = conn.prepare("INSERT INTO docs(rowid, text) VALUES (?, ?)")
try { try {
insert.bindLong(1, 1L) insert.bindLong(1, 1L)
insert.bindText(2, "внутренний домен траефик") insert.bindText(2, "Траефик внутри")
insert.executeUpdate() insert.executeUpdate()
} finally { } finally {
insert.close() insert.close()
@@ -118,6 +118,46 @@ class CoreSmokeTest {
} finally { } finally {
query.close() query.close()
} }
val queryLower = conn.prepare("SELECT rowid FROM docs WHERE docs MATCH ?")
try {
queryLower.bindText(1, "траефик")
val rs = queryLower.executeQuery()
try {
var hits = 0
var lastRowid = -1L
while (rs.next()) {
hits++
lastRowid = rs.getLong(0)!!
}
assertEquals(1, hits, "ожидалось ровно одно совпадение FTS5")
assertEquals(1L, lastRowid, "rowid должен быть 1")
} finally {
rs.close()
}
} finally {
queryLower.close()
}
val queryUpper = conn.prepare("SELECT rowid FROM docs WHERE docs MATCH ?")
try {
queryUpper.bindText(1, "Траефик")
val rs = queryUpper.executeQuery()
try {
var hits = 0
var lastRowid = -1L
while (rs.next()) {
hits++
lastRowid = rs.getLong(0)!!
}
assertEquals(1, hits, "ожидалось ровно одно совпадение FTS5")
assertEquals(1L, lastRowid, "rowid должен быть 1")
} finally {
rs.close()
}
} finally {
queryUpper.close()
}
} finally { } finally {
conn.close() conn.close()
file.delete() file.delete()