Expand Sale/Purchase/Project + 102 ORM tests — +4633 LOC

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>
This commit is contained in:
Marc
2026-04-03 23:39:41 +02:00
parent bdb97f98ad
commit fad2a37d1c
16 changed files with 4633 additions and 0 deletions

330
pkg/orm/field_test.go Normal file
View File

@@ -0,0 +1,330 @@
package orm
import "testing"
func TestFieldIsCopyable(t *testing.T) {
tests := []struct {
name string
field Field
want bool
}{
{"regular char", Field{Name: "name", Type: TypeChar}, true},
{"id field", Field{Name: "id", Type: TypeInteger}, false},
{"create_uid", Field{Name: "create_uid", Type: TypeMany2one}, false},
{"write_uid", Field{Name: "write_uid", Type: TypeMany2one}, false},
{"create_date", Field{Name: "create_date", Type: TypeDatetime}, false},
{"write_date", Field{Name: "write_date", Type: TypeDatetime}, false},
{"password", Field{Name: "password", Type: TypeChar}, false},
{"computed non-stored", Field{Name: "total", Type: TypeFloat, Compute: "x"}, false},
{"computed stored", Field{Name: "total", Type: TypeFloat, Compute: "x", Store: true}, true},
{"o2m", Field{Name: "lines", Type: TypeOne2many}, false},
{"m2o", Field{Name: "partner_id", Type: TypeMany2one}, true},
{"boolean", Field{Name: "active", Type: TypeBoolean}, true},
{"explicit copy true", Field{Name: "ref", Type: TypeChar, Copy: true}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.field.IsCopyable()
if got != tt.want {
t.Errorf("IsCopyable() = %v, want %v", got, tt.want)
}
})
}
}
func TestFieldSQLType(t *testing.T) {
tests := []struct {
typ FieldType
want string
}{
{TypeChar, "varchar"},
{TypeText, "text"},
{TypeHTML, "text"},
{TypeInteger, "int4"},
{TypeFloat, "numeric"},
{TypeMonetary, "numeric"},
{TypeBoolean, "bool"},
{TypeDate, "date"},
{TypeDatetime, "timestamp without time zone"},
{TypeMany2one, "int4"},
{TypeOne2many, ""},
{TypeMany2many, ""},
{TypeJson, "jsonb"},
{TypeProperties, "jsonb"},
{TypeBinary, "bytea"},
{TypeSelection, "varchar"},
{TypeReference, "varchar"},
}
for _, tt := range tests {
t.Run(tt.typ.String(), func(t *testing.T) {
got := tt.typ.SQLType()
if got != tt.want {
t.Errorf("SQLType() = %q, want %q", got, tt.want)
}
})
}
}
func TestFieldSQLTypeWithSize(t *testing.T) {
f := &Field{Type: TypeChar, Size: 64}
got := f.SQLType()
if got != "varchar(64)" {
t.Errorf("expected varchar(64), got %s", got)
}
}
func TestFieldTypeString(t *testing.T) {
if TypeChar.String() != "char" {
t.Error("expected char")
}
if TypeMany2one.String() != "many2one" {
t.Error("expected many2one")
}
if TypeBoolean.String() != "boolean" {
t.Error("expected boolean")
}
if TypeText.String() != "text" {
t.Error("expected text")
}
if TypeInteger.String() != "integer" {
t.Error("expected integer")
}
if TypeFloat.String() != "float" {
t.Error("expected float")
}
}
func TestFieldTypeIsRelational(t *testing.T) {
if !TypeMany2one.IsRelational() {
t.Error("m2o should be relational")
}
if !TypeOne2many.IsRelational() {
t.Error("o2m should be relational")
}
if !TypeMany2many.IsRelational() {
t.Error("m2m should be relational")
}
if TypeChar.IsRelational() {
t.Error("char should not be relational")
}
if TypeInteger.IsRelational() {
t.Error("integer should not be relational")
}
if TypeBoolean.IsRelational() {
t.Error("boolean should not be relational")
}
}
func TestFieldTypeIsStored(t *testing.T) {
if !TypeChar.IsStored() {
t.Error("char should be stored")
}
if !TypeMany2one.IsStored() {
t.Error("m2o should be stored")
}
if TypeOne2many.IsStored() {
t.Error("o2m should not be stored")
}
if TypeMany2many.IsStored() {
t.Error("m2m should not be stored")
}
if !TypeBoolean.IsStored() {
t.Error("boolean should be stored")
}
if !TypeInteger.IsStored() {
t.Error("integer should be stored")
}
}
func TestFieldIsStored(t *testing.T) {
tests := []struct {
name string
f Field
want bool
}{
{"plain char", Field{Type: TypeChar}, true},
{"computed not stored", Field{Type: TypeChar, Compute: "x"}, false},
{"computed stored", Field{Type: TypeChar, Compute: "x", Store: true}, true},
{"related not stored", Field{Type: TypeChar, Related: "partner_id.name"}, false},
{"related stored", Field{Type: TypeChar, Related: "partner_id.name", Store: true}, true},
{"o2m", Field{Type: TypeOne2many}, false},
{"m2m", Field{Type: TypeMany2many}, false},
{"m2o", Field{Type: TypeMany2one}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.f.IsStored()
if got != tt.want {
t.Errorf("IsStored() = %v, want %v", got, tt.want)
}
})
}
}
func TestFieldColumn(t *testing.T) {
f := &Field{Name: "partner_id", column: "partner_id"}
if f.Column() != "partner_id" {
t.Errorf("expected partner_id, got %s", f.Column())
}
f2 := &Field{Name: "custom", column: "custom_col"}
if f2.Column() != "custom_col" {
t.Errorf("expected custom_col, got %s", f2.Column())
}
// When column is empty, falls back to Name
f3 := &Field{Name: "fallback"}
if f3.Column() != "fallback" {
t.Errorf("expected fallback, got %s", f3.Column())
}
}
func TestFieldConstructors(t *testing.T) {
t.Run("Char", func(t *testing.T) {
f := Char("name", FieldOpts{String: "Name", Required: true})
if f.Type != TypeChar {
t.Errorf("type: %s", f.Type)
}
if f.Name != "name" {
t.Errorf("name: %s", f.Name)
}
if !f.Required {
t.Error("expected required")
}
if f.String != "Name" {
t.Errorf("string: %s", f.String)
}
})
t.Run("Integer", func(t *testing.T) {
f := Integer("count", FieldOpts{})
if f.Type != TypeInteger {
t.Errorf("type: %s", f.Type)
}
})
t.Run("Boolean", func(t *testing.T) {
f := Boolean("active", FieldOpts{Default: true})
if f.Type != TypeBoolean {
t.Errorf("type: %s", f.Type)
}
if f.Default != true {
t.Error("expected default true")
}
})
t.Run("Many2one", func(t *testing.T) {
f := Many2one("partner_id", "res.partner", FieldOpts{String: "Partner"})
if f.Type != TypeMany2one {
t.Errorf("type: %s", f.Type)
}
if f.Comodel != "res.partner" {
t.Errorf("comodel: %s", f.Comodel)
}
if !f.Index {
t.Error("M2O should be auto-indexed")
}
if f.OnDelete != OnDeleteSetNull {
t.Errorf("expected set null, got %s", f.OnDelete)
}
})
t.Run("One2many", func(t *testing.T) {
f := One2many("line_ids", "sale.order.line", "order_id", FieldOpts{})
if f.Type != TypeOne2many {
t.Errorf("type: %s", f.Type)
}
if f.InverseField != "order_id" {
t.Errorf("inverse: %s", f.InverseField)
}
})
t.Run("Many2many", func(t *testing.T) {
f := Many2many("tag_ids", "res.partner.tag", FieldOpts{})
if f.Type != TypeMany2many {
t.Errorf("type: %s", f.Type)
}
if f.Comodel != "res.partner.tag" {
t.Errorf("comodel: %s", f.Comodel)
}
})
t.Run("Text", func(t *testing.T) {
f := Text("description", FieldOpts{})
if f.Type != TypeText {
t.Errorf("type: %s", f.Type)
}
})
t.Run("Float", func(t *testing.T) {
f := Float("amount", FieldOpts{})
if f.Type != TypeFloat {
t.Errorf("type: %s", f.Type)
}
})
t.Run("Date", func(t *testing.T) {
f := Date("birthday", FieldOpts{})
if f.Type != TypeDate {
t.Errorf("type: %s", f.Type)
}
})
t.Run("Datetime", func(t *testing.T) {
f := Datetime("created", FieldOpts{})
if f.Type != TypeDatetime {
t.Errorf("type: %s", f.Type)
}
})
t.Run("Binary", func(t *testing.T) {
f := Binary("image", FieldOpts{})
if f.Type != TypeBinary {
t.Errorf("type: %s", f.Type)
}
})
t.Run("Json", func(t *testing.T) {
f := Json("data", FieldOpts{})
if f.Type != TypeJson {
t.Errorf("type: %s", f.Type)
}
})
t.Run("default label from name", func(t *testing.T) {
f := Char("my_field", FieldOpts{})
if f.String != "my_field" {
t.Errorf("expected my_field as default label, got %s", f.String)
}
})
}
func TestFieldResolveDefault(t *testing.T) {
t.Run("nil default", func(t *testing.T) {
f := &Field{Default: nil}
if f.ResolveDefault() != nil {
t.Error("expected nil")
}
})
t.Run("string default", func(t *testing.T) {
f := &Field{Default: "hello"}
if f.ResolveDefault() != "hello" {
t.Error("expected hello")
}
})
t.Run("bool default", func(t *testing.T) {
f := &Field{Default: true}
if f.ResolveDefault() != true {
t.Error("expected true")
}
})
t.Run("int default", func(t *testing.T) {
f := &Field{Default: 42}
if f.ResolveDefault() != 42 {
t.Error("expected 42")
}
})
}