Files
MyGoNavi/internal/db/tdengine_applychanges_test.go
tianqijiuyun-latiao d13c153f5e feat(i18n): 收口数据库驱动多语言代码
- 提交 internal/db 多驱动用户可见错误与状态文案多语言化

- 补齐数据库驱动多语言测试与六语言 catalog

- 修复 frontend i18n catalog 的 4 个失效 guard
2026-06-22 10:09:45 +08:00

561 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build gonavi_full_drivers || gonavi_tdengine_driver
package db
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"io"
"os"
"reflect"
"strings"
"sync"
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/shared/i18n"
)
const tdengineRecordingDriverName = "gonavi_tdengine_recording"
var (
registerTDengineRecordingDriverOnce sync.Once
tdengineRecordingDriverMu sync.Mutex
tdengineRecordingDriverSeq int
tdengineRecordingDriverStates = map[string]*tdengineRecordingState{}
)
type tdengineRecordingState struct {
mu sync.Mutex
queries []string
execErr error
queryResults map[string]tdengineQueryResult
}
func (s *tdengineRecordingState) snapshotQueries() []string {
s.mu.Lock()
defer s.mu.Unlock()
queries := make([]string, len(s.queries))
copy(queries, s.queries)
return queries
}
type tdengineQueryResult struct {
columns []string
rows [][]driver.Value
err error
}
type tdengineRecordingDriver struct{}
func (tdengineRecordingDriver) Open(name string) (driver.Conn, error) {
tdengineRecordingDriverMu.Lock()
state := tdengineRecordingDriverStates[name]
tdengineRecordingDriverMu.Unlock()
if state == nil {
return nil, fmt.Errorf("recording state not found: %s", name)
}
return &tdengineRecordingConn{state: state}, nil
}
type tdengineRecordingConn struct {
state *tdengineRecordingState
}
func (c *tdengineRecordingConn) Prepare(query string) (driver.Stmt, error) {
return nil, fmt.Errorf("prepare not supported in tdengine recording driver: %s", query)
}
func (c *tdengineRecordingConn) Close() error { return nil }
func (c *tdengineRecordingConn) Begin() (driver.Tx, error) {
return nil, fmt.Errorf("transactions not supported in tdengine recording driver")
}
func (c *tdengineRecordingConn) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
if len(args) > 0 {
return nil, fmt.Errorf("unexpected exec args: %d", len(args))
}
c.state.mu.Lock()
defer c.state.mu.Unlock()
if c.state.execErr != nil {
return nil, c.state.execErr
}
c.state.queries = append(c.state.queries, query)
return driver.RowsAffected(1), nil
}
var _ driver.ExecerContext = (*tdengineRecordingConn)(nil)
func (c *tdengineRecordingConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
if len(args) > 0 {
return nil, fmt.Errorf("unexpected query args: %d", len(args))
}
c.state.mu.Lock()
defer c.state.mu.Unlock()
c.state.queries = append(c.state.queries, query)
if result, ok := c.state.queryResults[query]; ok {
if result.err != nil {
return nil, result.err
}
return &tdengineRecordingRows{columns: result.columns, rows: result.rows}, nil
}
return &tdengineRecordingRows{}, nil
}
var _ driver.QueryerContext = (*tdengineRecordingConn)(nil)
type tdengineRecordingRows struct {
columns []string
rows [][]driver.Value
index int
}
func (r *tdengineRecordingRows) Columns() []string {
return append([]string(nil), r.columns...)
}
func (r *tdengineRecordingRows) Close() error { return nil }
func (r *tdengineRecordingRows) Next(dest []driver.Value) error {
if r.index >= len(r.rows) {
return io.EOF
}
row := r.rows[r.index]
for idx := range dest {
if idx < len(row) {
dest[idx] = row[idx]
}
}
r.index++
return nil
}
func openTDengineRecordingDB(t *testing.T) (*sql.DB, *tdengineRecordingState) {
t.Helper()
registerTDengineRecordingDriverOnce.Do(func() {
sql.Register(tdengineRecordingDriverName, tdengineRecordingDriver{})
})
tdengineRecordingDriverMu.Lock()
tdengineRecordingDriverSeq++
dsn := fmt.Sprintf("tdengine-recording-%d", tdengineRecordingDriverSeq)
state := &tdengineRecordingState{queryResults: map[string]tdengineQueryResult{}}
tdengineRecordingDriverStates[dsn] = state
tdengineRecordingDriverMu.Unlock()
dbConn, err := sql.Open(tdengineRecordingDriverName, dsn)
if err != nil {
t.Fatalf("打开 recording db 失败: %v", err)
}
t.Cleanup(func() {
_ = dbConn.Close()
tdengineRecordingDriverMu.Lock()
delete(tdengineRecordingDriverStates, dsn)
tdengineRecordingDriverMu.Unlock()
})
return dbConn, state
}
func TestTDengineApplyChanges_InsertsIntoQualifiedTable(t *testing.T) {
t.Parallel()
dbConn, state := openTDengineRecordingDB(t)
td := &TDengineDB{conn: dbConn}
changes := connection.ChangeSet{
Inserts: []map[string]interface{}{
{
"ts": "2026-03-09 10:00:00",
"value": 12.5,
"device": "sensor-a",
"enabled": true,
},
},
}
if err := td.ApplyChanges("analytics.metrics", changes); err != nil {
t.Fatalf("ApplyChanges 返回错误: %v", err)
}
queries := state.snapshotQueries()
if len(queries) != 1 {
t.Fatalf("期望执行 1 条 SQL实际 %d 条: %#v", len(queries), queries)
}
want := "INSERT INTO `analytics`.`metrics` (`device`, `enabled`, `ts`, `value`) VALUES ('sensor-a', 1, '2026-03-09 10:00:00', 12.5)"
if queries[0] != want {
t.Fatalf("插入 SQL 不符合预期\nwant: %s\n got: %s", want, queries[0])
}
}
func TestTDengineApplyChanges_RejectsMixedUpdatesWithoutPartialWrite(t *testing.T) {
t.Parallel()
dbConn, state := openTDengineRecordingDB(t)
td := &TDengineDB{conn: dbConn}
changes := connection.ChangeSet{
Inserts: []map[string]interface{}{{
"ts": "2026-03-09 10:00:00",
"value": 12.5,
}},
Updates: []connection.UpdateRow{{
Keys: map[string]interface{}{"ts": "2026-03-09 10:00:00"},
Values: map[string]interface{}{"value": 18.8},
}},
}
err := td.ApplyChanges("metrics", changes)
if err == nil {
t.Fatalf("期望 mixed changes 被拒绝")
}
if !strings.Contains(err.Error(), "UPDATE/DELETE") {
t.Fatalf("错误信息未说明限制边界: %v", err)
}
if queries := state.snapshotQueries(); len(queries) != 0 {
t.Fatalf("期望拒绝 mixed changes 时不执行任何 SQL实际=%#v", queries)
}
}
func rawTDengineConnectionNotOpenText() string {
return string([]rune{0x8fde, 0x63a5, 0x672a, 0x6253, 0x5f00})
}
func rawTDengineTableNameRequiredText() string {
return string([]rune{0x8868, 0x540d, 0x4e0d, 0x80fd, 0x4e3a, 0x7a7a})
}
func rawTDengineApplyChangesInsertOnlyText() string {
return string([]rune{
0x0054, 0x0044, 0x0065, 0x006e, 0x0067, 0x0069, 0x006e, 0x0065, 0x0020,
0x76ee, 0x6807, 0x7aef, 0x5f53, 0x524d, 0x4ec5, 0x652f, 0x6301,
0x0020, 0x0049, 0x004e, 0x0053, 0x0045, 0x0052, 0x0054, 0x0020,
0x5199, 0x5165, 0xff0c, 0x6682, 0x4e0d, 0x652f, 0x6301, 0x0020,
0x0055, 0x0050, 0x0044, 0x0041, 0x0054, 0x0045, 0x002f, 0x0044,
0x0045, 0x004c, 0x0045, 0x0054, 0x0045, 0x0020, 0x5dee, 0x5f02,
0x540c, 0x6b65, 0xff0c, 0x8bf7, 0x6539, 0x7528, 0x4ec5, 0x63d2,
0x5165, 0x6216, 0x5168, 0x91cf, 0x8986, 0x76d6, 0x6a21, 0x5f0f,
})
}
func tdengineApplyChangesI18nKeys() []string {
return []string{
"db.backend.error.connection_not_open",
"db.backend.error.table_name_required",
"db.backend.error.tdengine_apply_changes_insert_only",
}
}
func TestTDengineApplyChangesErrorsUseCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
t.Run("connection not open", func(t *testing.T) {
td := &TDengineDB{}
err := td.ApplyChanges("metrics", connection.ChangeSet{})
if err == nil {
t.Fatal("expected connection-not-open error")
}
if err.Error() != "Connection is not open" {
t.Fatalf("expected English connection-not-open error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawTDengineConnectionNotOpenText()) {
t.Fatalf("expected no raw Chinese connection-not-open text, got %q", err.Error())
}
})
t.Run("table name required", func(t *testing.T) {
dbConn, _ := openTDengineRecordingDB(t)
td := &TDengineDB{conn: dbConn}
err := td.ApplyChanges(" ", connection.ChangeSet{})
if err == nil {
t.Fatal("expected table-name-required error")
}
if err.Error() != "Table name is required" {
t.Fatalf("expected English table-name-required error, got %q", err.Error())
}
if strings.Contains(err.Error(), rawTDengineTableNameRequiredText()) {
t.Fatalf("expected no raw Chinese table-name-required text, got %q", err.Error())
}
})
t.Run("update delete unsupported", func(t *testing.T) {
dbConn, state := openTDengineRecordingDB(t)
td := &TDengineDB{conn: dbConn}
changes := connection.ChangeSet{
Deletes: []map[string]interface{}{
{"ts": "2026-03-09 10:00:00"},
},
}
err := td.ApplyChanges("metrics", changes)
if err == nil {
t.Fatal("expected TDengine insert-only error")
}
want := "TDengine targets currently support only INSERT writes; UPDATE/DELETE differences are not supported by ApplyChanges"
if err.Error() != want {
t.Fatalf("expected %q, got %q", want, err.Error())
}
if strings.Contains(err.Error(), rawTDengineApplyChangesInsertOnlyText()) {
t.Fatalf("expected no raw Chinese insert-only text, got %q", err.Error())
}
if queries := state.snapshotQueries(); len(queries) != 0 {
t.Fatalf("expected no SQL execution after insert-only rejection, got %#v", queries)
}
})
}
func TestTDengineApplyChangesErrorSourcesUseI18nKeys(t *testing.T) {
sourceBytes, err := os.ReadFile("tdengine_impl.go")
if err != nil {
t.Fatalf("read tdengine_impl.go: %v", err)
}
source := string(sourceBytes)
start := strings.Index(source, "func (t *TDengineDB) ApplyChanges")
if start < 0 {
t.Fatal("TDengine ApplyChanges function not found")
}
end := strings.Index(source[start:], "func execTDengineInsertBatches")
if end < 0 {
t.Fatal("TDengine ApplyChanges function end marker not found")
}
applyChangesSource := source[start : start+end]
for _, rawMessage := range []string{
`fmt.Errorf("` + rawTDengineConnectionNotOpenText() + `")`,
`fmt.Errorf("` + rawTDengineTableNameRequiredText() + `")`,
`fmt.Errorf("` + rawTDengineApplyChangesInsertOnlyText() + `")`,
} {
if strings.Contains(applyChangesSource, rawMessage) {
t.Fatalf("TDengine ApplyChanges still contains raw text %q", rawMessage)
}
}
for _, key := range tdengineApplyChangesI18nKeys() {
if !strings.Contains(applyChangesSource, key) {
t.Fatalf("TDengine ApplyChanges does not reference i18n key %q", key)
}
}
}
func TestTDengineApplyChangesCatalogKeysExist(t *testing.T) {
catalogs, err := i18n.LoadCatalogs()
if err != nil {
t.Fatalf("LoadCatalogs() error = %v", err)
}
for _, language := range i18n.SupportedLanguages() {
catalog := catalogs[language]
for _, key := range tdengineApplyChangesI18nKeys() {
value := strings.TrimSpace(catalog[key])
if value == "" {
t.Fatalf("%s catalog missing TDengine ApplyChanges key %q", language, key)
}
if strings.Contains(value, "{{") || strings.Contains(value, "}}") {
t.Fatalf("%s catalog key %q should not use placeholders, got %q", language, key, value)
}
}
}
}
func TestTDengineGetTablesIncludesSuperTables(t *testing.T) {
t.Parallel()
dbConn, state := openTDengineRecordingDB(t)
state.mu.Lock()
state.queryResults["SHOW TABLES FROM `metrics`"] = tdengineQueryResult{
columns: []string{"name"},
rows: [][]driver.Value{
{"d001"},
{"d002"},
},
}
state.queryResults["SHOW STABLES FROM `metrics`"] = tdengineQueryResult{
columns: []string{"name"},
rows: [][]driver.Value{
{"meters"},
},
}
state.mu.Unlock()
td := &TDengineDB{conn: dbConn}
tables, err := td.GetTables("metrics")
if err != nil {
t.Fatalf("GetTables returned error: %v", err)
}
want := []string{"d001", "d002", "meters"}
if !reflect.DeepEqual(tables, want) {
t.Fatalf("unexpected tables: got=%v want=%v", tables, want)
}
}
func TestTDengineGetTablesFallsBackToLegacyFromSyntax(t *testing.T) {
t.Parallel()
dbConn, state := openTDengineRecordingDB(t)
state.mu.Lock()
state.queryResults["SHOW TABLES FROM `metrics`"] = tdengineQueryResult{
err: fmt.Errorf("[0x2600] syntax error near '`metrics`'"),
}
state.queryResults["SHOW STABLES FROM `metrics`"] = tdengineQueryResult{
err: fmt.Errorf("[0x2600] syntax error near '`metrics`'"),
}
state.queryResults["SHOW TABLES FROM metrics"] = tdengineQueryResult{
columns: []string{"name"},
rows: [][]driver.Value{
{"d001"},
},
}
state.queryResults["SHOW STABLES FROM metrics"] = tdengineQueryResult{
columns: []string{"name"},
rows: [][]driver.Value{
{"meters"},
},
}
state.mu.Unlock()
td := &TDengineDB{conn: dbConn}
tables, err := td.GetTables("metrics")
if err != nil {
t.Fatalf("GetTables returned error: %v", err)
}
wantTables := []string{"d001", "meters"}
if !reflect.DeepEqual(tables, wantTables) {
t.Fatalf("unexpected tables: got=%v want=%v", tables, wantTables)
}
queries := state.snapshotQueries()
wantQueries := []string{
"SHOW TABLES FROM `metrics`",
"SHOW STABLES FROM `metrics`",
"SHOW TABLES FROM metrics",
"SHOW STABLES FROM metrics",
"SHOW TABLES",
"SHOW STABLES",
}
if !reflect.DeepEqual(queries, wantQueries) {
t.Fatalf("unexpected query sequence: got=%v want=%v", queries, wantQueries)
}
}
func TestTDengineGetColumnsFallsBackToLegacyDescribeSyntax(t *testing.T) {
t.Parallel()
dbConn, state := openTDengineRecordingDB(t)
state.mu.Lock()
state.queryResults["DESCRIBE `metrics`.`meters`"] = tdengineQueryResult{
err: fmt.Errorf("[0x2600] syntax error near '`metrics`.`meters`'"),
}
state.queryResults["DESCRIBE metrics.meters"] = tdengineQueryResult{
columns: []string{"Field", "Type", "Note", "Null"},
rows: [][]driver.Value{
{"ts", "TIMESTAMP", "", "NO"},
{"value", "DOUBLE", "", "YES"},
},
}
state.mu.Unlock()
td := &TDengineDB{conn: dbConn}
columns, err := td.GetColumns("metrics", "meters")
if err != nil {
t.Fatalf("GetColumns returned error: %v", err)
}
if len(columns) != 2 {
t.Fatalf("expected 2 columns, got %d", len(columns))
}
queries := state.snapshotQueries()
wantQueries := []string{"DESCRIBE `metrics`.`meters`", "DESCRIBE metrics.meters"}
if !reflect.DeepEqual(queries, wantQueries) {
t.Fatalf("unexpected query sequence: got=%v want=%v", queries, wantQueries)
}
}
func TestTDengineGetCreateStatementFallsBackToLegacySyntax(t *testing.T) {
t.Parallel()
dbConn, state := openTDengineRecordingDB(t)
state.mu.Lock()
state.queryResults["SHOW CREATE TABLE `metrics`.`meters`"] = tdengineQueryResult{
err: fmt.Errorf("[0x2600] syntax error near '`metrics`.`meters`'"),
}
state.queryResults["SHOW CREATE STABLE `metrics`.`meters`"] = tdengineQueryResult{
err: fmt.Errorf("[0x2600] syntax error near '`metrics`.`meters`'"),
}
state.queryResults["SHOW CREATE TABLE metrics.meters"] = tdengineQueryResult{
columns: []string{"SQL"},
rows: [][]driver.Value{
{"CREATE TABLE metrics.meters (ts TIMESTAMP, value DOUBLE)"},
},
}
state.mu.Unlock()
td := &TDengineDB{conn: dbConn}
ddl, err := td.GetCreateStatement("metrics", "meters")
if err != nil {
t.Fatalf("GetCreateStatement returned error: %v", err)
}
if ddl != "CREATE TABLE metrics.meters (ts TIMESTAMP, value DOUBLE)" {
t.Fatalf("unexpected DDL: %q", ddl)
}
queries := state.snapshotQueries()
wantQueries := []string{
"SHOW CREATE TABLE `metrics`.`meters`",
"SHOW CREATE STABLE `metrics`.`meters`",
"SHOW CREATE TABLE metrics.meters",
}
if !reflect.DeepEqual(queries, wantQueries) {
t.Fatalf("unexpected query sequence: got=%v want=%v", queries, wantQueries)
}
}
func TestTDengineGetCreateStatementNotFoundUsesCurrentLanguage(t *testing.T) {
SetBackendLanguage(i18n.LanguageEnUS)
t.Cleanup(func() {
SetBackendLanguage(i18n.LanguageZhCN)
})
dbConn, _ := openTDengineRecordingDB(t)
td := &TDengineDB{conn: dbConn}
_, err := td.GetCreateStatement("metrics", "meters")
if err == nil {
t.Fatal("expected CREATE TABLE not found error")
}
want := "The CREATE TABLE statement was not found"
if err.Error() != want {
t.Fatalf("expected %q, got %q", want, err.Error())
}
rawNotFoundText := "\u672a\u627e\u5230\u5efa\u8868\u8bed\u53e5"
if strings.Contains(err.Error(), rawNotFoundText) {
t.Fatalf("expected no raw Chinese CREATE TABLE not found text, got %q", err.Error())
}
}
func TestTDengineGetCreateStatementSourceUsesI18nKey(t *testing.T) {
sourceBytes, err := os.ReadFile("tdengine_impl.go")
if err != nil {
t.Fatalf("read tdengine_impl.go: %v", err)
}
source := string(sourceBytes)
rawNotFoundText := "\u672a\u627e\u5230\u5efa\u8868\u8bed\u53e5"
rawNotFoundSnippet := `fmt.Errorf("` + rawNotFoundText + `")`
if strings.Contains(source, rawNotFoundSnippet) {
t.Fatalf("TDengine GetCreateStatement still contains raw CREATE TABLE not found text")
}
if !strings.Contains(source, "db.backend.error.create_table_statement_not_found") {
t.Fatal("TDengine GetCreateStatement does not reference db.backend.error.create_table_statement_not_found")
}
}