Files
thptqg/parser/internal/ingest/detect2016_test.go
T
tiennm99 c359a0b444 refactor: one directory per pipeline stage, and an assembler to drive them
The repository now reads as the pipeline it is: crawler fetches, parser
converts, assembler verifies and publishes, with data/ and web/ as the stores
they hand work through. go-parser is renamed parser now that there is no other.

The assembler replaces build-db.js and assemble-site.js. It compiles the
parser, builds and verifies each database, compresses it, runs the Vite build
and assembles _site — one command, and the only place that knows the order.

It also closes a real hole: nothing previously asserted that a database reached
the site. An empty staging directory assembled happily, so every page rendered,
every query 404d and CI stayed green. The row-count and size guards could not
catch that, since they only run when a database was built at all.

Removing Node from the root forced the dataset list out of web/src/datasets.js,
which the assembler cannot import. datasets.json is now the registry both sides
read — JSON because Go and the browser both parse it without a dependency —
while presentation stays in the web app, keyed by id and cross-checked against
the registry so a half-added dataset fails instead of half-working.

Guards verified by making each one fail: a missing database, and an expected
row count one higher than the truth.
2026-08-13 22:50:05 +07:00

253 lines
8.7 KiB
Go

package ingest
import (
"testing"
"github.com/tiennm99/thptqg/parser/internal/reader"
)
// Ports the 11 tests in parser/src/format_detect_2016.rs, plus a guard per quirk.
//
// Phase 1 settled the Data -> string translation these fixtures need, so no
// guessing: Data::String(s) is s verbatim, Data::Float(8.0) renders "8" (Rust's
// f64 Display drops the .0, matching Go's FormatFloat(v,'f',-1,64)),
// Data::Float(8.5) renders "8.5", and Data::Empty is "" with IsEmpty true.
// --- header detection ---
func TestIsHeaderRow2016(t *testing.T) {
if !IsHeaderRow2016(cells("SBD", "HOTEN", "TOAN")) {
t.Error("SBD header not detected")
}
if !IsHeaderRow2016(cells("sobaodanh", "x")) {
t.Error("lowercase SOBAODANH not detected")
}
if IsHeaderRow2016(cells("Nguyen Van A", "01/01/2000")) {
t.Error("data row wrongly detected as header")
}
// The 2016 guard is len < 2, unlike the 2017 check's len < 3.
if IsHeaderRow2016(cells("SBD")) {
t.Error("single-cell row must not be a header")
}
if !IsHeaderRow2016(cells("SBD", "x")) {
t.Error("two-cell row with a header token must be a header")
}
}
// TestKnownHeadersHasTrailingSpaceToken pins the "SINH " literal. Trimming it
// would silently change which rows count as headers.
func TestKnownHeadersHasTrailingSpaceToken(t *testing.T) {
var found bool
for _, h := range KnownHeaders {
if h == "SINH " {
found = true
}
}
if !found {
t.Error(`KnownHeaders must contain "SINH " WITH its trailing space`)
}
if len(KnownHeaders) != 17 {
t.Errorf("KnownHeaders has %d tokens, want 17", len(KnownHeaders))
}
}
// --- format detection ---
func TestDetectFormatSeparateScores(t *testing.T) {
f := DetectFormat(cells("SBD", "HOTEN", "TOAN", "VAN"))
if f.Kind != FormatSeparateScores {
t.Errorf("Kind = %v, want FormatSeparateScores", f.Kind)
}
}
func TestDetectFormatMapped(t *testing.T) {
f := DetectFormat(cells("STT", "SOBAODANH", "HO_TEN", "NGAY_SINH", "TEN_CUMTHI", "GIOI_TINH", "DIEM_THI"))
if f.Kind != FormatMapped {
t.Fatalf("Kind = %v, want FormatMapped", f.Kind)
}
if f.Sbd != 1 || f.HoTen != 2 || f.DiemThi != 6 {
t.Errorf("indices sbd=%d ho_ten=%d diem_thi=%d", f.Sbd, f.HoTen, f.DiemThi)
}
if f.NgaySinh == nil || *f.NgaySinh != 3 || f.TenCumThi == nil || *f.TenCumThi != 4 || f.GioiTinh == nil || *f.GioiTinh != 5 {
t.Error("optional indices not resolved")
}
}
// TestDetectFormatMappedIsOrderIndependent: indices are resolved by name.
func TestDetectFormatMappedIsOrderIndependent(t *testing.T) {
f := DetectFormat(cells("DIEM_THI", "HO_TEN", "SBD"))
if f.Kind != FormatMapped || f.Sbd != 2 || f.DiemThi != 0 || f.HoTen != 1 {
t.Errorf("got %+v", f)
}
}
// TestDetectFormatMappedHoTenFallback ports the col-1 fallback
// (format_detect_2016.rs:132).
func TestDetectFormatMappedHoTenFallback(t *testing.T) {
f := DetectFormat(cells("SBD", "SOMETHING", "DIEM_THI"))
if f.Kind != FormatMapped {
t.Fatalf("Kind = %v, want FormatMapped", f.Kind)
}
if f.HoTen != 1 {
t.Errorf("ho_ten = %d, want fallback 1", f.HoTen)
}
}
// TestDetectFormatDefault: a header lacking SBD or DIEM_THI falls back to the
// positional layout.
func TestDetectFormatDefault(t *testing.T) {
f := DetectFormat(cells("A", "B", "C"))
if f.Kind != FormatDefault {
t.Errorf("Kind = %v, want FormatDefault", f.Kind)
}
if f.Sbd != 0 || f.HoTen != 1 || f.DiemThi != 5 {
t.Errorf("default indices wrong: %+v", f)
}
}
// --- row processing ---
func TestProcessSeparateScoresRow(t *testing.T) {
// 0=SBD 1=HOTEN 2=TOAN 3=VAN 4=LY 5=HOA 6=SINH 7=SU 8=DIA 9,10=NN 11=NN total
row := cells("1000", "Nguyễn Văn Đức", "8", "7.5", "", "6", "", "5", "", "", "", "9.25")
got := ProcessRow2016(row, Format{Kind: FormatSeparateScores})
if got == nil {
t.Fatal("row rejected")
}
if got.SoBaoDanh != "1000" || got.HoTenAscii != "nguyen van duc" {
t.Errorf("sbd=%q ascii=%q", got.SoBaoDanh, got.HoTenAscii)
}
want := map[string]float64{"toan": 8, "ngu_van": 7.5, "hoa_hoc": 6, "lich_su": 5, "tieng_anh": 9.25}
if len(got.Scores) != len(want) {
t.Errorf("scores = %v, want %v", got.Scores, want)
}
for k, v := range want {
if got.Scores[k] != v {
t.Errorf("%s = %v, want %v", k, got.Scores[k], v)
}
}
// These columns are structurally unreachable in this layout.
for _, absent := range []string{"tieng_phap", "tieng_duc", "tieng_nhat", "tieng_trung", "khtn", "khxh", "gdcd"} {
if _, ok := got.Scores[absent]; ok {
t.Errorf("%s must be unreachable in separate-scores", absent)
}
}
if got.NgaySinh != nil || got.TenCumThi != nil || got.GioiTinh != nil {
t.Error("ngay_sinh/ten_cum_thi/gioi_tinh are always nil in separate-scores")
}
}
// TestZeroScoreBecomesNull pins the JS falsy quirk: parseFloat(x) || null means
// a literal 0 is indistinguishable from "no score" (format_detect_2016.rs:165).
func TestZeroScoreBecomesNull(t *testing.T) {
row := cells("1000", "A", "0", "0.0", "1", "", "", "", "", "", "", "")
got := ProcessRow2016(row, Format{Kind: FormatSeparateScores})
if got == nil {
t.Fatal("row rejected")
}
if _, ok := got.Scores["toan"]; ok {
t.Error(`a "0" score must become NULL, not 0`)
}
if _, ok := got.Scores["ngu_van"]; ok {
t.Error(`a "0.0" score must become NULL, not 0`)
}
if got.Scores["vat_ly"] != 1 {
t.Error("a non-zero score must survive")
}
}
// TestGenderAllowlist ports format_detect_2016.rs:263-271 — exactly two values.
func TestGenderAllowlist(t *testing.T) {
f := defaultFormat()
for in, want := range map[string]string{"Nam": "Nam", "Nữ": "Nữ"} {
row := cells("1", "A", "", "", in, "")
got := ProcessRow2016(row, f)
if got == nil || got.GioiTinh == nil || *got.GioiTinh != want {
t.Errorf("gioi_tinh for %q not preserved", in)
}
}
for _, in := range []string{"nam", "NAM", "Unknown", "M", "F", "nữ", ""} {
row := cells("1", "A", "", "", in, "")
got := ProcessRow2016(row, f)
if got == nil {
t.Fatalf("row rejected for %q", in)
}
if got.GioiTinh != nil {
t.Errorf("gioi_tinh for %q = %q, want nil", in, *got.GioiTinh)
}
}
}
// TestLeakedHeaderRowSkipped ports format_detect_2016.rs:244-250.
func TestLeakedHeaderRowSkipped(t *testing.T) {
f := defaultFormat()
if ProcessRow2016(cells("SBD", "HO_TEN", "", "", "", ""), f) != nil {
t.Error("a repeated header row must be skipped")
}
if ProcessRow2016(cells("123", "SOBAODANH", "", "", "", ""), f) != nil {
t.Error("a row whose name cell is a header token must be skipped")
}
if ProcessRow2016(cells("123", "Nguyen Van A", "", "", "", ""), f) == nil {
t.Error("a genuine data row must not be skipped")
}
}
// TestMappedRowEmptyFieldsRejected: either identity field empty rejects the row.
func TestMappedRowEmptyFieldsRejected(t *testing.T) {
f := defaultFormat()
if ProcessRow2016(cells("", "A", "", "", "", ""), f) != nil {
t.Error("empty sbd must reject")
}
if ProcessRow2016(cells("1", "", "", "", "", ""), f) != nil {
t.Error("empty ho_ten must reject")
}
}
// TestMappedRowPopulates2016OnlyColumns: this is the only dataset that fills
// ten_cum_thi and gioi_tinh.
func TestMappedRowPopulates2016OnlyColumns(t *testing.T) {
row := cells("123", "Lê Văn Long", "01/01/1998", "Cụm thi số 1", "Nam", "Toán: 7.5 Tiếng Đức: 6")
got := ProcessRow2016(row, defaultFormat())
if got == nil {
t.Fatal("row rejected")
}
if got.NgaySinh == nil || *got.NgaySinh != "01/01/1998" {
t.Errorf("ngay_sinh = %v", got.NgaySinh)
}
if got.TenCumThi == nil || *got.TenCumThi != "Cụm thi số 1" {
t.Errorf("ten_cum_thi = %v", got.TenCumThi)
}
if got.Scores["toan"] != 7.5 || got.Scores["tieng_duc"] != 6 {
t.Errorf("scores = %v", got.Scores)
}
}
// TestDefaultIsMappedWithFixedIndices: FormatDefault must not be a separate code
// path (format_detect_2016.rs:295-306).
func TestDefaultIsMappedWithFixedIndices(t *testing.T) {
row := cells("123", "A", "01/01/2000", "Cluster", "Nữ", "Toán: 5")
viaDefault := ProcessRow2016(row, defaultFormat())
two, three, four := 2, 3, 4
viaMapped := ProcessRow2016(row, Format{
Kind: FormatMapped, Sbd: 0, HoTen: 1,
NgaySinh: &two, TenCumThi: &three, GioiTinh: &four, DiemThi: 5,
})
if viaDefault == nil || viaMapped == nil {
t.Fatal("row rejected")
}
if viaDefault.SoBaoDanh != viaMapped.SoBaoDanh ||
*viaDefault.TenCumThi != *viaMapped.TenCumThi ||
*viaDefault.GioiTinh != *viaMapped.GioiTinh ||
viaDefault.Scores["toan"] != viaMapped.Scores["toan"] {
t.Error("FormatDefault must behave identically to the equivalent FormatMapped")
}
}
// TestShortRowGuardIsSeparateFromValidation documents that rows under 2 cells
// are dropped by the loop before the counter (main.rs:351-353), not here.
func TestShortRowGuardIsSeparateFromValidation(t *testing.T) {
if got := ProcessRow2016([]reader.Cell{{Str: "123"}}, defaultFormat()); got != nil {
t.Error("a 1-cell row has no name and must be rejected")
}
}