feat: send nothing to TypeSafe when an import's header row holds an address, date or number, skip placeholder Column N headers, accept only the options each column was offered, never infer Subscribed, Categories or Email, prefer an exact existing field name before a folded match on the server as the client does, compute value kinds once per preview, and make Enter in the empty field search a no-op

This commit is contained in:
Matthew Meszaros
2026-09-22 21:29:22 -07:00
parent c1acded32c
commit 7813d77efc
8 changed files with 204 additions and 132 deletions
+26 -14
View File
@@ -83,11 +83,12 @@ func (s *contactService) ImportPreview(ctx context.Context, orgID uuid.UUID, r i
// placed, so the mapper can ask for a second look at those.
func (s *contactService) SuggestImportMapping(ctx context.Context, orgID uuid.UUID, headers []string, sample [][]string) ([]models.ContactImportColumnMapping, []int) {
keys := s.existingCustomFieldKeys(ctx, orgID)
suggested := SuggestMapping(headers, sample, keys)
shapes := importmap.Shapes(len(headers), sample)
suggested := suggestMapping(headers, sample, shapes, keys)
if s.columnJudge == nil {
return suggested, nil
}
return importmap.Infer(ctx, s.columnJudge, suggested, headers, importmap.Shapes(len(headers), sample), keys)
return importmap.Infer(ctx, s.columnJudge, suggested, headers, shapes, keys)
}
// existingCustomFieldKeys is the workspace's custom-field keys for the
@@ -907,6 +908,10 @@ func padRow(row []string, n int) []string {
// better than inventing a custom-field key the user didn't ask for. Every
// importer that shows a column mapper calls this, so they suggest alike.
func SuggestMapping(headers []string, sample [][]string, existingKeys []string) []models.ContactImportColumnMapping {
return suggestMapping(headers, sample, importmap.Shapes(len(headers), sample), existingKeys)
}
func suggestMapping(headers []string, sample [][]string, shapes []importmap.Shape, existingKeys []string) []models.ContactImportColumnMapping {
out := make([]models.ContactImportColumnMapping, len(headers))
for i, h := range headers {
out[i] = guessTarget(i, h)
@@ -940,22 +945,21 @@ func SuggestMapping(headers []string, sample [][]string, existingKeys []string)
out[i] = models.ContactImportColumnMapping{Index: i, Target: models.ContactImportTargetVerificationStatus, VerificationProvider: provider}
}
matchExistingCustomFields(out, headers, existingKeys)
matchEmailByValues(out, headers, sample)
matchEmailByValues(out, shapes)
return out
}
// matchEmailByValues maps the first column of addresses to Email when no
// header named it, so a file with "Work contact" or no header row at all
// still has the one column an import cannot go without.
func matchEmailByValues(out []models.ContactImportColumnMapping, headers []string, sample [][]string) {
func matchEmailByValues(out []models.ContactImportColumnMapping, shapes []importmap.Shape) {
for _, m := range out {
if m.Target == models.ContactImportTargetEmail {
return
}
}
shapes := importmap.Shapes(len(headers), sample)
for i := range out {
if out[i].Target == models.ContactImportTargetIgnore && shapes[i] == importmap.ShapeEmail {
if i < len(shapes) && out[i].Target == models.ContactImportTargetIgnore && shapes[i] == importmap.ShapeEmail {
out[i] = models.ContactImportColumnMapping{Index: i, Target: models.ContactImportTargetEmail}
return
}
@@ -963,15 +967,18 @@ func matchEmailByValues(out []models.ContactImportColumnMapping, headers []strin
}
// matchExistingCustomFields maps each still-ignored column whose header names
// an existing custom field, ignoring case and separators, onto that field's
// stored spelling, so "industry" in a file lands on "Industry" instead of
// starting a second field. Each field is claimed by one column at most.
// an existing custom field onto that field's stored spelling: the exact name
// first, then one differing only in case or separators, so "industry" in a
// file lands on "Industry" instead of starting a second field. Each field is
// claimed by one column at most. Mirrored by matchExistingKey in the web app.
func matchExistingCustomFields(out []models.ContactImportColumnMapping, headers, existingKeys []string) {
if len(existingKeys) == 0 {
return
}
exact := make(map[string]bool, len(existingKeys))
byFold := make(map[string]string, len(existingKeys))
for _, k := range existingKeys {
exact[k] = true
f := FoldCustomFieldKey(k)
if _, taken := byFold[f]; f != "" && !taken {
byFold[f] = k
@@ -982,12 +989,17 @@ func matchExistingCustomFields(out []models.ContactImportColumnMapping, headers,
if out[i].Target != models.ContactImportTargetIgnore {
continue
}
f := FoldCustomFieldKey(h)
key, ok := byFold[f]
if !ok || claimed[f] {
key := utils.NormalizeJSONKey(h)
if !exact[key] {
var ok bool
if key, ok = byFold[FoldCustomFieldKey(h)]; !ok {
continue
}
}
if claimed[key] {
continue
}
claimed[f] = true
claimed[key] = true
out[i] = models.ContactImportColumnMapping{Index: i, Target: models.ContactImportTargetCustom, CustomKey: key}
}
}
@@ -998,7 +1010,7 @@ func matchExistingCustomFields(out []models.ContactImportColumnMapping, headers,
func FoldCustomFieldKey(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
if unicode.IsLetter(r) || unicode.IsNumber(r) {
b.WriteRune(r)
}
}
+12 -4
View File
@@ -14,15 +14,15 @@ func TestSuggestMappingMatchesExistingCustomFields(t *testing.T) {
want := []models.ContactImportColumnMapping{
{Index: 0, Target: models.ContactImportTargetEmail},
// The stored spelling wins, and the most used of two spellings.
{Index: 1, Target: models.ContactImportTargetCustom, CustomKey: "Industry"},
// An exact name wins over a more used spelling of it.
{Index: 1, Target: models.ContactImportTargetCustom, CustomKey: "industry"},
// Otherwise the stored spelling, the most used of several.
{Index: 2, Target: models.ContactImportTargetCustom, CustomKey: "company_url"},
// No field by that name: left for the user, never invented.
{Index: 3, Target: models.ContactImportTargetIgnore},
// A standard field outranks a custom field of the same name.
{Index: 4, Target: models.ContactImportTargetCompany},
// One column per field; a second spelling of it stays ignored.
{Index: 5, Target: models.ContactImportTargetIgnore},
{Index: 5, Target: models.ContactImportTargetCustom, CustomKey: "Industry"},
{Index: 6, Target: models.ContactImportTargetIgnore},
}
if len(got) != len(want) {
@@ -35,6 +35,13 @@ func TestSuggestMappingMatchesExistingCustomFields(t *testing.T) {
}
}
func TestSuggestMappingGivesEachFieldOneColumn(t *testing.T) {
got := SuggestMapping([]string{"Email", "Industry", "INDUSTRY"}, nil, []string{"Industry"})
if got[1].CustomKey != "Industry" || got[2].Target != models.ContactImportTargetIgnore {
t.Fatalf("got %+v, want the second spelling left ignored", got)
}
}
func TestSuggestMappingKeepsVerificationAheadOfCustomFields(t *testing.T) {
headers := []string{"email", "ZeroBounce Status"}
got := SuggestMapping(headers, [][]string{{"a@x.com", "valid"}}, []string{"ZeroBounce Status"})
@@ -59,6 +66,7 @@ func TestFoldCustomFieldKey(t *testing.T) {
"Revenue ($)": "revenue",
"###": "",
"Straße Nummer2": "straßenummer2",
"Area m²": "aream²",
} {
if got := FoldCustomFieldKey(in); got != want {
t.Errorf("FoldCustomFieldKey(%q) = %q, want %q", in, got, want)
+95 -87
View File
@@ -1,26 +1,15 @@
// Package importmap asks TypeSafe which field an import column holds when its
// header matched nothing, so "Job Title", "Firmenname" or "Sector" lands on the
// right field before anyone opens a dropdown.
//
// THIS FILE IS THE POLICY: every question, option and threshold is here. It
// follows the rules the inbox tagger learned the hard way:
//
// 1. One call per file. Every unmatched column is its own question over one
// state, so the headers are ingested once.
// 2. The model answers, code decides. Resolve applies the confidence floor
// and gives each field to one column at most.
// 3. Never ask what is already known. Columns the header aliases, the
// verification vocabulary, an existing custom field or the values
// themselves already decided are facts, and are never sent as questions.
//
// No cell value leaves the instance. The state is the headers, the workspace's
// custom field names, and the kind of value each column holds (Shape).
// Package importmap asks TypeSafe which field an import column holds when no
// header rule placed it. The model answers, resolve decides, and the state
// carries headers, value kinds and field names, never a cell.
package importmap
import (
"context"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
@@ -53,22 +42,25 @@ const (
// optionNone is the answer for a column that holds none of the fields offered.
const optionNone = "none"
// standardOptions are the fields the model may pick, with what each holds.
// Verification status is never offered: it is recognised from the values'
// vocabulary, which the model is not shown.
// standardOptions are the standard fields the model may pick, and the value
// kinds each is offered for. Email is found by its values, and Subscribed and
// Categories stay the user's call: a wrong guess there unsubscribes people or
// mints categories from cell values.
var standardOptions = []struct {
target models.ContactImportColumnTarget
desc string
holds []Shape
}{
{models.ContactImportTargetEmail, "The contact's email address"},
{models.ContactImportTargetFirstName, "The contact's first name, or given name"},
{models.ContactImportTargetLastName, "The contact's last name, surname, or family name"},
{models.ContactImportTargetCompany, "The name of the company or organization the contact works for"},
{models.ContactImportTargetPhone, "The contact's phone or mobile number"},
{models.ContactImportTargetSubscribed, "Whether the contact agreed to receive email, as yes or no"},
{models.ContactImportTargetCategories, "Tags, labels, lists, or segments the contact belongs to"},
{models.ContactImportTargetFirstName, "The contact's first name, or given name", []Shape{ShapeText}},
{models.ContactImportTargetLastName, "The contact's last name, surname, or family name", []Shape{ShapeText}},
{models.ContactImportTargetCompany, "The name of the company or organization the contact works for", []Shape{ShapeText}},
{models.ContactImportTargetPhone, "The contact's phone or mobile number", []Shape{ShapePhone, ShapeNumber, ShapeMixed}},
}
// placeholderRe is the name a column gets when its header cell is blank or
// the file has no header row; it says nothing about the column.
var placeholderRe = regexp.MustCompile(`^Column [0-9]+$`)
// Column is one column as the model sees it.
type Column struct {
Header string `json:"header"`
@@ -98,23 +90,35 @@ func Infer(
shapes []Shape,
existingKeys []string,
) ([]models.ContactImportColumnMapping, []int) {
if asker == nil {
// A header row holding an address, a date or a number is a data row, so
// sending it would send a contact.
if asker == nil || headerRowIsData(headers) {
return mapping, nil
}
taken := takenTargets(mapping)
options, criteria := buildOptions(taken, existingKeys)
options := buildOptions(taken, existingKeys)
st := state{Columns: make([]Column, len(headers)), CustomFields: capKeys(existingKeys)}
questions := map[string]typesafe.Question{}
for i := range headers {
col := Column{Header: safeHeader(i, headers[i]), Holds: shapeAt(shapes, i)}
offered := map[int]map[string]bool{}
for i, h := range headers {
col := Column{Header: capRunes(strings.TrimSpace(h), headerRunes), Holds: shapeAt(shapes, i)}
st.Columns[i] = col
if len(questions) >= MaxQuestions || i >= len(mapping) || mapping[i].Target != models.ContactImportTargetIgnore || col.Holds == ShapeEmpty {
if len(questions) >= MaxQuestions || i >= len(mapping) || mapping[i].Target != models.ContactImportTargetIgnore ||
col.Holds == ShapeEmpty || col.Header == "" || placeholderRe.MatchString(col.Header) {
continue
}
criteria := columnCriteria(options, col.Holds)
if len(criteria) <= 1 {
continue
}
offered[i] = make(map[string]bool, len(criteria))
for id := range criteria {
offered[i][id] = true
}
questions[questionID(i)] = typesafe.Choice(
"Which contact field does the column headed \""+col.Header+"\" hold? Its values are "+string(col.Holds)+".",
columnCriteria(criteria, col.Holds),
criteria,
)
}
if len(questions) == 0 {
@@ -127,16 +131,18 @@ func Infer(
if err != nil || resp == nil {
return mapping, nil
}
return Resolve(mapping, resp.Answers, options, taken)
return resolve(mapping, resp.Answers, options, offered, taken)
}
// Resolve applies the answers: a confident choice of a field nobody fills yet
// maps its column, and when two columns want one field the more confident
// wins. It never touches a column the suggester already mapped.
func Resolve(
// resolve applies the answers: a confident choice, among the options that
// column was offered, of a field nobody fills yet maps its column, and when
// two columns want one field the more confident wins. It never touches a
// column the suggester already mapped.
func resolve(
mapping []models.ContactImportColumnMapping,
answers map[string]typesafe.Answer,
options map[string]option,
options map[string]offer,
offered map[int]map[string]bool,
taken map[string]bool,
) ([]models.ContactImportColumnMapping, []int) {
type candidate struct {
@@ -146,19 +152,19 @@ func Resolve(
conf float64
}
var cands []candidate
for id, a := range answers {
col, ok := columnOf(id)
for qid, a := range answers {
col, ok := columnOf(qid)
if !ok || col >= len(mapping) || mapping[col].Target != models.ContactImportTargetIgnore {
continue
}
if a.Confidence < ConfFloor || a.Choice == optionNone {
if a.Confidence < ConfFloor || a.Choice == optionNone || !offered[col][a.Choice] {
continue
}
opt, ok := options[a.Choice]
o, ok := options[a.Choice]
if !ok {
continue
}
cands = append(cands, candidate{col: col, opt: opt, id: identity(opt), conf: a.Confidence})
cands = append(cands, candidate{col: col, opt: o.option, id: identity(o.option), conf: a.Confidence})
}
sort.Slice(cands, func(i, j int) bool {
if cands[i].conf != cands[j].conf {
@@ -174,12 +180,10 @@ func Resolve(
}
var inferred []int
for _, c := range cands {
if c.id != "" && claimed[c.id] {
if claimed[c.id] {
continue
}
if c.id != "" {
claimed[c.id] = true
}
claimed[c.id] = true
out[c.col] = models.ContactImportColumnMapping{Index: c.col, Target: c.opt.target, CustomKey: c.opt.customKey}
inferred = append(inferred, c.col)
}
@@ -187,49 +191,58 @@ func Resolve(
return out, inferred
}
// offer is one option with what it says to the model and which value kinds
// it is offered for (nil: every kind).
type offer struct {
option
desc string
holds []Shape
}
// buildOptions lists the fields still free, under short ids so no field name
// has to survive as an option key. criteria holds each id's description.
func buildOptions(taken map[string]bool, existingKeys []string) (map[string]option, map[string]string) {
options := map[string]option{}
criteria := map[string]string{}
// has to survive as an option key.
func buildOptions(taken map[string]bool, existingKeys []string) map[string]offer {
options := map[string]offer{}
for _, s := range standardOptions {
o := option{target: s.target}
if taken[identity(o)] {
continue
if !taken[identity(o)] {
options[string(s.target)] = offer{option: o, desc: s.desc, holds: s.holds}
}
id := string(s.target)
options[id] = o
criteria[id] = s.desc
}
for i, k := range capKeys(existingKeys) {
o := option{target: models.ContactImportTargetCustom, customKey: k}
if taken[identity(o)] {
continue
if !taken[identity(o)] {
options["field_"+strconv.Itoa(i+1)] = offer{option: o, desc: "The workspace's existing custom field named \"" + k + "\""}
}
id := "field_" + strconv.Itoa(i+1)
options[id] = o
criteria[id] = "The workspace's existing custom field named \"" + k + "\""
}
criteria[optionNone] = "None of these: the column holds something else"
return options, criteria
return options
}
// columnCriteria drops the options a column's values rule out, so a column of
// URLs is never offered Subscribed.
func columnCriteria(all map[string]string, holds Shape) map[string]string {
out := make(map[string]string, len(all))
for id, desc := range all {
if id == string(models.ContactImportTargetSubscribed) && holds != ShapeYesNo {
continue
// columnCriteria is what one column is asked to choose from: the options its
// value kind allows, and none.
func columnCriteria(options map[string]offer, holds Shape) map[string]string {
out := map[string]string{optionNone: "None of these: the column holds something else"}
for id, o := range options {
if o.holds == nil || slices.Contains(o.holds, holds) {
out[id] = o.desc
}
if id == string(models.ContactImportTargetEmail) && holds != ShapeEmail {
continue
}
out[id] = desc
}
return out
}
// headerRowIsData reports whether any header cell is shaped like a value
// rather than a name.
func headerRowIsData(headers []string) bool {
for _, h := range headers {
switch ShapeOf([]string{h}) {
case ShapeText, ShapeLongText, ShapeEmpty:
default:
return true
}
}
return false
}
// takenTargets is every destination the suggestion already fills.
func takenTargets(mapping []models.ContactImportColumnMapping) map[string]bool {
taken := map[string]bool{}
@@ -242,11 +255,11 @@ func takenTargets(mapping []models.ContactImportColumnMapping) map[string]bool {
return taken
}
// identity names where an option writes; "" for targets that take any number
// of columns (categories) or none (ignore).
// identity names where an option writes. Every target Infer can pick holds
// one column's value.
func identity(o option) string {
switch o.target {
case models.ContactImportTargetIgnore, models.ContactImportTargetCategories, "":
case models.ContactImportTargetIgnore, "":
return ""
case models.ContactImportTargetCustom:
return "custom:" + o.customKey
@@ -264,16 +277,11 @@ func capKeys(keys []string) []string {
return keys
}
// safeHeader is the header as sent. A header row that is really a data row
// ("dana@acme.com") is never sent: the column goes by its number instead.
func safeHeader(i int, h string) string {
if ShapeOf([]string{h}) != ShapeText {
return "Column " + strconv.Itoa(i+1)
func capRunes(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
if utf8.RuneCountInString(h) > headerRunes {
h = string([]rune(h)[:headerRunes])
}
return h
return string([]rune(s)[:n])
}
func shapeAt(shapes []Shape, i int) Shape {
+61 -24
View File
@@ -49,10 +49,10 @@ func optionFor(t *testing.T, q typesafe.Question, fragment string) string {
}
func TestInferAsksOnlyAboutUnmappedColumnsAndSendsNoValues(t *testing.T) {
headers := []string{"Email", "Job Title", "Firmenname", "Blank", "dana@acme.com"}
headers := []string{"Email", "Job Title", "Firmenname", "Blank", "Column 5"}
sample := [][]string{
{"dana@acme.com", "Head of Sales", "Acme BV", "", "lee@beta.io"},
{"lee@beta.io", "CTO", "Beta GmbH", "", "kim@gamma.io"},
{"dana@acme.com", "Head of Sales", "Acme BV", "", "Warm"},
{"lee@beta.io", "CTO", "Beta GmbH", "", "Cold"},
}
mapping := ignored(len(headers))
mapping[0].Target = models.ContactImportTargetEmail
@@ -63,34 +63,44 @@ func TestInferAsksOnlyAboutUnmappedColumnsAndSendsNoValues(t *testing.T) {
if f.calls != 1 {
t.Fatalf("want one call per file, got %d", f.calls)
}
for _, id := range []string{"column_2", "column_3", "column_5"} {
for _, id := range []string{"column_2", "column_3"} {
if _, ok := f.questions[id]; !ok {
t.Errorf("missing question %s", id)
}
}
for _, id := range []string{"column_1", "column_4"} {
// Mapped, empty, and a placeholder name that says nothing about the column.
for _, id := range []string{"column_1", "column_4", "column_5"} {
if _, ok := f.questions[id]; ok {
t.Errorf("asked about %s, which is mapped or empty", id)
t.Errorf("asked about %s", id)
}
}
raw, _ := json.Marshal(f.state)
for _, cell := range []string{"Head of Sales", "Acme BV", "dana@acme.com", "lee@beta.io", "CTO"} {
for _, cell := range []string{"Head of Sales", "Acme BV", "dana@acme.com", "lee@beta.io", "CTO", "Warm"} {
if strings.Contains(string(raw), cell) {
t.Errorf("state carries the cell value %q: %s", cell, raw)
}
}
if !strings.Contains(string(raw), `"Column 5"`) {
t.Errorf("a header that is really data must go by its number: %s", raw)
}
if strings.Contains(f.questions["column_5"].Instructions, "@") {
t.Errorf("the question repeats a data-shaped header: %s", f.questions["column_5"].Instructions)
}
func TestInferSendsNothingWhenTheHeaderRowIsData(t *testing.T) {
// A sheet with no header row: its first contact became the headers.
for _, headers := range [][]string{
{"dana@acme.com", "Dana", "Reyes", "Acme BV"},
{"Email", "Dana", "2026-09-05", "Acme BV"},
} {
f := &fakeAsker{}
got, inferred := Infer(context.Background(), f, ignored(len(headers)), headers,
[]Shape{ShapeEmail, ShapeText, ShapeText, ShapeText}, nil)
if f.calls != 0 || inferred != nil || got[1].Target != models.ContactImportTargetIgnore {
t.Fatalf("header row %q reached the judge", headers)
}
}
}
func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
headers := []string{"Email", "Job Title", "Firmenname", "Org", "Opt-in", "Notes"}
sample := [][]string{{"a@x.com", "CEO", "Acme", "Acme", "yes", "met at a conference"}}
headers := []string{"Email", "Job Title", "Firmenname", "Org", "Newsletter", "Notes", "Mobil"}
sample := [][]string{{"a@x.com", "CEO", "Acme", "Acme", "yes", "met at a conference", "+49 151 2345678"}}
mapping := ignored(len(headers))
mapping[0].Target = models.ContactImportTargetEmail
@@ -98,14 +108,18 @@ func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
probe := &fakeAsker{answers: map[string]typesafe.Answer{}}
Infer(context.Background(), probe, mapping, headers, Shapes(len(headers), sample), []string{"Title"})
title := optionFor(t, probe.questions["column_2"], `"Title"`)
if _, offered := probe.questions["column_2"].Criteria.(map[string]string)[string(models.ContactImportTargetEmail)]; offered {
t.Errorf("Email is already filled and must not be offered")
for id, q := range probe.questions {
crit := q.Criteria.(map[string]string)
for _, never := range []models.ContactImportColumnTarget{
models.ContactImportTargetEmail, models.ContactImportTargetSubscribed, models.ContactImportTargetCategories,
} {
if _, offered := crit[string(never)]; offered {
t.Errorf("%s offers %s, which stays the user's call", id, never)
}
}
}
if _, offered := probe.questions["column_2"].Criteria.(map[string]string)[string(models.ContactImportTargetSubscribed)]; offered {
t.Errorf("Subscribed is offered for a column that is not yes or no")
}
if _, offered := probe.questions["column_5"].Criteria.(map[string]string)[string(models.ContactImportTargetSubscribed)]; !offered {
t.Errorf("Subscribed is not offered for a yes or no column")
if _, offered := probe.questions["column_7"].Criteria.(map[string]string)[string(models.ContactImportTargetFirstName)]; offered {
t.Errorf("a column of phone numbers is offered First name")
}
f := &fakeAsker{answers: map[string]typesafe.Answer{
@@ -113,10 +127,12 @@ func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
// Two columns want Company: the more confident one gets it.
"column_3": {Choice: string(models.ContactImportTargetCompany), Confidence: 0.95},
"column_4": {Choice: string(models.ContactImportTargetCompany), Confidence: 0.80},
// Under the floor: left for the user.
"column_5": {Choice: string(models.ContactImportTargetSubscribed), Confidence: 0.55},
// Offered to text columns, never to this yes or no one.
"column_5": {Choice: string(models.ContactImportTargetLastName), Confidence: 0.99},
"column_6": {Choice: optionNone, Confidence: 0.99},
// Never asked, and an option nobody offered: both ignored.
// Under the floor: left for the user.
"column_7": {Choice: string(models.ContactImportTargetPhone), Confidence: 0.55},
// Never asked, and an id nobody offered: both ignored.
"column_1": {Choice: string(models.ContactImportTargetPhone), Confidence: 0.99},
"column_9": {Choice: "made_up", Confidence: 0.99},
}}
@@ -129,6 +145,7 @@ func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
{Index: 3, Target: models.ContactImportTargetIgnore},
{Index: 4, Target: models.ContactImportTargetIgnore},
{Index: 5, Target: models.ContactImportTargetIgnore},
{Index: 6, Target: models.ContactImportTargetIgnore},
}
for i := range want {
if got[i] != want[i] {
@@ -141,6 +158,26 @@ func TestInferAppliesConfidentAnswersAndCodeDecidesTheRest(t *testing.T) {
if mapping[1].Target != models.ContactImportTargetIgnore {
t.Errorf("Infer changed the caller's mapping in place")
}
// A different field that no other column wants is still applied.
f.answers["column_7"] = typesafe.Answer{Choice: string(models.ContactImportTargetPhone), Confidence: 0.9}
got, _ = Infer(context.Background(), f, mapping, headers, Shapes(len(headers), sample), []string{"Title"})
if got[6].Target != models.ContactImportTargetPhone {
t.Errorf("phone column: got %+v", got[6])
}
}
func TestInferSendsLongHeadersCapped(t *testing.T) {
long := "What is the primary reason your company is evaluating outreach tools this quarter, in a few words?"
f := &fakeAsker{}
Infer(context.Background(), f, ignored(2), []string{"Email", long}, []Shape{ShapeEmail, ShapeText}, nil)
q, ok := f.questions["column_2"]
if !ok {
t.Fatalf("a long header was not asked about")
}
if strings.Contains(q.Instructions, long) || !strings.Contains(q.Instructions, long[:headerRunes]) {
t.Errorf("header not capped at %d runes: %s", headerRunes, q.Instructions)
}
}
func TestInferFallsBackToTheSuggestion(t *testing.T) {