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>
530 lines
13 KiB
Go
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)")
|
|
}
|