core: fts5-тест проверяет регистронезависимость кириллицы
This commit is contained in:
@@ -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/**), только тестовый файл.
|
||||||
|
- Не менять другие тесты, не переименовывать их.
|
||||||
|
- Не добавлять зависимости.
|
||||||
@@ -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) и любые другие.
|
||||||
|
- Не добавлять зависимости.
|
||||||
|
- Не создавать другие файлы.
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
|
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user