mirror of
https://github.com/tiennm99/thptqg.git
synced 2026-10-15 08:17:25 +00:00
refactor: comments state current behavior, not project history
Comments across the tree justified the code by pointing at a Rust implementation that is no longer in the repository, citing files and line numbers (config.rs:132, schema.rs:26-54, reader.rs:42) that cannot be opened, plus crates and datasets that are equally gone. A reader could not check any of it. Every invariant those comments carried is kept and restated so it stands on its own: the bytewise sort that decides which row survives a duplicate exam number, the literal U+0300..U+036F range that must match the site's toAscii, the trailing space in "SINH ", the BIFF and shared-string corrections, the VACUUM-after-COMMIT rule, the deploy-from-main guard. The reader's contract is now anchored to the frozen oracle in parser/testdata, which still exists and is still checked, rather than to the tool that originally produced it. TestDDLMatchesRust becomes TestDDLIsFrozen: it compares against a copy of the DDL inside the test and never read schema.rs, so both the name and the failure message were misleading. ToAscii no longer claims the d-replacement must precede lowercasing. Both cases map to 'd' and ToLower runs last, so the order has no effect.
This commit is contained in:
1 parent
c988bfafcf
commit
933adf70c9
43 files changed
+392
-515
No files matched your search
@@ -9,17 +9,18 @@ import (
|
||||
"github.com/tiennm99/thptqg/parser/internal/transform"
|
||||
)
|
||||
|
||||
// TestToAsciiAgainstRustOutput cross-checks ToAscii against Rust on real data.
|
||||
// TestToAsciiAgainstRustOutput cross-checks ToAscii against a reference database
|
||||
// on real data.
|
||||
//
|
||||
// A Rust-built database is its own oracle: every row carries ho_ten alongside
|
||||
// the ho_ten_ascii that Rust derived from it, so the whole table is a
|
||||
// name -> expected-slug corpus far broader than the 20 hand-picked unit cases.
|
||||
//
|
||||
// Point it at a Rust-built database:
|
||||
// Such a database is its own oracle: every row carries ho_ten alongside the
|
||||
// ho_ten_ascii derived from it, so the whole table is a name -> expected-slug
|
||||
// corpus far broader than the 20 hand-picked unit cases.
|
||||
//
|
||||
// GO_PARSER_RUST_DB=/tmp/rust-2016.db go test ./internal/transform/
|
||||
//
|
||||
// Skips when unset, so the default suite stays hermetic.
|
||||
// Skips when unset, so the default suite stays hermetic. The reference parser is
|
||||
// gone from the tree, so no new oracle database can be produced and this always
|
||||
// skips today.
|
||||
func TestToAsciiAgainstRustOutput(t *testing.T) {
|
||||
path := os.Getenv("GO_PARSER_RUST_DB")
|
||||
if path == "" {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
// Package transform performs row transformation: ASCII normalisation, score
|
||||
// regex parsing, and validation — a port of parser/src/transform.rs.
|
||||
//
|
||||
// ToAscii replicates build-lib.js toAscii exactly:
|
||||
//
|
||||
// str.normalize("NFD").replace(/[̀-ͯ]/g,"").replace(/đ/gi,"d").toLowerCase()
|
||||
// regex parsing, and validation.
|
||||
package transform
|
||||
|
||||
import (
|
||||
@@ -26,14 +22,11 @@ import (
|
||||
// 3. Replace đ/Đ with d (NFD does not decompose them)
|
||||
// 4. Lowercase
|
||||
//
|
||||
// Step 2 filters a LITERAL CODEPOINT RANGE, not a Unicode category. The inline
|
||||
// comment at transform.rs:53 says "Unicode category M", but the code at :56 is
|
||||
// the specification and it checks '\u{0300}'..='\u{036f}'. unicode.Is(unicode.Mn, r)
|
||||
// is strictly broader and would strip marks Rust keeps, silently changing
|
||||
// ho_ten_ascii — the column the site's accent-insensitive search runs on.
|
||||
//
|
||||
// Step order matters and mirrors transform.rs:54-63: the đ/Đ replacement happens
|
||||
// before lowercasing.
|
||||
// Step 2 filters a LITERAL CODEPOINT RANGE, not a Unicode category.
|
||||
// unicode.Is(unicode.Mn, r) is strictly broader and would strip marks this keeps,
|
||||
// silently changing ho_ten_ascii — the column the site's accent-insensitive
|
||||
// search runs on. That search normalises the query with the same four steps in
|
||||
// web/src/App.jsx toAscii; the two must produce identical output or lookups miss.
|
||||
func ToAscii(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
@@ -44,7 +37,7 @@ func ToAscii(s string) string {
|
||||
b.Grow(len(decomposed))
|
||||
for _, r := range decomposed {
|
||||
if r >= 0x0300 && r <= 0x036F {
|
||||
continue // combining mark, in the range Rust drops
|
||||
continue // combining mark inside the dropped range
|
||||
}
|
||||
switch r {
|
||||
case 'đ', 'Đ':
|
||||
@@ -73,23 +66,22 @@ type ParsedRow struct {
|
||||
|
||||
// SkipReason says why a row was skipped, or SkipNone when it passed.
|
||||
//
|
||||
// The distinction is load-bearing for the printed counters, and the two
|
||||
// non-blank reasons are counted as source rows while BlankRow is not — but note
|
||||
// that split lives in the CALLER, not here. parser/src/main.rs has two call
|
||||
// sites with opposite outcomes for BlankRow: at :135-137 it returns before the
|
||||
// counter at :140, while at :151 it matches Err(BlankRow) => {} and falls
|
||||
// through to transform and insert. The build loop must reproduce both.
|
||||
// The distinction is load-bearing for the printed counters: the two non-blank
|
||||
// reasons count as source rows while SkipBlankRow does not. That split lives in
|
||||
// the CALLER, not here — the build loop drops blank rows before the source-row
|
||||
// counter and, at the later switch, lets SkipBlankRow fall through to transform
|
||||
// and insert. Both call sites have to stay as they are.
|
||||
type SkipReason int
|
||||
|
||||
const (
|
||||
SkipNone SkipReason = iota
|
||||
// SkipBlankRow: row is fully blank (2017-old2 only, checked before the
|
||||
// source-row counter).
|
||||
// SkipBlankRow: row is fully blank, and strip_blank_rows is on. Checked
|
||||
// before the source-row counter.
|
||||
SkipBlankRow
|
||||
// SkipEmptyField: so_bao_danh or ho_ten empty/missing.
|
||||
SkipEmptyField
|
||||
// SkipNonNumericSbd: so_bao_danh contains non-digit characters
|
||||
// (2017-old / 2017-old2 guard).
|
||||
// SkipNonNumericSbd: so_bao_danh contains non-digit characters, and
|
||||
// require_numeric_sbd is on.
|
||||
SkipNonNumericSbd
|
||||
)
|
||||
|
||||
@@ -109,10 +101,10 @@ func (s SkipReason) String() string {
|
||||
|
||||
// ValidateRow checks a row against the dataset's validation rules.
|
||||
//
|
||||
// Signature mirrors transform.rs:101-107, taking stripBlankRows and allBlank
|
||||
// explicitly; a shorter signature could not express both blank-row paths.
|
||||
// stripBlankRows and allBlank are passed explicitly because a shorter signature
|
||||
// could not express both blank-row paths.
|
||||
func ValidateRow(hoTen, soBaoDanh string, cfg *config.ValidationCfg, stripBlankRows, allBlank bool) SkipReason {
|
||||
// 2017-old2: skip fully blank rows BEFORE counting source rows.
|
||||
// Skip fully blank rows BEFORE counting source rows.
|
||||
if stripBlankRows && allBlank {
|
||||
return SkipBlankRow
|
||||
}
|
||||
@@ -128,11 +120,11 @@ func ValidateRow(hoTen, soBaoDanh string, cfg *config.ValidationCfg, stripBlankR
|
||||
return SkipNone
|
||||
}
|
||||
|
||||
// allASCIIDigits mirrors Rust's chars().all(|c| c.is_ascii_digit()).
|
||||
// allASCIIDigits reports whether every byte of s is an ASCII digit.
|
||||
//
|
||||
// Deliberately not strconv.Atoi: Atoi accepts a leading sign, so "+123" would
|
||||
// pass a check Rust rejects. Empty input returns true, matching Rust's all() on
|
||||
// an empty iterator — the empty case is caught earlier by RequireNonemptySbd.
|
||||
// pass a check that must reject it. Empty input returns true; the empty case is
|
||||
// caught earlier by RequireNonemptySbd.
|
||||
func allASCIIDigits(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
@@ -146,7 +138,7 @@ func allASCIIDigits(s string) bool {
|
||||
//
|
||||
// Every one of the 16 patterns runs against every dataset; a subject absent from
|
||||
// a given exam year never matches and stays NULL. Matching is unanchored
|
||||
// first-match, like Rust's Regex::captures.
|
||||
// first-match.
|
||||
func ParseScores(diemThi string) map[string]float64 {
|
||||
out := make(map[string]float64)
|
||||
if diemThi == "" {
|
||||
@@ -161,8 +153,8 @@ func ParseScores(diemThi string) map[string]float64 {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Unreachable given the pattern shape, but kept for parity with
|
||||
// transform.rs:136 (is_finite).
|
||||
// Unreachable given the pattern shape; kept so a widened pattern can
|
||||
// never write Inf or NaN into a score column.
|
||||
if math.IsInf(v, 0) || math.IsNaN(v) {
|
||||
continue
|
||||
}
|
||||
@@ -172,21 +164,20 @@ func ParseScores(diemThi string) map[string]float64 {
|
||||
}
|
||||
|
||||
// ErrNoColumns is returned when the fixed-column path is used on a config that
|
||||
// has no columns: mapping. Rust panics here via .expect() (transform.rs:162);
|
||||
// returning an error is the Go-idiomatic equivalent and is unreachable in
|
||||
// practice, since only the non-2016 path calls this.
|
||||
// has no columns: mapping. Unreachable in practice, since only the non-2016 path
|
||||
// calls TransformRow.
|
||||
var ErrNoColumns = errors.New("transform: config has no columns mapping")
|
||||
|
||||
// TransformRow extracts one row into a ParsedRow using fixed column indices,
|
||||
// the 2017-family path. 2016 uses runtime format detection instead.
|
||||
// the 2017 path. 2016 uses runtime format detection instead.
|
||||
func TransformRow(raw []reader.Cell, cfg *config.DatasetConfig) (*ParsedRow, error) {
|
||||
cols := cfg.Columns
|
||||
if cols == nil {
|
||||
return nil, ErrNoColumns
|
||||
}
|
||||
|
||||
// Trimmed accessor, mirroring the closure at transform.rs:163-167. Out-of-range
|
||||
// indices yield "" rather than an error, matching unwrap_or_default().
|
||||
// Trimmed accessor. Out-of-range indices yield "" rather than an error, so a
|
||||
// short row produces empty fields instead of failing.
|
||||
get := func(idx int) string {
|
||||
if idx < 0 || idx >= len(raw) {
|
||||
return ""
|
||||
@@ -198,9 +189,9 @@ func TransformRow(raw []reader.Cell, cfg *config.DatasetConfig) (*ParsedRow, err
|
||||
ngaySinh := get(cols.NgaySinh)
|
||||
soBaoDanh := get(cols.SoBaoDanh)
|
||||
|
||||
// diem_thi is read WITHOUT trimming (transform.rs:172-175), unlike the three
|
||||
// fields above. Harmless because the score patterns are unanchored, but it is
|
||||
// the shipped behaviour — do not "tidy" it.
|
||||
// diem_thi is read WITHOUT trimming, unlike the three fields above. Harmless
|
||||
// because the score patterns are unanchored, but it is the shipped behaviour
|
||||
// — do not "tidy" it.
|
||||
diemThi := ""
|
||||
if cols.DiemThi >= 0 && cols.DiemThi < len(raw) {
|
||||
diemThi = raw[cols.DiemThi].Str
|
||||
|
||||
@@ -7,12 +7,7 @@ import (
|
||||
"github.com/tiennm99/thptqg/parser/internal/reader"
|
||||
)
|
||||
|
||||
// Ports every test in parser/src/transform.rs's test module (:201-409) — 29 in
|
||||
// total, not the 20 in the :213-315 range, which covers only ToAscii. The nine
|
||||
// outside that range are the ParseScores and ValidateRow cases, i.e. exactly the
|
||||
// behaviours this package's traps concern.
|
||||
|
||||
// --- ToAscii: the 20 cases at transform.rs:213-315 ---
|
||||
// --- ToAscii ---
|
||||
|
||||
func TestToAscii(t *testing.T) {
|
||||
cases := []struct{ name, in, want string }{
|
||||
@@ -47,10 +42,9 @@ func TestToAscii(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestToAsciiUsesLiteralRangeNotUnicodeMn guards the highest-value trap in this
|
||||
// package. transform.rs:56 filters the literal range U+0300..U+036F; the inline
|
||||
// comment at :53 calls it "Unicode category M", but the code is the spec.
|
||||
// unicode.Mn is strictly broader, so using it would strip marks Rust keeps.
|
||||
// U+0654 (ARABIC HAMZA ABOVE) is in Mn but outside the range: Rust keeps it.
|
||||
// package: ToAscii drops the literal range U+0300..U+036F, not Unicode category
|
||||
// Mn, which is strictly broader. U+0654 (ARABIC HAMZA ABOVE) is in Mn but outside
|
||||
// the range, so it must survive.
|
||||
func TestToAsciiUsesLiteralRangeNotUnicodeMn(t *testing.T) {
|
||||
const in = "aٔb"
|
||||
if got := ToAscii(in); got != in {
|
||||
@@ -76,7 +70,7 @@ func TestToAsciiDStrokeIndependentOfNFD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- ParseScores: transform.rs:323, :332, :342 ---
|
||||
// --- ParseScores ---
|
||||
|
||||
func TestParseScoresSingle(t *testing.T) {
|
||||
s := ParseScores("Toán: 8.5")
|
||||
@@ -115,7 +109,7 @@ func TestParseScoresRealCellShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- ValidateRow: transform.rs:359, :365, :374, :383, :393, :400 ---
|
||||
// --- ValidateRow ---
|
||||
|
||||
func defaultValidation() *config.ValidationCfg {
|
||||
return &config.ValidationCfg{
|
||||
@@ -165,9 +159,8 @@ func TestValidateBlankRowSkipped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateNumericSbdIsDigitScanNotAtoi: Rust uses chars().all(is_ascii_digit),
|
||||
// which strconv.Atoi does not reproduce — Atoi accepts a leading sign, and would
|
||||
// wrongly admit "+123".
|
||||
// TestValidateNumericSbdIsDigitScanNotAtoi: the check is a digit scan, not
|
||||
// strconv.Atoi — Atoi accepts a leading sign and would wrongly admit "+123".
|
||||
func TestValidateNumericSbdIsDigitScanNotAtoi(t *testing.T) {
|
||||
v := defaultValidation()
|
||||
v.RequireNumericSbd = true
|
||||
@@ -179,7 +172,7 @@ func TestValidateNumericSbdIsDigitScanNotAtoi(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestValidateBlankRowOnlyWhenStripEnabled: with strip_blank_rows false, an
|
||||
// all-blank row falls through to the empty-field checks instead (transform.rs:109).
|
||||
// all-blank row falls through to the empty-field checks instead.
|
||||
func TestValidateBlankRowOnlyWhenStripEnabled(t *testing.T) {
|
||||
if r := ValidateRow("", "", defaultValidation(), false, true); r != SkipEmptyField {
|
||||
t.Errorf("got %v, want SkipEmptyField when strip_blank_rows is off", r)
|
||||
@@ -227,7 +220,6 @@ func TestTransformRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransformRowEmptyNgaySinhBecomesNil ports transform.rs:179-183.
|
||||
func TestTransformRowEmptyNgaySinhBecomesNil(t *testing.T) {
|
||||
got, err := TransformRow(cells("A", "", "1", ""), fixedColumnCfg())
|
||||
if err != nil {
|
||||
@@ -238,9 +230,8 @@ func TestTransformRowEmptyNgaySinhBecomesNil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransformRowShortRowYieldsEmptyFields ports the unwrap_or_default()
|
||||
// behaviour at transform.rs:163-176: a row shorter than the configured indices
|
||||
// yields empty strings rather than an error.
|
||||
// TestTransformRowShortRowYieldsEmptyFields: a row shorter than the configured
|
||||
// indices yields empty strings rather than an error.
|
||||
func TestTransformRowShortRowYieldsEmptyFields(t *testing.T) {
|
||||
got, err := TransformRow(cells("OnlyName"), fixedColumnCfg())
|
||||
if err != nil {
|
||||
@@ -252,8 +243,8 @@ func TestTransformRowShortRowYieldsEmptyFields(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestTransformRowDiemThiIsNotTrimmed pins an asymmetry that is easy to
|
||||
// "tidy away": ho_ten, ngay_sinh and so_bao_danh are trimmed through the closure
|
||||
// at transform.rs:164-168, but diem_thi is read raw at :172-175.
|
||||
// "tidy away": ho_ten, ngay_sinh and so_bao_danh are trimmed, but diem_thi is
|
||||
// read raw.
|
||||
func TestTransformRowDiemThiIsNotTrimmed(t *testing.T) {
|
||||
row := cells(" A ", " 01/01/2000 ", " 123 ", " Toán: 5 ")
|
||||
got, err := TransformRow(row, fixedColumnCfg())
|
||||
@@ -273,8 +264,7 @@ func TestTransformRowDiemThiIsNotTrimmed(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestTransformRowRequiresColumns: the fixed-column path is only reachable when
|
||||
// the config has a columns: mapping. Rust panics via .expect() (transform.rs:162);
|
||||
// Go returns an error instead.
|
||||
// the config has a columns: mapping, and must error rather than guess otherwise.
|
||||
func TestTransformRowRequiresColumns(t *testing.T) {
|
||||
cfg := &config.DatasetConfig{Validation: *defaultValidation()}
|
||||
if _, err := TransformRow(cells("A", "B", "C", "D"), cfg); err == nil {
|
||||
|
||||
Reference in new issue
Block a user