diff --git a/table/table_test.go b/table/table_test.go index cc49f0d3c..6129245ca 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -1,14 +1,183 @@ package table import ( + "reflect" "testing" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" ) -func TestFromValues(t *testing.T) { +var testCols = []Column{ + {Title: "col1", Width: 10}, + {Title: "col2", Width: 10}, + {Title: "col3", Width: 10}, +} + +func TestNew(t *testing.T) { + tests := map[string]struct { + opts []Option + want Model + }{ + "Default": { + want: Model{ + // Default fields + cursor: 0, + viewport: viewport.New(0, 20), + KeyMap: DefaultKeyMap(), + Help: help.New(), + styles: DefaultStyles(), + }, + }, + "WithColumns": { + opts: []Option{ + WithColumns([]Column{ + {Title: "Foo", Width: 1}, + {Title: "Bar", Width: 2}, + }), + }, + want: Model{ + // Default fields + cursor: 0, + viewport: viewport.New(0, 20), + KeyMap: DefaultKeyMap(), + Help: help.New(), + styles: DefaultStyles(), + + // Modified fields + cols: []Column{ + {Title: "Foo", Width: 1}, + {Title: "Bar", Width: 2}, + }, + }, + }, + "WithColumns; WithRows": { + opts: []Option{ + WithColumns([]Column{ + {Title: "Foo", Width: 1}, + {Title: "Bar", Width: 2}, + }), + WithRows([]Row{ + {"1", "Foo"}, + {"2", "Bar"}, + }), + }, + want: Model{ + // Default fields + cursor: 0, + viewport: viewport.New(0, 20), + KeyMap: DefaultKeyMap(), + Help: help.New(), + styles: DefaultStyles(), + + // Modified fields + cols: []Column{ + {Title: "Foo", Width: 1}, + {Title: "Bar", Width: 2}, + }, + rows: []Row{ + {"1", "Foo"}, + {"2", "Bar"}, + }, + }, + }, + "WithHeight": { + opts: []Option{ + WithHeight(10), + }, + want: Model{ + // Default fields + cursor: 0, + KeyMap: DefaultKeyMap(), + Help: help.New(), + styles: DefaultStyles(), + + // Modified fields + // Viewport height is 1 less than the provided height when no header is present since lipgloss.Height adds 1 + viewport: viewport.New(0, 9), + }, + }, + "WithWidth": { + opts: []Option{ + WithWidth(10), + }, + want: Model{ + // Default fields + cursor: 0, + KeyMap: DefaultKeyMap(), + Help: help.New(), + styles: DefaultStyles(), + + // Modified fields + // Viewport height is 1 less than the provided height when no header is present since lipgloss.Height adds 1 + viewport: viewport.New(10, 20), + }, + }, + "WithFocused": { + opts: []Option{ + WithFocused(true), + }, + want: Model{ + // Default fields + cursor: 0, + viewport: viewport.New(0, 20), + KeyMap: DefaultKeyMap(), + Help: help.New(), + styles: DefaultStyles(), + + // Modified fields + focus: true, + }, + }, + "WithStyles": { + opts: []Option{ + WithStyles(Styles{}), + }, + want: Model{ + // Default fields + cursor: 0, + viewport: viewport.New(0, 20), + KeyMap: DefaultKeyMap(), + Help: help.New(), + + // Modified fields + styles: Styles{}, + }, + }, + "WithKeyMap": { + opts: []Option{ + WithKeyMap(KeyMap{}), + }, + want: Model{ + // Default fields + cursor: 0, + viewport: viewport.New(0, 20), + Help: help.New(), + styles: DefaultStyles(), + + // Modified fields + KeyMap: KeyMap{}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + tc.want.UpdateViewport() + + got := New(tc.opts...) + + if !reflect.DeepEqual(tc.want, got) { + t.Errorf("\n\nwant %v\n\ngot %v", tc.want, got) + } + }) + } +} + +func TestModel_FromValues(t *testing.T) { input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3" table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}})) table.FromValues(input, ",") @@ -22,12 +191,12 @@ func TestFromValues(t *testing.T) { {"foo2", "bar2"}, {"foo3", "bar3"}, } - if !deepEqual(table.rows, expect) { - t.Fatal("table rows is not equals to the input") + if !reflect.DeepEqual(table.rows, expect) { + t.Fatalf("\n\nwant %v\n\ngot %v", expect, table.rows) } } -func TestFromValuesWithTabSeparator(t *testing.T) { +func TestModel_FromValues_WithTabSeparator(t *testing.T) { input := "foo1.\tbar1\nfoo,bar,baz\tbar,2" table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}})) table.FromValues(input, "\t") @@ -40,32 +209,12 @@ func TestFromValuesWithTabSeparator(t *testing.T) { {"foo1.", "bar1"}, {"foo,bar,baz", "bar,2"}, } - if !deepEqual(table.rows, expect) { - t.Fatal("table rows is not equals to the input") + if !reflect.DeepEqual(table.rows, expect) { + t.Fatalf("\n\nwant %v\n\ngot %v", expect, table.rows) } } -func deepEqual(a, b []Row) bool { - if len(a) != len(b) { - return false - } - for i, r := range a { - for j, f := range r { - if f != b[i][j] { - return false - } - } - } - return true -} - -var cols = []Column{ - {Title: "col1", Width: 10}, - {Title: "col2", Width: 10}, - {Title: "col3", Width: 10}, -} - -func TestRenderRow(t *testing.T) { +func TestModel_RenderRow(t *testing.T) { tests := []struct { name string table *Model @@ -75,7 +224,7 @@ func TestRenderRow(t *testing.T) { name: "simple row", table: &Model{ rows: []Row{{"Foooooo", "Baaaaar", "Baaaaaz"}}, - cols: cols, + cols: testCols, styles: Styles{Cell: lipgloss.NewStyle()}, }, expected: "Foooooo Baaaaar Baaaaaz ", @@ -84,7 +233,7 @@ func TestRenderRow(t *testing.T) { name: "simple row with truncations", table: &Model{ rows: []Row{{"Foooooooooo", "Baaaaaaaaar", "Quuuuuuuuux"}}, - cols: cols, + cols: testCols, styles: Styles{Cell: lipgloss.NewStyle()}, }, expected: "Foooooooo…Baaaaaaaa…Quuuuuuuu…", @@ -93,7 +242,7 @@ func TestRenderRow(t *testing.T) { name: "simple row avoiding truncations", table: &Model{ rows: []Row{{"Fooooooooo", "Baaaaaaaar", "Quuuuuuuux"}}, - cols: cols, + cols: testCols, styles: Styles{Cell: lipgloss.NewStyle()}, }, expected: "FoooooooooBaaaaaaaarQuuuuuuuux", @@ -157,3 +306,432 @@ func TestTableAlignment(t *testing.T) { golden.RequireEqual(t, []byte(got)) }) } + +func TestCursorNavigation(t *testing.T) { + tests := map[string]struct { + rows []Row + action func(*Model) + want int + }{ + "New": { + rows: []Row{ + {"r1"}, + {"r2"}, + {"r3"}, + }, + action: func(_ *Model) {}, + want: 0, + }, + "MoveDown": { + rows: []Row{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.MoveDown(2) + }, + want: 2, + }, + "MoveUp": { + rows: []Row{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.cursor = 3 + t.MoveUp(2) + }, + want: 1, + }, + "GotoBottom": { + rows: []Row{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.GotoBottom() + }, + want: 3, + }, + "GotoTop": { + rows: []Row{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.cursor = 3 + t.GotoTop() + }, + want: 0, + }, + "SetCursor": { + rows: []Row{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.SetCursor(2) + }, + want: 2, + }, + "MoveDown with overflow": { + rows: []Row{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.MoveDown(5) + }, + want: 3, + }, + "MoveUp with overflow": { + rows: []Row{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.cursor = 3 + t.MoveUp(5) + }, + want: 0, + }, + "Blur does not stop movement": { + rows: []Row{ + {"r1"}, + {"r2"}, + {"r3"}, + {"r4"}, + }, + action: func(t *Model) { + t.Blur() + t.MoveDown(2) + }, + want: 2, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + table := New(WithColumns(testCols), WithRows(tc.rows)) + tc.action(&table) + + if table.Cursor() != tc.want { + t.Errorf("want %d, got %d", tc.want, table.Cursor()) + } + }) + } +} + +func TestModel_SetRows(t *testing.T) { + table := New(WithColumns(testCols)) + + if len(table.rows) != 0 { + t.Fatalf("want 0, got %d", len(table.rows)) + } + + table.SetRows([]Row{{"r1"}, {"r2"}}) + + if len(table.rows) != 2 { + t.Fatalf("want 2, got %d", len(table.rows)) + } + + want := []Row{{"r1"}, {"r2"}} + if !reflect.DeepEqual(table.rows, want) { + t.Fatalf("\n\nwant %v\n\ngot %v", want, table.rows) + } +} + +func TestModel_SetColumns(t *testing.T) { + table := New() + + if len(table.cols) != 0 { + t.Fatalf("want 0, got %d", len(table.cols)) + } + + table.SetColumns([]Column{{Title: "Foo"}, {Title: "Bar"}}) + + if len(table.cols) != 2 { + t.Fatalf("want 2, got %d", len(table.cols)) + } + + want := []Column{{Title: "Foo"}, {Title: "Bar"}} + if !reflect.DeepEqual(table.cols, want) { + t.Fatalf("\n\nwant %v\n\ngot %v", want, table.cols) + } +} + +func TestModel_View(t *testing.T) { + tests := map[string]struct { + modelFunc func() Model + skip bool + }{ + // TODO(?): should the view/output of empty tables use the same default height? (this has height 21) + "Empty": { + modelFunc: func() Model { + return New() + }, + }, + "Single row and column": { + modelFunc: func() Model { + return New( + WithColumns([]Column{ + {Title: "Name", Width: 25}, + }), + WithRows([]Row{ + {"Chocolate Digestives"}, + }), + ) + }, + }, + "Multiple rows and columns": { + modelFunc: func() Model { + return New( + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + ) + }, + }, + // TODO(fix): since the table height is tied to the viewport height, adding vertical padding to the headers' height directly increases the table height. + "Extra padding": { + modelFunc: func() Model { + s := DefaultStyles() + s.Header = lipgloss.NewStyle().Padding(2, 2) + s.Cell = lipgloss.NewStyle().Padding(2, 2) + + return New( + WithHeight(10), + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + WithStyles(s), + ) + }, + }, + "No padding": { + modelFunc: func() Model { + s := DefaultStyles() + s.Header = lipgloss.NewStyle() + s.Cell = lipgloss.NewStyle() + + return New( + WithHeight(10), + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + WithStyles(s), + ) + }, + }, + // TODO(?): the total height is modified with borderd headers, however not with bordered cells. Is this expected/desired? + "Bordered headers": { + modelFunc: func() Model { + return New( + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + WithStyles(Styles{ + Header: lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()), + }), + ) + }, + }, + // TODO(fix): Headers are not horizontally aligned with cells due to the border adding width to the cells. + "Bordered cells": { + modelFunc: func() Model { + return New( + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + WithStyles(Styles{ + Cell: lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()), + }), + ) + }, + }, + "Manual height greater than rows": { + modelFunc: func() Model { + return New( + WithHeight(6), + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + ) + }, + }, + "Manual height less than rows": { + modelFunc: func() Model { + return New( + WithHeight(2), + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + ) + }, + }, + // TODO(fix): spaces are added to the right of the viewport to fill the width, but the headers end as though they are not aware of the width. + "Manual width greater than columns": { + modelFunc: func() Model { + return New( + WithWidth(80), + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + ) + }, + }, + // TODO(fix): Setting the table width does not affect the total headers' width. Cells are wrapped. + // Headers are not affected. Truncation/resizing should match lipgloss.table functionality. + "Manual width less than columns": { + modelFunc: func() Model { + return New( + WithWidth(30), + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + ) + }, + skip: true, + }, + "Modified viewport height": { + modelFunc: func() Model { + m := New( + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + ) + + m.viewport.Height = 2 + + return m + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if tc.skip { + t.Skip() + } + + table := tc.modelFunc() + + got := ansi.Strip(table.View()) + + golden.RequireEqual(t, []byte(got)) + }) + } +} + +// TODO: Fix table to make this test will pass. +func TestModel_View_CenteredInABox(t *testing.T) { + t.Skip() + + boxStyle := lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + Align(lipgloss.Center) + + table := New( + WithHeight(6), + WithWidth(80), + WithColumns([]Column{ + {Title: "Name", Width: 25}, + {Title: "Country of Origin", Width: 16}, + {Title: "Dunk-able", Width: 12}, + }), + WithRows([]Row{ + {"Chocolate Digestives", "UK", "Yes"}, + {"Tim Tams", "Australia", "No"}, + {"Hobnobs", "UK", "Yes"}, + }), + ) + + tableView := ansi.Strip(table.View()) + got := boxStyle.Render(tableView) + + golden.RequireEqual(t, []byte(got)) +} diff --git a/table/testdata/TestModel_View/Bordered_cells.golden b/table/testdata/TestModel_View/Bordered_cells.golden new file mode 100644 index 000000000..71e95988b --- /dev/null +++ b/table/testdata/TestModel_View/Bordered_cells.golden @@ -0,0 +1,21 @@ +Name Country of Orig…Dunk-able +┌─────────────────────────┐┌────────────────┐┌────────────┐ +│Chocolate Digestives ││UK ││Yes │ +└─────────────────────────┘└────────────────┘└────────────┘ +┌─────────────────────────┐┌────────────────┐┌────────────┐ +│Tim Tams ││Australia ││No │ +└─────────────────────────┘└────────────────┘└────────────┘ +┌─────────────────────────┐┌────────────────┐┌────────────┐ +│Hobnobs ││UK ││Yes │ +└─────────────────────────┘└────────────────┘└────────────┘ + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Bordered_headers.golden b/table/testdata/TestModel_View/Bordered_headers.golden new file mode 100644 index 000000000..0e260bae2 --- /dev/null +++ b/table/testdata/TestModel_View/Bordered_headers.golden @@ -0,0 +1,23 @@ +┌─────────────────────────┐┌────────────────┐┌────────────┐ +│Name ││Country of Orig…││Dunk-able │ +└─────────────────────────┘└────────────────┘└────────────┘ +Chocolate Digestives UK Yes +Tim Tams Australia No +Hobnobs UK Yes + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Empty.golden b/table/testdata/TestModel_View/Empty.golden new file mode 100644 index 000000000..7b050800f --- /dev/null +++ b/table/testdata/TestModel_View/Empty.golden @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/table/testdata/TestModel_View/Extra_padding.golden b/table/testdata/TestModel_View/Extra_padding.golden new file mode 100644 index 000000000..3028ba66e --- /dev/null +++ b/table/testdata/TestModel_View/Extra_padding.golden @@ -0,0 +1,14 @@ + + + Name Country of Orig… Dunk-able + + + + + Chocolate Digestives UK Yes + + + + + Tim Tams Australia No + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Manual_height_greater_than_rows.golden b/table/testdata/TestModel_View/Manual_height_greater_than_rows.golden new file mode 100644 index 000000000..0888bdeb0 --- /dev/null +++ b/table/testdata/TestModel_View/Manual_height_greater_than_rows.golden @@ -0,0 +1,6 @@ + Name Country of Orig… Dunk-able + Chocolate Digestives UK Yes + Tim Tams Australia No + Hobnobs UK Yes + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Manual_height_less_than_rows.golden b/table/testdata/TestModel_View/Manual_height_less_than_rows.golden new file mode 100644 index 000000000..9c331b390 --- /dev/null +++ b/table/testdata/TestModel_View/Manual_height_less_than_rows.golden @@ -0,0 +1,2 @@ + Name Country of Orig… Dunk-able + Chocolate Digestives UK Yes \ No newline at end of file diff --git a/table/testdata/TestModel_View/Manual_width_greater_than_columns.golden b/table/testdata/TestModel_View/Manual_width_greater_than_columns.golden new file mode 100644 index 000000000..684450492 --- /dev/null +++ b/table/testdata/TestModel_View/Manual_width_greater_than_columns.golden @@ -0,0 +1,21 @@ + Name Country of Orig… Dunk-able + Chocolate Digestives UK Yes + Tim Tams Australia No + Hobnobs UK Yes + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Manual_width_less_than_columns.golden b/table/testdata/TestModel_View/Manual_width_less_than_columns.golden new file mode 100644 index 000000000..d2bc25b96 --- /dev/null +++ b/table/testdata/TestModel_View/Manual_width_less_than_columns.golden @@ -0,0 +1,21 @@ + Name Country of Origin Dunk-able + Chocolate Digestives UK Yes + Tim Tams Australia No + Hobnobs UK Yes + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Modified_viewport_height.golden b/table/testdata/TestModel_View/Modified_viewport_height.golden new file mode 100644 index 000000000..91445d49a --- /dev/null +++ b/table/testdata/TestModel_View/Modified_viewport_height.golden @@ -0,0 +1,3 @@ + Name Country of Orig… Dunk-able + Chocolate Digestives UK Yes + Tim Tams Australia No \ No newline at end of file diff --git a/table/testdata/TestModel_View/Multiple_rows_and_columns.golden b/table/testdata/TestModel_View/Multiple_rows_and_columns.golden new file mode 100644 index 000000000..6ba436ce5 --- /dev/null +++ b/table/testdata/TestModel_View/Multiple_rows_and_columns.golden @@ -0,0 +1,21 @@ + Name Country of Orig… Dunk-able + Chocolate Digestives UK Yes + Tim Tams Australia No + Hobnobs UK Yes + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/No_padding.golden b/table/testdata/TestModel_View/No_padding.golden new file mode 100644 index 000000000..f74874668 --- /dev/null +++ b/table/testdata/TestModel_View/No_padding.golden @@ -0,0 +1,10 @@ +Name Country of Orig…Dunk-able +Chocolate Digestives UK Yes +Tim Tams Australia No +Hobnobs UK Yes + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Single_row_and_column.golden b/table/testdata/TestModel_View/Single_row_and_column.golden new file mode 100644 index 000000000..84950d577 --- /dev/null +++ b/table/testdata/TestModel_View/Single_row_and_column.golden @@ -0,0 +1,21 @@ + Name + Chocolate Digestives + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View_CenteredInABox.golden b/table/testdata/TestModel_View_CenteredInABox.golden new file mode 100644 index 000000000..59d9c7030 --- /dev/null +++ b/table/testdata/TestModel_View_CenteredInABox.golden @@ -0,0 +1,8 @@ +┌────────────────────────────────────────────────────────────────────────────────┐ +│ Name Country of Orig… Dunk-able │ +│ Chocolate Digestives UK Yes │ +│ Tim Tams Australia No │ +│ Hobnobs UK Yes │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────┘ \ No newline at end of file