Sale (1177→2321 LOC): - Quotation templates (apply to order, option lines) - Sales reports (by month, product, customer, salesperson, category) - Advance payment wizard (delivered/percentage/fixed modes) - SO cancel wizard, discount wizard - action_quotation_sent, action_lock/unlock, preview_quotation - Line computes: invoice_status, price_reduce, untaxed_amount - Partner extension: sale_order_total Purchase (478→1424 LOC): - Purchase reports (by month, category, bill status, receipt analysis) - Receipt creation from PO (action_create_picking) - 3-way matching: action_view_picking, action_view_invoice - button_approve, button_done, action_rfq_send - Line computes: price_subtotal/total with tax, product onchange - Partner extension: purchase_order_count/total Project (218→1161 LOC): - Project updates (status tracking: on_track/at_risk/off_track) - Milestones (deadline, reached tracking, task count) - Timesheet integration (account.analytic.line extension) - Timesheet reports (by project, employee, task, week) - Task recurrence model - Task: planned/effective/remaining hours, progress, subtask hours - Project: allocated/remaining hours, profitability actions ORM Tests (102 tests, 0→1257 LOC): - domain_test.go: 32 tests (compile, operators, AND/OR/NOT, null) - field_test.go: 15 tests (IsCopyable, SQLType, IsRelational, IsStored) - model_test.go: 21 tests (NewModel, AddFields, RegisterMethod, ExtendModel) - domain_parse_test.go: 21 tests (parse Python domain strings) - sanitize_test.go: 13 tests (false→nil, type conversions) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
374 lines
9.1 KiB
Go
374 lines
9.1 KiB
Go
package orm
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
)
|
|
|
|
func TestNewModel(t *testing.T) {
|
|
m := NewModel("test.model.unit.new", ModelOpts{
|
|
Description: "Test Model",
|
|
RecName: "name",
|
|
})
|
|
if m.Name() != "test.model.unit.new" {
|
|
t.Errorf("name: %s", m.Name())
|
|
}
|
|
if m.Table() != "test_model_unit_new" {
|
|
t.Errorf("table: %s", m.Table())
|
|
}
|
|
if m.Description() != "Test Model" {
|
|
t.Errorf("desc: %s", m.Description())
|
|
}
|
|
if m.RecName() != "name" {
|
|
t.Errorf("rec_name: %s", m.RecName())
|
|
}
|
|
}
|
|
|
|
func TestNewModelDefaults(t *testing.T) {
|
|
m := NewModel("test.model.defaults", ModelOpts{})
|
|
if m.Order() != "id" {
|
|
t.Errorf("default order: %s", m.Order())
|
|
}
|
|
if m.RecName() != "name" {
|
|
t.Errorf("default rec_name: %s", m.RecName())
|
|
}
|
|
if m.IsAbstract() {
|
|
t.Error("should not be abstract")
|
|
}
|
|
if m.IsTransient() {
|
|
t.Error("should not be transient")
|
|
}
|
|
}
|
|
|
|
func TestNewModelCustomTable(t *testing.T) {
|
|
m := NewModel("test.model.custom.table", ModelOpts{
|
|
Table: "my_custom_table",
|
|
})
|
|
if m.Table() != "my_custom_table" {
|
|
t.Errorf("table: %s", m.Table())
|
|
}
|
|
}
|
|
|
|
func TestNewModelAbstract(t *testing.T) {
|
|
m := NewModel("test.model.abstract", ModelOpts{
|
|
Type: ModelAbstract,
|
|
})
|
|
if m.IsAbstract() != true {
|
|
t.Error("should be abstract")
|
|
}
|
|
// Abstract models have no table
|
|
if m.Table() != "" {
|
|
t.Errorf("abstract should have no table, got %s", m.Table())
|
|
}
|
|
}
|
|
|
|
func TestNewModelTransient(t *testing.T) {
|
|
m := NewModel("test.model.transient", ModelOpts{
|
|
Type: ModelTransient,
|
|
})
|
|
if m.IsTransient() != true {
|
|
t.Error("should be transient")
|
|
}
|
|
}
|
|
|
|
func TestModelMagicFields(t *testing.T) {
|
|
m := NewModel("test.model.magic", ModelOpts{})
|
|
// Magic fields should be auto-created
|
|
if f := m.GetField("id"); f == nil {
|
|
t.Error("id field missing")
|
|
}
|
|
if f := m.GetField("display_name"); f == nil {
|
|
t.Error("display_name field missing")
|
|
}
|
|
if f := m.GetField("create_uid"); f == nil {
|
|
t.Error("create_uid field missing")
|
|
}
|
|
if f := m.GetField("create_date"); f == nil {
|
|
t.Error("create_date field missing")
|
|
}
|
|
if f := m.GetField("write_uid"); f == nil {
|
|
t.Error("write_uid field missing")
|
|
}
|
|
if f := m.GetField("write_date"); f == nil {
|
|
t.Error("write_date field missing")
|
|
}
|
|
}
|
|
|
|
func TestModelAddFields(t *testing.T) {
|
|
m := NewModel("test.model.fields.add", ModelOpts{})
|
|
m.AddFields(
|
|
Char("name", FieldOpts{String: "Name", Required: true}),
|
|
Integer("age", FieldOpts{String: "Age"}),
|
|
Boolean("active", FieldOpts{String: "Active", Default: true}),
|
|
)
|
|
|
|
if f := m.GetField("name"); f == nil {
|
|
t.Error("name field missing")
|
|
}
|
|
if f := m.GetField("age"); f == nil {
|
|
t.Error("age field missing")
|
|
}
|
|
if f := m.GetField("active"); f == nil {
|
|
t.Error("active field missing")
|
|
}
|
|
if f := m.GetField("nonexistent"); f != nil {
|
|
t.Error("should be nil")
|
|
}
|
|
|
|
nameF := m.GetField("name")
|
|
if nameF.Type != TypeChar {
|
|
t.Error("expected char")
|
|
}
|
|
if !nameF.Required {
|
|
t.Error("expected required")
|
|
}
|
|
if nameF.String != "Name" {
|
|
t.Error("expected Name label")
|
|
}
|
|
}
|
|
|
|
func TestModelAddFieldSetsModel(t *testing.T) {
|
|
m := NewModel("test.model.field.backref", ModelOpts{})
|
|
f := Char("ref", FieldOpts{})
|
|
m.AddField(f)
|
|
if f.model != m {
|
|
t.Error("field should have back-reference to model")
|
|
}
|
|
}
|
|
|
|
func TestModelStoredFields(t *testing.T) {
|
|
m := NewModel("test.model.stored", ModelOpts{})
|
|
m.AddFields(
|
|
Char("name", FieldOpts{}),
|
|
Char("computed_field", FieldOpts{Compute: "x"}),
|
|
One2many("lines", "other.model", "parent_id", FieldOpts{}),
|
|
)
|
|
|
|
stored := m.StoredFields()
|
|
// Should include magic fields + name, but not computed_field or o2m
|
|
nameFound := false
|
|
computedFound := false
|
|
linesFound := false
|
|
for _, f := range stored {
|
|
switch f.Name {
|
|
case "name":
|
|
nameFound = true
|
|
case "computed_field":
|
|
computedFound = true
|
|
case "lines":
|
|
linesFound = true
|
|
}
|
|
}
|
|
if !nameFound {
|
|
t.Error("name should be in stored fields")
|
|
}
|
|
if computedFound {
|
|
t.Error("computed_field should not be in stored fields")
|
|
}
|
|
if linesFound {
|
|
t.Error("o2m lines should not be in stored fields")
|
|
}
|
|
}
|
|
|
|
func TestModelRegisterMethod(t *testing.T) {
|
|
m := NewModel("test.model.methods.reg", ModelOpts{})
|
|
called := false
|
|
m.RegisterMethod("test_action", func(rs *Recordset, args ...interface{}) (interface{}, error) {
|
|
called = true
|
|
return "ok", nil
|
|
})
|
|
|
|
if _, ok := m.Methods["test_action"]; !ok {
|
|
t.Error("method not registered")
|
|
}
|
|
|
|
result, err := m.Methods["test_action"](nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result != "ok" {
|
|
t.Error("expected ok")
|
|
}
|
|
if !called {
|
|
t.Error("method not called")
|
|
}
|
|
}
|
|
|
|
func TestExtendModel(t *testing.T) {
|
|
NewModel("test.model.base.ext", ModelOpts{Description: "Base"})
|
|
|
|
ext := ExtendModel("test.model.base.ext")
|
|
ext.AddFields(Char("extra_field", FieldOpts{String: "Extra"}))
|
|
|
|
base := Registry.Get("test.model.base.ext")
|
|
if f := base.GetField("extra_field"); f == nil {
|
|
t.Error("extension field missing")
|
|
}
|
|
}
|
|
|
|
func TestExtendModelPanics(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Error("expected panic for missing model")
|
|
}
|
|
}()
|
|
ExtendModel("nonexistent.model.xyz.panic")
|
|
}
|
|
|
|
func TestRegistryGet(t *testing.T) {
|
|
NewModel("test.registry.get.model", ModelOpts{})
|
|
if m := Registry.Get("test.registry.get.model"); m == nil {
|
|
t.Error("model not found")
|
|
}
|
|
if m := Registry.Get("nonexistent.registry.model"); m != nil {
|
|
t.Error("should be nil")
|
|
}
|
|
}
|
|
|
|
func TestRegistryMustGetPanics(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Error("expected panic for missing model")
|
|
}
|
|
}()
|
|
Registry.MustGet("nonexistent.mustget.model")
|
|
}
|
|
|
|
func TestRegistryAll(t *testing.T) {
|
|
NewModel("test.registry.all.model", ModelOpts{})
|
|
all := Registry.All()
|
|
found := false
|
|
for _, name := range all {
|
|
if name == "test.registry.all.model" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("model not in Registry.All()")
|
|
}
|
|
}
|
|
|
|
func TestRegistryModels(t *testing.T) {
|
|
NewModel("test.registry.models.model", ModelOpts{})
|
|
models := Registry.Models()
|
|
if _, ok := models["test.registry.models.model"]; !ok {
|
|
t.Error("model not in Registry.Models()")
|
|
}
|
|
}
|
|
|
|
func TestModelSQLConstraint(t *testing.T) {
|
|
m := NewModel("test.model.constraint.sql", ModelOpts{})
|
|
m.AddSQLConstraint("unique_name", "UNIQUE(name)", "Name must be unique")
|
|
if len(m.SQLConstraints) != 1 {
|
|
t.Error("constraint not added")
|
|
}
|
|
if m.SQLConstraints[0].Name != "unique_name" {
|
|
t.Error("wrong name")
|
|
}
|
|
if m.SQLConstraints[0].Definition != "UNIQUE(name)" {
|
|
t.Error("wrong definition")
|
|
}
|
|
if m.SQLConstraints[0].Message != "Name must be unique" {
|
|
t.Error("wrong message")
|
|
}
|
|
}
|
|
|
|
func TestModelAddConstraint(t *testing.T) {
|
|
m := NewModel("test.model.constraint.func", ModelOpts{})
|
|
m.AddConstraint(func(rs *Recordset) error {
|
|
return fmt.Errorf("test error")
|
|
})
|
|
if len(m.Constraints) != 1 {
|
|
t.Error("constraint not added")
|
|
}
|
|
}
|
|
|
|
func TestModelRegisterOnchange(t *testing.T) {
|
|
m := NewModel("test.model.onchange", ModelOpts{})
|
|
m.RegisterOnchange("partner_id", func(env *Environment, vals Values) Values {
|
|
return Values{"name": "changed"}
|
|
})
|
|
if m.OnchangeHandlers == nil {
|
|
t.Fatal("OnchangeHandlers should not be nil")
|
|
}
|
|
if _, ok := m.OnchangeHandlers["partner_id"]; !ok {
|
|
t.Error("onchange handler not registered")
|
|
}
|
|
}
|
|
|
|
func TestModelRegisterInverse(t *testing.T) {
|
|
m := NewModel("test.model.inverse", ModelOpts{})
|
|
m.AddFields(Char("computed", FieldOpts{Compute: "_compute_computed"}))
|
|
m.RegisterInverse("computed", func(rs *Recordset, args ...interface{}) (interface{}, error) {
|
|
return nil, nil
|
|
})
|
|
|
|
f := m.GetField("computed")
|
|
if f.Inverse != "_inverse_computed" {
|
|
t.Errorf("expected _inverse_computed, got %s", f.Inverse)
|
|
}
|
|
if _, ok := m.Methods["_inverse_computed"]; !ok {
|
|
t.Error("inverse method not registered")
|
|
}
|
|
}
|
|
|
|
func TestModelCreateTableSQL(t *testing.T) {
|
|
m := NewModel("test.model.ddl", ModelOpts{})
|
|
m.AddFields(
|
|
Char("name", FieldOpts{Required: true}),
|
|
Integer("count", FieldOpts{}),
|
|
)
|
|
|
|
sql := m.CreateTableSQL()
|
|
if sql == "" {
|
|
t.Fatal("expected non-empty SQL")
|
|
}
|
|
// Should contain the table name
|
|
if !containsStr(sql, `"test_model_ddl"`) {
|
|
t.Error("missing table name in DDL")
|
|
}
|
|
// Should contain name column
|
|
if !containsStr(sql, `"name"`) {
|
|
t.Error("missing name column in DDL")
|
|
}
|
|
// Should contain NOT NULL for required
|
|
if !containsStr(sql, "NOT NULL") {
|
|
t.Error("missing NOT NULL for required field")
|
|
}
|
|
}
|
|
|
|
func TestModelCreateTableSQLAbstract(t *testing.T) {
|
|
m := NewModel("test.model.ddl.abstract", ModelOpts{Type: ModelAbstract})
|
|
sql := m.CreateTableSQL()
|
|
if sql != "" {
|
|
t.Error("abstract model should have empty DDL")
|
|
}
|
|
}
|
|
|
|
func TestModelFields(t *testing.T) {
|
|
m := NewModel("test.model.all.fields", ModelOpts{})
|
|
m.AddFields(Char("name", FieldOpts{}))
|
|
fields := m.Fields()
|
|
if _, ok := fields["name"]; !ok {
|
|
t.Error("name not in Fields()")
|
|
}
|
|
if _, ok := fields["id"]; !ok {
|
|
t.Error("id not in Fields()")
|
|
}
|
|
}
|
|
|
|
// containsStr is a test helper - checks if s contains substr
|
|
func containsStr(s, substr string) bool {
|
|
return len(s) >= len(substr) && (s == substr || len(s) > 0 && stringContains(s, substr))
|
|
}
|
|
|
|
func stringContains(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|