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 (?, ?)")
|
||||
try {
|
||||
insert.bindLong(1, 1L)
|
||||
insert.bindText(2, "внутренний домен траефик")
|
||||
insert.bindText(2, "Траефик внутри")
|
||||
insert.executeUpdate()
|
||||
} finally {
|
||||
insert.close()
|
||||
@@ -118,6 +118,46 @@ class CoreSmokeTest {
|
||||
} finally {
|
||||
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 {
|
||||
conn.close()
|
||||
file.delete()
|
||||
|
||||
Reference in New Issue
Block a user