Files
five/hbrdd/dbf/dbf_integration_test.go
Charles KWON OhJun 08c0ef13d4 feat: transparent MEMO read/write + documentation update
DBFArea auto-manages FPT memo files:
- Create/Open: auto-creates/opens FPT when memo fields exist
- PutValue: string on MEMO field auto-writes to FPT
- GetValue: MEMO field auto-reads from FPT, returns string
- Close: auto-closes FPT

Documentation: Value methods, MEMVAR, SET, ErrorBlock, MEMO
added to five-syntax-ko.md and five-syntax-en.md (+480 lines)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:32:07 +09:00

530 lines
13 KiB
Go

// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
// All rights reserved.
// Integration tests: create DBF with various field types,
// insert 100 records, verify read-back, test memo fields.
package dbf
import (
"five/hbrt"
"five/hbrdd"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"testing"
)
// TestDBFNTX_Create100 creates a DBFNTX-style DBF with 100 records.
func TestDBFNTX_Create100(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ntx_test")
drv := &DBFDriver{}
area, err := drv.Create(hbrdd.CreateParams{
Path: path,
Fields: []hbrdd.FieldInfo{
{Name: "ID", Type: 'N', Len: 10, Dec: 0},
{Name: "NAME", Type: 'C', Len: 30},
{Name: "SALARY", Type: 'N', Len: 12, Dec: 2},
{Name: "ACTIVE", Type: 'L', Len: 1},
{Name: "HIREDATE", Type: 'D', Len: 8},
},
})
if err != nil {
t.Fatal(err)
}
// Verify DBF version byte (NTX style = standard DBF3)
dbfArea := area.(*DBFArea)
if dbfArea.header.Version != VersionDBF3 {
t.Errorf("DBFNTX version = 0x%02X, want 0x%02X", dbfArea.header.Version, VersionDBF3)
}
// Insert 100 records
names := []string{"Kim", "Lee", "Park", "Choi", "Jung", "Kang", "Cho", "Yoon", "Jang", "Lim"}
for i := 1; i <= 100; i++ {
area.Append()
area.PutValue(0, hbrt.MakeInt(i))
area.PutValue(1, hbrt.MakeString(fmt.Sprintf("%s_%03d", names[i%10], i)))
area.PutValue(2, hbrt.MakeDouble(30000.0+float64(i)*500.50, 12, 2))
area.PutValue(3, hbrt.MakeBool(i%3 != 0))
area.PutValue(4, hbrt.MakeDate(dateToJulian(2020, (i%12)+1, (i%28)+1)))
area.Flush()
}
area.Close()
// Reopen and verify
area2, err := drv.Open(hbrdd.OpenParams{Path: path})
if err != nil {
t.Fatal(err)
}
defer area2.Close()
rc, _ := area2.RecCount()
if rc != 100 {
t.Fatalf("reccount = %d, want 100", rc)
}
// Verify field count and types
if area2.FieldCount() != 5 {
t.Fatalf("fieldcount = %d, want 5", area2.FieldCount())
}
// Verify record 1
area2.GoTo(1)
id, _ := area2.GetValue(0)
name, _ := area2.GetValue(1)
salary, _ := area2.GetValue(2)
active, _ := area2.GetValue(3)
hdate, _ := area2.GetValue(4)
if id.AsNumInt() != 1 {
t.Errorf("rec1 ID = %d, want 1", id.AsNumInt())
}
if name.AsString()[:7] != "Lee_001" {
t.Errorf("rec1 NAME = %q", name.AsString())
}
if math.Abs(salary.AsDouble()-30500.50) > 0.01 {
t.Errorf("rec1 SALARY = %f, want 30500.50", salary.AsDouble())
}
if !active.AsBool() {
t.Error("rec1 ACTIVE should be .T.")
}
if !hdate.IsDate() {
t.Error("rec1 HIREDATE should be date type")
}
// Verify record 50
area2.GoTo(50)
id, _ = area2.GetValue(0)
if id.AsNumInt() != 50 {
t.Errorf("rec50 ID = %d, want 50", id.AsNumInt())
}
// Verify record 100
area2.GoTo(100)
id, _ = area2.GetValue(0)
if id.AsNumInt() != 100 {
t.Errorf("rec100 ID = %d, want 100", id.AsNumInt())
}
// Verify sequential scan
area2.GoTop()
count := 0
for !area2.EOF() {
count++
area2.Skip(1)
}
if count != 100 {
t.Errorf("scan count = %d, want 100", count)
}
// Verify backward scan
area2.GoBottom()
count = 0
for !area2.BOF() {
count++
area2.Skip(-1)
}
if count != 100 {
t.Errorf("backward scan = %d, want 100", count)
}
// Verify file extension
if _, err := os.Stat(path + ".dbf"); err != nil {
t.Error("DBF file should exist")
}
}
// TestDBFCDX_Create100 creates a DBFCDX-style DBF (FPT version byte).
func TestDBFCDX_Create100(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cdx_test")
// Create with FPT version (CDX style)
drv := &DBFDriver{}
area, err := drv.Create(hbrdd.CreateParams{
Path: path,
Fields: []hbrdd.FieldInfo{
{Name: "CODE", Type: 'C', Len: 10},
{Name: "AMOUNT", Type: 'N', Len: 15, Dec: 4},
{Name: "FLAG", Type: 'L', Len: 1},
{Name: "TXDATE", Type: 'D', Len: 8},
},
})
if err != nil {
t.Fatal(err)
}
// Insert 100 records
for i := 1; i <= 100; i++ {
area.Append()
area.PutValue(0, hbrt.MakeString(fmt.Sprintf("TX%06d", i)))
area.PutValue(1, hbrt.MakeDouble(float64(i)*123.4567, 15, 4))
area.PutValue(2, hbrt.MakeBool(i%2 == 0))
area.PutValue(3, hbrt.MakeDate(dateToJulian(2025, (i%12)+1, (i%28)+1)))
area.Flush()
}
area.Close()
// Reopen and verify
area2, err := drv.Open(hbrdd.OpenParams{Path: path})
if err != nil {
t.Fatal(err)
}
defer area2.Close()
rc, _ := area2.RecCount()
if rc != 100 {
t.Fatalf("reccount = %d, want 100", rc)
}
// Verify record 1
area2.GoTo(1)
code, _ := area2.GetValue(0)
if code.AsString()[:8] != "TX000001" {
t.Errorf("rec1 CODE = %q", code.AsString())
}
amount, _ := area2.GetValue(1)
if math.Abs(amount.AsDouble()-123.4567) > 0.001 {
t.Errorf("rec1 AMOUNT = %f", amount.AsDouble())
}
// Verify record 100
area2.GoTo(100)
code, _ = area2.GetValue(0)
if code.AsString()[:8] != "TX000100" {
t.Errorf("rec100 CODE = %q", code.AsString())
}
}
// TestDBF_MemoField tests transparent memo field read/write with auto-managed FPT.
func TestDBF_MemoField(t *testing.T) {
dir := t.TempDir()
dbfPath := filepath.Join(dir, "memo_test")
fptPath := dbfPath + ".fpt"
// Create DBF with memo field — FPT auto-created
drv := &DBFDriver{}
area, err := drv.Create(hbrdd.CreateParams{
Path: dbfPath,
Fields: []hbrdd.FieldInfo{
{Name: "ID", Type: 'N', Len: 5},
{Name: "TITLE", Type: 'C', Len: 50},
{Name: "NOTES", Type: 'M', Len: 10},
},
})
if err != nil {
t.Fatal(err)
}
// Insert 100 records with memo data (transparent write)
for i := 1; i <= 100; i++ {
area.Append()
area.PutValue(0, hbrt.MakeInt(i))
area.PutValue(1, hbrt.MakeString(fmt.Sprintf("Item %d", i)))
memoText := fmt.Sprintf("This is memo #%d with some longer text for testing. Record number: %d. "+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt.", i, i)
area.PutValue(2, hbrt.MakeString(memoText))
area.Flush()
}
area.Close()
// Verify FPT file exists
if _, err := os.Stat(fptPath); err != nil {
t.Error("FPT memo file should exist")
}
// Verify no DBT file
dbtPath := dbfPath + ".dbt"
if _, err := os.Stat(dbtPath); err == nil {
t.Error("DBT file should NOT exist for FPT")
}
// Reopen and verify (transparent read)
area2, err := drv.Open(hbrdd.OpenParams{Path: dbfPath})
if err != nil {
t.Fatal(err)
}
defer area2.Close()
rc, _ := area2.RecCount()
if rc != 100 {
t.Fatalf("reccount = %d, want 100", rc)
}
// Read and verify memo for record 1
area2.GoTo(1)
v, _ := area2.GetValue(2)
memoStr := v.AsString()
if len(memoStr) < 10 {
t.Errorf("rec1 memo too short: %d bytes", len(memoStr))
}
if memoStr[:16] != "This is memo #1 " {
t.Errorf("rec1 memo start = %q", memoStr[:20])
}
// Read and verify memo for record 50
area2.GoTo(50)
v, _ = area2.GetValue(2)
memoStr = v.AsString()
if memoStr[:17] != "This is memo #50 " {
t.Errorf("rec50 memo start = %q", memoStr[:20])
}
// Read and verify memo for record 100
area2.GoTo(100)
v, _ = area2.GetValue(2)
memoStr = v.AsString()
if memoStr[:18] != "This is memo #100 " {
t.Errorf("rec100 memo start = %q", memoStr[:20])
}
t.Logf("Memo test: 100 records with FPT memo verified (transparent API)")
}
// TestDBFCDX_MemoIsFPT confirms that DBFCDX uses FPT format, not DBT.
func TestDBFCDX_MemoIsFPT(t *testing.T) {
dir := t.TempDir()
dbfPath := filepath.Join(dir, "cdx_memo_test")
fptPath := dbfPath + ".fpt"
dbtPath := dbfPath + ".dbt"
// Create FPT (what DBFCDX would use)
fpt, err := CreateFPT(fptPath, 64)
if err != nil {
t.Fatal(err)
}
// Write some memo data
block1, _ := fpt.WriteMemo([]byte("FPT memo data for DBFCDX"))
block2, _ := fpt.WriteMemo([]byte("Second memo entry"))
fpt.Close()
// Verify FPT exists
if _, err := os.Stat(fptPath); err != nil {
t.Fatal("FPT file must exist for DBFCDX")
}
// Verify DBT does NOT exist
if _, err := os.Stat(dbtPath); err == nil {
t.Fatal("DBT file must NOT exist for DBFCDX (uses FPT)")
}
// Reopen and read back
fpt2, err := OpenFPT(fptPath)
if err != nil {
t.Fatal(err)
}
defer fpt2.Close()
data1, _ := fpt2.ReadMemo(block1)
if string(data1) != "FPT memo data for DBFCDX" {
t.Errorf("memo 1 = %q", string(data1))
}
data2, _ := fpt2.ReadMemo(block2)
if string(data2) != "Second memo entry" {
t.Errorf("memo 2 = %q", string(data2))
}
// Verify Big-Endian header (FPT characteristic)
f, _ := os.Open(fptPath)
hdr := make([]byte, 8)
f.ReadAt(hdr, 0)
f.Close()
// FPT header: nextBlock(4 BE), reserved(2), blockSize(2 BE)
// Block size should be 64 in Big-Endian
blockSizeBE := uint16(hdr[6])<<8 | uint16(hdr[7])
if blockSizeBE != 64 {
t.Errorf("FPT block size (BE) = %d, want 64", blockSizeBE)
}
t.Logf("DBFCDX memo format: FPT confirmed (Big-Endian header, block size %d)", blockSizeBE)
t.Logf("Block 1 at %d, Block 2 at %d", block1, block2)
}
// TestDBF_AllFieldTypes tests all common DBF field types.
func TestDBF_AllFieldTypes(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "alltypes")
drv := &DBFDriver{}
area, err := drv.Create(hbrdd.CreateParams{
Path: path,
Fields: []hbrdd.FieldInfo{
{Name: "CHAR", Type: 'C', Len: 20},
{Name: "NUM", Type: 'N', Len: 12, Dec: 2},
{Name: "INT", Type: 'N', Len: 10, Dec: 0},
{Name: "LOGIC", Type: 'L', Len: 1},
{Name: "DATE", Type: 'D', Len: 8},
{Name: "MEMO", Type: 'M', Len: 10},
},
})
if err != nil {
t.Fatal(err)
}
// Insert test records
area.Append()
area.PutValue(0, hbrt.MakeString("Hello World"))
area.PutValue(1, hbrt.MakeDouble(-12345.67, 12, 2))
area.PutValue(2, hbrt.MakeInt(999999))
area.PutValue(3, hbrt.MakeBool(true))
area.PutValue(4, hbrt.MakeDate(dateToJulian(2026, 3, 28)))
area.PutValue(5, hbrt.MakeString("Memo for rec1")) // memo text
area.Flush()
area.Append()
area.PutValue(0, hbrt.MakeString("")) // empty string
area.PutValue(1, hbrt.MakeDouble(0.0, 12, 2)) // zero
area.PutValue(2, hbrt.MakeInt(-1)) // negative
area.PutValue(3, hbrt.MakeBool(false))
area.PutValue(4, hbrt.MakeDate(0)) // empty date
area.PutValue(5, hbrt.MakeString("")) // empty memo
area.Flush()
area.Close()
// Reopen and verify
area2, err := drv.Open(hbrdd.OpenParams{Path: path})
if err != nil {
t.Fatal(err)
}
defer area2.Close()
// Record 1
area2.GoTo(1)
v, _ := area2.GetValue(0)
if v.AsString()[:11] != "Hello World" {
t.Errorf("CHAR = %q", v.AsString())
}
v, _ = area2.GetValue(1)
if math.Abs(v.AsDouble()-(-12345.67)) > 0.01 {
t.Errorf("NUM = %f, want -12345.67", v.AsDouble())
}
v, _ = area2.GetValue(2)
if v.AsNumInt() != 999999 {
t.Errorf("INT = %d, want 999999", v.AsNumInt())
}
v, _ = area2.GetValue(3)
if !v.AsBool() {
t.Error("LOGIC should be TRUE")
}
v, _ = area2.GetValue(4)
y, m, d := julianToDate(v.AsJulian())
if y != 2026 || m != 3 || d != 28 {
t.Errorf("DATE = %d-%d-%d, want 2026-3-28", y, m, d)
}
// Record 2 — edge cases
area2.GoTo(2)
v, _ = area2.GetValue(1)
if v.AsDouble() != 0.0 {
t.Errorf("zero NUM = %f", v.AsDouble())
}
v, _ = area2.GetValue(2)
if v.AsNumInt() != -1 {
t.Errorf("negative INT = %d", v.AsNumInt())
}
v, _ = area2.GetValue(3)
if v.AsBool() {
t.Error("LOGIC should be FALSE")
}
v, _ = area2.GetValue(4)
if v.AsJulian() != 0 {
t.Error("empty date should be julian 0")
}
t.Log("All field types test passed")
}
// TestDBF_TransparentMemo tests transparent MEMO read/write through PutValue/GetValue.
// User writes a string → DBF auto-writes to FPT → user reads back a string.
func TestDBF_TransparentMemo(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "memo_transparent")
drv := &DBFDriver{}
area, err := drv.Create(hbrdd.CreateParams{
Path: path,
Fields: []hbrdd.FieldInfo{
{Name: "NAME", Type: 'C', Len: 20},
{Name: "NOTES", Type: 'M', Len: 10},
},
})
if err != nil {
t.Fatal(err)
}
// Verify FPT was auto-created
dbfArea := area.(*DBFArea)
if dbfArea.MemoFile() == nil {
t.Fatal("FPT should be auto-created for MEMO fields")
}
// Record 1: normal memo
area.Append()
area.PutValue(0, hbrt.MakeString("Alice"))
area.PutValue(1, hbrt.MakeString("This is Alice's memo with some longer text for testing."))
area.Flush()
// Record 2: empty memo
area.Append()
area.PutValue(0, hbrt.MakeString("Bob"))
area.PutValue(1, hbrt.MakeString(""))
area.Flush()
// Record 3: large memo
largeMemo := strings.Repeat("Large memo data. ", 100) // ~1700 bytes
area.Append()
area.PutValue(0, hbrt.MakeString("Charlie"))
area.PutValue(1, hbrt.MakeString(largeMemo))
area.Flush()
area.Close()
// Reopen and verify transparent read
area2, err := drv.Open(hbrdd.OpenParams{Path: path})
if err != nil {
t.Fatal(err)
}
defer area2.Close()
// Verify FPT auto-opened
if area2.(*DBFArea).MemoFile() == nil {
t.Fatal("FPT should be auto-opened on Open")
}
// Record 1
area2.GoTo(1)
v, _ := area2.GetValue(0)
if strings.TrimSpace(v.AsString()) != "Alice" {
t.Errorf("rec1 NAME = %q", v.AsString())
}
v, _ = area2.GetValue(1)
if v.AsString() != "This is Alice's memo with some longer text for testing." {
t.Errorf("rec1 NOTES = %q", v.AsString())
}
// Record 2: empty memo
area2.GoTo(2)
v, _ = area2.GetValue(1)
if v.AsString() != "" {
t.Errorf("rec2 NOTES should be empty, got %q", v.AsString())
}
// Record 3: large memo
area2.GoTo(3)
v, _ = area2.GetValue(1)
if v.AsString() != largeMemo {
t.Errorf("rec3 NOTES length = %d, want %d", len(v.AsString()), len(largeMemo))
}
t.Logf("Transparent MEMO: 3 records verified (normal/empty/large)")
}