summaryrefslogtreecommitdiff
path: root/addons/point_of_sale/static/tests
diff options
context:
space:
mode:
Diffstat (limited to 'addons/point_of_sale/static/tests')
-rw-r--r--addons/point_of_sale/static/tests/tours/Chrome.tour.js103
-rw-r--r--addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js138
-rw-r--r--addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js70
-rw-r--r--addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js66
-rw-r--r--addons/point_of_sale/static/tests/tours/ProductScreen.tour.js105
-rw-r--r--addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js61
-rw-r--r--addons/point_of_sale/static/tests/tours/TicketScreen.tour.js54
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js42
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js57
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js23
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js30
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js72
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js180
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js215
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js77
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js254
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js79
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js39
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js107
-rw-r--r--addons/point_of_sale/static/tests/tours/helpers/utils.js153
-rw-r--r--addons/point_of_sale/static/tests/tours/point_of_sale.js436
-rw-r--r--addons/point_of_sale/static/tests/unit/helpers/test_env.js46
-rw-r--r--addons/point_of_sale/static/tests/unit/helpers/test_main.js23
-rw-r--r--addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js89
-rw-r--r--addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js414
-rw-r--r--addons/point_of_sale/static/tests/unit/test_NumberBuffer.js65
-rw-r--r--addons/point_of_sale/static/tests/unit/test_PaymentScreen.js309
-rw-r--r--addons/point_of_sale/static/tests/unit/test_ProductScreen.js603
-rw-r--r--addons/point_of_sale/static/tests/unit/test_popups.js180
29 files changed, 4090 insertions, 0 deletions
diff --git a/addons/point_of_sale/static/tests/tours/Chrome.tour.js b/addons/point_of_sale/static/tests/tours/Chrome.tour.js
new file mode 100644
index 00000000..a1c992de
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/Chrome.tour.js
@@ -0,0 +1,103 @@
+odoo.define('point_of_sale.tour.Chrome', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods');
+ const { Chrome } = require('point_of_sale.tour.ChromeTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ startSteps();
+
+ // Order 1 is at Product Screen
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.exec.addOrderline('Desk Pad', '1', '2', '2.0');
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.checkStatus('-0001', 'Ongoing');
+
+ // Order 2 is at Payment Screen
+ TicketScreen.do.clickNewTicket();
+ ProductScreen.exec.addOrderline('Monitor Stand', '3', '4', '12.0');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.check.isShown();
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.checkStatus('-0002', 'Payment');
+
+ // Order 3 is at Receipt Screen
+ TicketScreen.do.clickNewTicket();
+ ProductScreen.exec.addOrderline('Whiteboard Pen', '5', '6', '30.0');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.isShown();
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.checkStatus('-0003', 'Receipt');
+
+ // Select order 1, should be at Product Screen
+ TicketScreen.do.selectOrder('-0001');
+ ProductScreen.check.productIsDisplayed('Desk Pad');
+ ProductScreen.check.selectedOrderlineHas('Desk Pad', '1.0', '2.0');
+
+ // Select order 2, should be at Payment Screen
+ Chrome.do.clickTicketButton();
+ TicketScreen.do.selectOrder('-0002');
+ PaymentScreen.check.emptyPaymentlines('12.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+
+ // Select order 3, should be at Receipt Screen
+ Chrome.do.clickTicketButton();
+ TicketScreen.do.selectOrder('-0003');
+ ReceiptScreen.check.totalAmountContains('30.0');
+
+ // Pay order 1, with change
+ Chrome.do.clickTicketButton();
+ TicketScreen.do.selectOrder('-0001');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('2 0');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.totalAmountContains('2.0');
+
+ // Order 1 now should have Receipt status
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.checkStatus('-0001', 'Receipt');
+
+ // Select order 3, should still be at Receipt Screen
+ // and the total amount doesn't change.
+ TicketScreen.do.selectOrder('-0003');
+ ReceiptScreen.check.totalAmountContains('30.0');
+
+ // click next screen on order 3
+ // then delete the new empty order
+ ReceiptScreen.do.clickNextOrder();
+ ProductScreen.check.orderIsEmpty();
+ Chrome.do.clickTicketButton();
+ TicketScreen.do.deleteOrder('-0004');
+ TicketScreen.do.deleteOrder('-0001');
+
+ // After deleting order 1 above, order 2 became
+ // the 2nd-row order and it has payment status
+ TicketScreen.check.nthRowContains(2, 'Payment')
+ TicketScreen.do.deleteOrder('-0002');
+ Chrome.do.confirmPopup();
+ TicketScreen.do.clickNewTicket();
+
+ // Invoice an order
+ ProductScreen.exec.addOrderline('Whiteboard Pen', '5', '6');
+ ProductScreen.do.clickCustomerButton();
+ ProductScreen.do.clickCustomer('Nicole Ford');
+ ProductScreen.do.clickSetCustomer();
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.do.clickInvoiceButton();
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.isShown();
+
+ Tour.register('ChromeTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js b/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js
new file mode 100644
index 00000000..cfd6483a
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/OrderManagementScreen.tour.js
@@ -0,0 +1,138 @@
+odoo.define('point_of_sale.tour.OrderManagementScreen', function (require) {
+ 'use strict';
+
+ const { OrderManagementScreen } = require('point_of_sale.tour.OrderManagementScreenTourMethods');
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { ClientListScreen } = require('point_of_sale.tour.ClientListScreenTourMethods');
+ const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods');
+ const { Chrome } = require('point_of_sale.tour.ChromeTourMethods');
+ const { makeFullOrder } = require('point_of_sale.tour.CompositeTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ // signal to start generating steps
+ // when finished, steps can be taken from getSteps
+ startSteps();
+
+ // Go by default to home category
+ ProductScreen.do.clickHomeCategory();
+
+ // make one order and check if it can be seen from the management screen.
+ // order 0001
+ makeFullOrder({ orderlist: [['Whiteboard Pen', '5', '6']], payment: ['Cash', '30'] });
+ Chrome.do.clickOrderManagementButton();
+ OrderManagementScreen.check.isShown();
+ OrderManagementScreen.check.orderlistHas({ orderName: '-0001', total: '30' });
+
+ OrderManagementScreen.do.clickBack();
+
+ // make multiple orders and check them in the management screen.
+ // order 0002
+ makeFullOrder({
+ orderlist: [
+ ['Desk Pad', '1', '2'],
+ ['Monitor Stand', '3', '4'],
+ ['Whiteboard Pen', '5', '6'],
+ ],
+ payment: ['Bank', '44'],
+ });
+ // order 0003
+ makeFullOrder({
+ orderlist: [
+ ['Desk Pad', '1', '2'],
+ ['Whiteboard Pen', '5', '6'],
+ ],
+ customer: 'Colleen Diaz',
+ payment: ['Cash', '50'],
+ });
+ // order 0004
+ makeFullOrder({
+ orderlist: [
+ ['Monitor Stand', '3', '4'],
+ ['Whiteboard Pen', '5', '6'],
+ ],
+ payment: ['Bank', '42'],
+ });
+
+ Chrome.do.clickOrderManagementButton();
+ OrderManagementScreen.check.isShown();
+ OrderManagementScreen.check.orderlistHas({ orderName: '-0002', total: '44' });
+ OrderManagementScreen.check.orderlistHas({
+ orderName: '0003',
+ total: '32',
+ customer: 'Colleen Diaz',
+ });
+ OrderManagementScreen.check.orderlistHas({ orderName: '-0004', total: '42' });
+
+ // click the currently active order
+ OrderManagementScreen.do.clickOrder('-0005');
+ ProductScreen.check.isShown();
+
+ // Add 2 orders, they should appear in order management screen
+ // order 0006
+ Chrome.do.clickTicketButton();
+ TicketScreen.do.clickNewTicket();
+ ProductScreen.exec.addOrderline('Whiteboard Pen', '66', '6');
+
+ // order 0007, should be at payment screen
+ Chrome.do.clickTicketButton();
+ TicketScreen.do.clickNewTicket();
+ ProductScreen.exec.addOrderline('Monitor Stand', '55', '5');
+ ProductScreen.do.clickCustomerButton();
+ ClientListScreen.exec.setClient('Azure Interior');
+ ProductScreen.do.clickPayButton();
+
+ Chrome.do.clickOrderManagementButton();
+ OrderManagementScreen.check.orderlistHas({ orderName: '-0006', total: '396' });
+ OrderManagementScreen.check.orderlistHas({
+ orderName: '-0007',
+ total: '275',
+ customer: 'Azure Interior',
+ });
+
+ // select a paid order, order row should be highlighted and should show order details
+ OrderManagementScreen.do.clickOrder('-0004');
+ OrderManagementScreen.check.highlightedOrderRowHas('-0004');
+ OrderManagementScreen.check.orderDetailsHas({
+ lines: [
+ { product: 'Monitor Stand', quantity: '3' },
+ { product: 'Whiteboard Pen', quantity: '5' },
+ ],
+ total: '42',
+ });
+ OrderManagementScreen.do.clickOrder('-0001');
+ OrderManagementScreen.check.highlightedOrderRowHas('-0001');
+ // 0004 should not be highlighted anymore
+ OrderManagementScreen.check.orderRowIsNotHighlighted('-0004');
+ OrderManagementScreen.check.orderDetailsHas({
+ lines: [{ product: 'Whiteboard Pen', quantity: '5' }],
+ total: '30',
+ });
+
+ // Select a paid order then invoice it. The selected order should remain selected
+ // and will contain a new customer. After invoice, the current customer should be removed.
+ // TODO: enable the following steps once the issue in invoicing is solved.
+ // OrderManagementScreen.do.clickInvoiceButton();
+ // Chrome.do.confirmPopup();
+ // ClientListScreen.check.isShown();
+ // ClientListScreen.exec.setClient('Jesse Brown');
+ // OrderManagementScreen.check.highlightedOrderRowHas('Jesse Brown');
+
+ // Check if order 0007 is selected, it should be at payment screen
+ OrderManagementScreen.do.clickOrder('-0007');
+ PaymentScreen.check.isShown();
+
+ Chrome.do.clickOrderManagementButton();
+ OrderManagementScreen.check.isShown();
+ OrderManagementScreen.do.clickOrder('-0003');
+ OrderManagementScreen.do.clickPrintReceiptButton();
+ OrderManagementScreen.check.reprintReceiptIsShown();
+ OrderManagementScreen.check.receiptChangeIs('18.0');
+ OrderManagementScreen.check.receiptOrderDataContains('-0003');
+ OrderManagementScreen.check.receiptAmountIs('32.0');
+ OrderManagementScreen.do.closeReceipt();
+ OrderManagementScreen.check.isNotHidden();
+
+ Tour.register('OrderManagementScreenTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js b/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js
new file mode 100644
index 00000000..296fbd55
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js
@@ -0,0 +1,70 @@
+odoo.define('point_of_sale.tour.PaymentScreen', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ startSteps();
+
+ ProductScreen.exec.addOrderline('Letter Tray', '10');
+ ProductScreen.check.selectedOrderlineHas('Letter Tray', '10.0');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.check.emptyPaymentlines('52.8');
+
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('1 1');
+ PaymentScreen.check.selectedPaymentlineHas('Cash', '11.00');
+ PaymentScreen.check.remainingIs('41.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ // remove the selected paymentline with multiple backspace presses
+ PaymentScreen.do.pressNumpad('Backspace Backspace');
+ PaymentScreen.check.selectedPaymentlineHas('Cash', '0.00');
+ PaymentScreen.do.pressNumpad('Backspace');
+ PaymentScreen.check.emptyPaymentlines('52.8');
+
+ // Pay with bank, the selected line should have full amount
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+ // remove the line using the delete button
+ PaymentScreen.do.clickPaymentlineDelButton('Bank', '52.8');
+
+ // Use +10 and +50 to increment the amount of the paymentline
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('+10');
+ PaymentScreen.check.remainingIs('42.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ PaymentScreen.do.pressNumpad('+50');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.changeIs('7.2');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+ PaymentScreen.do.clickPaymentlineDelButton('Cash', '60.0');
+
+ // Multiple paymentlines
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('1');
+ PaymentScreen.check.remainingIs('51.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('5');
+ PaymentScreen.check.remainingIs('46.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.do.pressNumpad('2 0');
+ PaymentScreen.check.remainingIs('26.8');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(false);
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.check.remainingIs('0.0');
+ PaymentScreen.check.changeIs('0.0');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+
+ Tour.register('PaymentScreenTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js b/addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js
new file mode 100644
index 00000000..d3acf388
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/ProductConfigurator.tour.js
@@ -0,0 +1,66 @@
+odoo.define('point_of_sale.tour.ProductConfigurator', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ProductConfigurator } = require('point_of_sale.tour.ProductConfiguratorTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ // signal to start generating steps
+ // when finished, steps can be taken from getSteps
+ startSteps();
+
+ // Go by default to home category
+ ProductScreen.do.clickHomeCategory();
+
+ // Click on Configurable Chair product
+ ProductScreen.do.clickDisplayedProduct('Configurable Chair');
+ ProductConfigurator.check.isShown();
+
+ // Cancel configuration, not product should be in order
+ ProductConfigurator.do.cancelAttributes();
+ ProductScreen.check.orderIsEmpty();
+
+ // Click on Configurable Chair product
+ ProductScreen.do.clickDisplayedProduct('Configurable Chair');
+ ProductConfigurator.check.isShown();
+
+ // Pick Color
+ ProductConfigurator.do.pickColor('Red');
+
+ // Pick Radio
+ ProductConfigurator.do.pickSelect('Metal');
+
+ // Pick Select
+ ProductConfigurator.do.pickRadio('Other');
+
+ // Fill in custom attribute
+ ProductConfigurator.do.fillCustomAttribute('Custom Fabric');
+
+ // Confirm configuration
+ ProductConfigurator.do.confirmAttributes();
+
+ // Check that the product has been added to the order with correct attributes and price
+ ProductScreen.check.selectedOrderlineHas('Configurable Chair (Red, Metal, Other: Custom Fabric)', '1.0', '11.0');
+
+ // Orderlines with the same attributes should be merged
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.do.clickDisplayedProduct('Configurable Chair');
+ ProductConfigurator.do.pickColor('Red');
+ ProductConfigurator.do.pickSelect('Metal');
+ ProductConfigurator.do.pickRadio('Other');
+ ProductConfigurator.do.fillCustomAttribute('Custom Fabric');
+ ProductConfigurator.do.confirmAttributes();
+ ProductScreen.check.selectedOrderlineHas('Configurable Chair (Red, Metal, Other: Custom Fabric)', '2.0', '22.0');
+
+ // Orderlines with different attributes shouldn't be merged
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.do.clickDisplayedProduct('Configurable Chair');
+ ProductConfigurator.do.pickColor('Blue');
+ ProductConfigurator.do.pickSelect('Metal');
+ ProductConfigurator.do.pickRadio('Leather');
+ ProductConfigurator.do.confirmAttributes();
+ ProductScreen.check.selectedOrderlineHas('Configurable Chair (Blue, Metal, Leather)', '1.0', '10.0');
+
+ Tour.register('ProductConfiguratorTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/ProductScreen.tour.js b/addons/point_of_sale/static/tests/tours/ProductScreen.tour.js
new file mode 100644
index 00000000..9d3dcc3f
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/ProductScreen.tour.js
@@ -0,0 +1,105 @@
+odoo.define('point_of_sale.tour.ProductScreen', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ // signal to start generating steps
+ // when finished, steps can be taken from getSteps
+ startSteps();
+
+ // Go by default to home category
+ ProductScreen.do.clickHomeCategory();
+
+ // Clicking product multiple times should increment quantity
+ ProductScreen.do.clickDisplayedProduct('Desk Organizer');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '1.0', '5.10');
+ ProductScreen.do.clickDisplayedProduct('Desk Organizer');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '2.0', '10.20');
+
+ // Clicking product should add new orderline and select the orderline
+ // If orderline exists, increment the quantity
+ ProductScreen.do.clickDisplayedProduct('Letter Tray');
+ ProductScreen.check.selectedOrderlineHas('Letter Tray', '1.0', '4.80');
+ ProductScreen.do.clickDisplayedProduct('Desk Organizer');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '3.0', '15.30');
+
+ // Check effects of clicking numpad buttons
+ ProductScreen.do.clickOrderline('Letter Tray', '1');
+ ProductScreen.check.selectedOrderlineHas('Letter Tray', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Letter Tray', '0.0', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '3', '15.30');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '0.0', '0.0');
+ ProductScreen.do.pressNumpad('1');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '1.0', '5.1');
+ ProductScreen.do.pressNumpad('2');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '12.0', '61.2');
+ ProductScreen.do.pressNumpad('3');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.0', '627.3');
+ ProductScreen.do.pressNumpad('. 5');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '629.85');
+ ProductScreen.do.pressNumpad('Price');
+ ProductScreen.do.pressNumpad('1');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '123.5');
+ ProductScreen.do.pressNumpad('1 .');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '1,358.5');
+ ProductScreen.do.pressNumpad('Disc');
+ ProductScreen.do.pressNumpad('5 .');
+ ProductScreen.check.selectedOrderlineHas('Desk Organizer', '123.5', '1,290.58');
+ ProductScreen.do.pressNumpad('Qty');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.orderIsEmpty();
+
+ // Check different subcategories
+ ProductScreen.do.clickSubcategory('Desks');
+ ProductScreen.check.productIsDisplayed('Desk Pad');
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.do.clickSubcategory('Miscellaneous');
+ ProductScreen.check.productIsDisplayed('Whiteboard Pen');
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.do.clickSubcategory('Chairs');
+ ProductScreen.check.productIsDisplayed('Letter Tray');
+ ProductScreen.do.clickHomeCategory();
+
+ // Add multiple orderlines then delete each of them until empty
+ ProductScreen.do.clickDisplayedProduct('Whiteboard Pen');
+ ProductScreen.do.clickDisplayedProduct('Wall Shelf Unit');
+ ProductScreen.do.clickDisplayedProduct('Small Shelf');
+ ProductScreen.do.clickDisplayedProduct('Magnetic Board');
+ ProductScreen.do.clickDisplayedProduct('Monitor Stand');
+ ProductScreen.do.clickOrderline('Whiteboard Pen', '1.0');
+ ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Whiteboard Pen', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0');
+ ProductScreen.do.clickOrderline('Wall Shelf Unit', '1.0');
+ ProductScreen.check.selectedOrderlineHas('Wall Shelf Unit', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Wall Shelf Unit', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0');
+ ProductScreen.do.clickOrderline('Small Shelf', '1.0');
+ ProductScreen.check.selectedOrderlineHas('Small Shelf', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Small Shelf', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0');
+ ProductScreen.do.clickOrderline('Magnetic Board', '1.0');
+ ProductScreen.check.selectedOrderlineHas('Magnetic Board', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Magnetic Board', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Monitor Stand', '1.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.selectedOrderlineHas('Monitor Stand', '0.0');
+ ProductScreen.do.pressNumpad('Backspace');
+ ProductScreen.check.orderIsEmpty();
+
+ Tour.register('ProductScreenTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js b/addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js
new file mode 100644
index 00000000..2e330a9a
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/ReceiptScreen.tour.js
@@ -0,0 +1,61 @@
+odoo.define('point_of_sale.tour.ReceiptScreen', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { NumberPopup } = require('point_of_sale.tour.NumberPopupTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ const Tour = require('web_tour.tour');
+
+ startSteps();
+
+ // press close button in receipt screen
+ ProductScreen.exec.addOrderline('Letter Tray', '10', '5');
+ ProductScreen.check.selectedOrderlineHas('Letter Tray', '10');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.check.validateButtonIsHighlighted(true);
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.receiptIsThere();
+ // letter tray has 10% tax (search SRC)
+ ReceiptScreen.check.totalAmountContains('55.0');
+ ReceiptScreen.do.clickNextOrder();
+
+ // send email in receipt screen
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.exec.addOrderline('Desk Pad', '6', '5', '30.0');
+ ProductScreen.exec.addOrderline('Whiteboard Pen', '6', '6', '36.0');
+ ProductScreen.exec.addOrderline('Monitor Stand', '6', '1', '6.0');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.pressNumpad('7 0');
+ PaymentScreen.check.remainingIs('2.0');
+ PaymentScreen.do.pressNumpad('0');
+ PaymentScreen.check.remainingIs('0.00');
+ PaymentScreen.check.changeIs('628.0');
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.receiptIsThere();
+ ReceiptScreen.check.totalAmountContains('72.0');
+ ReceiptScreen.do.setEmail('test@receiptscreen.com');
+ ReceiptScreen.do.clickSend();
+ ReceiptScreen.check.emailIsSuccessful();
+ ReceiptScreen.do.clickNextOrder();
+
+ // order with tip
+ // check if tip amount is displayed
+ ProductScreen.exec.addOrderline('Desk Pad', '6', '5');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickTipButton();
+ NumberPopup.do.pressNumpad('1');
+ NumberPopup.check.inputShownIs('1');
+ NumberPopup.do.clickConfirm();
+ PaymentScreen.check.emptyPaymentlines('31.0');
+ PaymentScreen.do.clickPaymentMethod('Cash');
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.receiptIsThere();
+ ReceiptScreen.check.totalAmountContains('$ 30.00 + $ 1.00 tip');
+ ReceiptScreen.do.clickNextOrder();
+
+ Tour.register('ReceiptScreenTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js b/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js
new file mode 100644
index 00000000..a26c0b36
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/TicketScreen.tour.js
@@ -0,0 +1,54 @@
+odoo.define('point_of_sale.tour.TicketScreen', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { TicketScreen } = require('point_of_sale.tour.TicketScreenTourMethods');
+ const { Chrome } = require('point_of_sale.tour.ChromeTourMethods');
+ const { getSteps, startSteps } = require('point_of_sale.tour.utils');
+ var Tour = require('web_tour.tour');
+
+ startSteps();
+
+ ProductScreen.do.clickHomeCategory();
+ ProductScreen.exec.addOrderline('Desk Pad', '1', '2');
+ ProductScreen.do.clickCustomerButton();
+ ProductScreen.do.clickCustomer('Nicole Ford');
+ ProductScreen.do.clickSetCustomer();
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.nthRowContains(2, 'Nicole Ford');
+ TicketScreen.do.clickNewTicket();
+ ProductScreen.exec.addOrderline('Desk Pad', '1', '3');
+ ProductScreen.do.clickCustomerButton();
+ ProductScreen.do.clickCustomer('Brandon Freeman');
+ ProductScreen.do.clickSetCustomer();
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.check.isShown();
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.nthRowContains(3, 'Brandon Freeman');
+ TicketScreen.do.clickNewTicket();
+ ProductScreen.exec.addOrderline('Desk Pad', '1', '4');
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.do.clickPaymentMethod('Bank');
+ PaymentScreen.do.clickValidate();
+ ReceiptScreen.check.isShown();
+ Chrome.do.clickTicketButton();
+ TicketScreen.check.nthRowContains(4, 'Receipt');
+ TicketScreen.do.selectFilter('Receipt');
+ TicketScreen.check.nthRowContains(2, 'Receipt');
+ TicketScreen.do.selectFilter('Payment');
+ TicketScreen.check.nthRowContains(2, 'Payment');
+ TicketScreen.do.selectFilter('Ongoing');
+ TicketScreen.check.nthRowContains(2, 'Ongoing');
+ TicketScreen.do.selectFilter('All');
+ TicketScreen.check.nthRowContains(4, 'Receipt');
+ TicketScreen.do.search('Customer', 'Nicole');
+ TicketScreen.check.nthRowContains(2, 'Nicole');
+ TicketScreen.do.search('Customer', 'Brandon');
+ TicketScreen.check.nthRowContains(2, 'Brandon');
+ TicketScreen.do.search('Receipt Number', '-0003');
+ TicketScreen.check.nthRowContains(2, 'Receipt');
+
+ Tour.register('TicketScreenTour', { test: true, url: '/pos/ui' }, getSteps());
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js
new file mode 100644
index 00000000..30609a9f
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js
@@ -0,0 +1,42 @@
+odoo.define('point_of_sale.tour.ChromeTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ confirmPopup() {
+ return [
+ {
+ content: 'confirm popup',
+ trigger: '.popups .modal-dialog .button.confirm',
+ },
+ ];
+ }
+ clickOrderManagementButton() {
+ return [
+ {
+ content: 'check order management button is shown',
+ trigger: '.pos .pos-rightheader .order-management',
+ run: () => {},
+ },
+ {
+ content: 'click order management button',
+ trigger: '.pos .pos-rightheader .order-management',
+ },
+ ];
+ }
+ clickTicketButton() {
+ return [
+ {
+ trigger: '.pos-topheader .ticket-button',
+ },
+ {
+ trigger: '.subwindow .ticket-screen',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return createTourMethods('Chrome', Do);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js
new file mode 100644
index 00000000..d6be643e
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ClientListScreenTourMethods.js
@@ -0,0 +1,57 @@
+odoo.define('point_of_sale.tour.ClientListScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickClient(name) {
+ return [
+ {
+ content: `click client '${name}' from client list screen`,
+ trigger: `.clientlist-screen .client-list-contents .client-line td:contains("${name}")`,
+ },
+ {
+ content: `check if client '${name}' is highlighted`,
+ trigger: `.clientlist-screen .client-list-contents .client-line.highlight td:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+ clickSet() {
+ return [
+ {
+ content: 'check if set button shown',
+ trigger: '.clientlist-screen .button.next.highlight',
+ run: () => {},
+ },
+ {
+ content: 'click set button',
+ trigger: '.clientlist-screen .button.next.highlight',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'client list screen is shown',
+ trigger: '.pos-content .clientlist-screen',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ class Execute {
+ setClient(name) {
+ const steps = [];
+ steps.push(...this._do.clickClient(name));
+ steps.push(...this._do.clickSet());
+ return steps;
+ }
+ }
+
+ return createTourMethods('ClientListScreen', Do, Check, Execute);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js
new file mode 100644
index 00000000..c361a532
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/CompositeTourMethods.js
@@ -0,0 +1,23 @@
+odoo.define('point_of_sale.tour.CompositeTourMethods', function (require) {
+ 'use strict';
+
+ const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
+ const { ReceiptScreen } = require('point_of_sale.tour.ReceiptScreenTourMethods');
+ const { PaymentScreen } = require('point_of_sale.tour.PaymentScreenTourMethods');
+ const { ClientListScreen } = require('point_of_sale.tour.ClientListScreenTourMethods');
+
+ function makeFullOrder({ orderlist, customer, payment, ntimes = 1 }) {
+ for (let i = 0; i < ntimes; i++) {
+ ProductScreen.exec.addMultiOrderlines(...orderlist);
+ if (customer) {
+ ProductScreen.do.clickCustomerButton();
+ ClientListScreen.exec.setClient(customer);
+ }
+ ProductScreen.do.clickPayButton();
+ PaymentScreen.exec.pay(...payment);
+ ReceiptScreen.exec.nextOrder();
+ }
+ }
+
+ return { makeFullOrder };
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js
new file mode 100644
index 00000000..3d8c07cf
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ErrorPopupTourMethods.js
@@ -0,0 +1,30 @@
+odoo.define('point_of_sale.tour.ErrorPopupTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickConfirm() {
+ return [
+ {
+ content: 'click confirm button',
+ trigger: '.popup-error .footer .cancel',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'error popup is shown',
+ trigger: '.modal-dialog .popup-error',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return createTourMethods('ErrorPopup', Do, Check);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js
new file mode 100644
index 00000000..c12d0d02
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/NumberPopupTourMethods.js
@@ -0,0 +1,72 @@
+odoo.define('point_of_sale.tour.NumberPopupTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ /**
+ * Note: Maximum of 2 characters because NumberBuffer only allows 2 consecutive
+ * fast inputs. Fast inputs is the case in tours.
+ *
+ * @param {String} keys space-separated input keys
+ */
+ pressNumpad(keys) {
+ const numberChars = '0 1 2 3 4 5 6 7 8 9 C'.split(' ');
+ const modeButtons = '+1 +10 +2 +20 +5 +50'.split(' ');
+ const decimalSeparators = ', .'.split(' ');
+ function generateStep(key) {
+ let trigger;
+ if (numberChars.includes(key)) {
+ trigger = `.popup-numpad .number-char:contains("${key}")`;
+ } else if (modeButtons.includes(key)) {
+ trigger = `.popup-numpad .mode-button:contains("${key}")`;
+ } else if (key === 'Backspace') {
+ trigger = `.popup-numpad .numpad-backspace`;
+ } else if (decimalSeparators.includes(key)) {
+ trigger = `.popup-numpad .number-char.dot`;
+ }
+ return {
+ content: `'${key}' pressed in numpad`,
+ trigger,
+ };
+ }
+ return keys.split(' ').map(generateStep);
+ }
+ clickConfirm() {
+ return [
+ {
+ content: 'click confirm button',
+ trigger: '.popup-number .footer .confirm',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'number popup is shown',
+ trigger: '.modal-dialog .popup-number',
+ run: () => {},
+ },
+ ];
+ }
+ inputShownIs(val) {
+ return [
+ {
+ content: 'number input element check',
+ trigger: '.modal-dialog .popup-number .popup-input',
+ run: () => {},
+ },
+ {
+ content: `input shown is '${val}'`,
+ trigger: `.modal-dialog .popup-number .popup-input:contains("${val}")`,
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return createTourMethods('NumberPopup', Do, Check);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js
new file mode 100644
index 00000000..26e48589
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/OrderManagementScreenTourMethods.js
@@ -0,0 +1,180 @@
+odoo.define('point_of_sale.tour.OrderManagementScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickBack() {
+ return [
+ {
+ content: 'order management screen, click back button',
+ trigger: '.order-management-screen .control-panel .button.back',
+ },
+ ];
+ }
+ clickOrder(name, [otherCol, otherColVal] = [null, null]) {
+ let trigger = `.order-management-screen .order-list .order-row .item.name:contains("${name}")`;
+ if (otherCol) {
+ trigger = `${trigger} ~ .item.${otherCol}:contains("${otherColVal}")`;
+ }
+ return [
+ {
+ content: `clicking order '${name}' from orderlist`,
+ trigger,
+ },
+ ];
+ }
+ clickInvoiceButton() {
+ return [
+ {
+ content: 'click invoice button',
+ trigger: '.order-management-screen .control-button span:contains("Invoice")',
+ },
+ ];
+ }
+ clickPrintReceiptButton() {
+ return [
+ {
+ content: 'click reprint receipt button',
+ trigger: '.order-management-screen .control-button span:contains("Print Receipt")'
+ }
+ ]
+ }
+ clickCustomerButton() {
+ return [
+ {
+ content: 'click customer button',
+ trigger: '.order-management-screen .actionpad .button.set-customer',
+ },
+ ];
+ }
+ closeReceipt() {
+ return [
+ {
+ content: 'close receipt',
+ trigger: '.receipt-screen .button.back',
+ }
+ ]
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'order management screen is shown',
+ trigger: '.pos .pos-content .order-management-screen',
+ run: () => {},
+ },
+ ];
+ }
+ orderlistHas({ orderName, total, customer }) {
+ const steps = [];
+ steps.push({
+ content: `order list has row having: name '${orderName}', total '${total}'`,
+ trigger: `.order-list .order-row .item:contains("${orderName}") ~ .item:contains("${total}")`,
+ run: () => {},
+ });
+ if (customer) {
+ steps.push({
+ content: `order list has row having: name '${orderName}', customer '${customer}'`,
+ trigger: `.order-list .order-row .item:contains("${orderName}") ~ .item:contains("${customer}")`,
+ run: () => {},
+ });
+ }
+ return steps;
+ }
+ highlightedOrderRowHas(name) {
+ return [
+ {
+ content: `order '${name}' in orderlist is highligted`,
+ trigger: `.order-list .order-row.highlight:has(> .item:contains("${name}"))`,
+ run: () => {},
+ },
+ ];
+ }
+ orderRowIsNotHighlighted(name) {
+ return [
+ {
+ content: `order '${name}' in orderlist is not highligted`,
+ trigger: `.order-list .order-row:not(:has(.highlight)):has(> .item:contains("${name}"))`,
+ run: () => {},
+ },
+ ];
+ }
+ orderDetailsHas({ lines, total }) {
+ const steps = [];
+ for (let { product, quantity } of lines) {
+ steps.push({
+ content: `order details has product '${product}' and quantity '${quantity}'`,
+ trigger: `.orderlines .product-name:contains("${product}") ~ .info strong:contains("${quantity}")`,
+ run: () => {},
+ });
+ }
+ if (total) {
+ steps.push({
+ content: `order details has total amount of ${total}`,
+ trigger: `.order-container .summary .total .value:contains("${total}")`,
+ run: () => {},
+ });
+ }
+ return steps;
+ }
+ customerIs(name) {
+ return [
+ {
+ content: `set customer is '${name}'`,
+ trigger: `.order-management-screen .actionpad .set-customer:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+ reprintReceiptIsShown() {
+ return [
+ {
+ content: 'reprint receipt screen is shown',
+ trigger: '.pos .receipt-screen',
+ run: () => {},
+ }
+ ]
+ }
+ receiptChangeIs(amount) {
+ return [
+ {
+ content: `receipt change is ${amount}`,
+ trigger: `.pos-receipt-amount.receipt-change:contains("${amount}")`,
+ run: () => {},
+ }
+ ]
+ }
+ receiptOrderDataContains(orderInfo) {
+ return [
+ {
+ content: `order data contains ${orderInfo}`,
+ trigger: `.pos-receipt-order-data:contains("${orderInfo}")`,
+ run: () => {},
+ }
+ ]
+ }
+ receiptAmountIs(amount) {
+ return [
+ {
+ content: `receipt amount is ${amount}`,
+ trigger: `.pos-receipt-amount:contains("${amount}")`,
+ run: () => {},
+ }
+ ]
+ }
+ isNotHidden() {
+ return [
+ {
+ content: 'order management screen is not hidden',
+ trigger: `.order-management-screen:not(:has(.oe_hidden))`,
+ run: () => {},
+ }
+ ]
+ }
+ }
+
+ return createTourMethods('OrderManagementScreen', Do, Check);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js
new file mode 100644
index 00000000..93a5cef6
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js
@@ -0,0 +1,215 @@
+odoo.define('point_of_sale.tour.PaymentScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickPaymentMethod(name) {
+ return [
+ {
+ content: `click '${name}' payment method`,
+ trigger: `.paymentmethods .button.paymentmethod:contains("${name}")`,
+ },
+ ];
+ }
+
+ /**
+ * Delete the paymentline having the given payment method name and amount.
+ * @param {String} name payment method
+ * @param {String} amount
+ */
+ clickPaymentlineDelButton(name, amount) {
+ return [
+ {
+ content: `delete ${name} paymentline with ${amount} amount`,
+ trigger: `.paymentlines .paymentline .payment-name:contains("${name}") ~ .delete-button`,
+ },
+ ];
+ }
+
+ clickEmailButton() {
+ return [
+ {
+ content: `click email button`,
+ trigger: `.payment-buttons .js_email`,
+ },
+ ];
+ }
+
+ clickTipButton() {
+ return [
+ {
+ trigger: `.payment-buttons .js_tip`,
+ },
+ ];
+ }
+
+ clickInvoiceButton() {
+ return [{ content: 'click invoice button', trigger: '.payment-buttons .js_invoice' }];
+ }
+
+ clickValidate() {
+ return [
+ {
+ content: 'validate payment',
+ trigger: `.payment-screen .button.next.highlight`,
+ },
+ ];
+ }
+
+ /**
+ * Press the numpad in sequence based on the given space-separated keys.
+ * Note: Maximum of 2 characters because NumberBuffer only allows 2 consecutive
+ * fast inputs. Fast inputs is the case in tours.
+ *
+ * @param {String} keys space-separated numpad keys
+ */
+ pressNumpad(keys) {
+ const numberChars = '. +/- 0 1 2 3 4 5 6 7 8 9'.split(' ');
+ const modeButtons = '+10 +20 +50'.split(' ');
+ function generateStep(key) {
+ let trigger;
+ if (numberChars.includes(key)) {
+ trigger = `.payment-numpad .number-char:contains("${key}")`;
+ } else if (modeButtons.includes(key)) {
+ trigger = `.payment-numpad .mode-button:contains("${key}")`;
+ } else if (key === 'Backspace') {
+ trigger = `.payment-numpad .number-char img[alt="Backspace"]`;
+ }
+ return {
+ content: `'${key}' pressed in payment numpad`,
+ trigger,
+ };
+ }
+ return keys.split(' ').map(generateStep);
+ }
+
+ clickBack() {
+ return [
+ {
+ content: 'click back button',
+ trigger: '.payment-screen .button.back',
+ },
+ ];
+ }
+
+ clickTipButton() {
+ return [
+ {
+ trigger: '.payment-screen .button.js_tip',
+ },
+ ]
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'payment screen is shown',
+ trigger: '.pos .payment-screen',
+ run: () => {},
+ },
+ ];
+ }
+ /**
+ * Check if change is the provided amount.
+ * @param {String} amount
+ */
+ changeIs(amount) {
+ return [
+ {
+ content: `change is ${amount}`,
+ trigger: `.payment-status-change .amount:contains("${amount}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ /**
+ * Check if the remaining is the provided amount.
+ * @param {String} amount
+ */
+ remainingIs(amount) {
+ return [
+ {
+ content: `remaining amount is ${amount}`,
+ trigger: `.payment-status-remaining .amount:contains("${amount}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ /**
+ * Check if validate button is highlighted.
+ * @param {Boolean} isHighlighted
+ */
+ validateButtonIsHighlighted(isHighlighted = true) {
+ return [
+ {
+ content: `validate button is ${
+ isHighlighted ? 'highlighted' : 'not highligted'
+ }`,
+ trigger: isHighlighted
+ ? `.payment-screen .button.next.highlight`
+ : `.payment-screen .button.next:not(:has(.highlight))`,
+ run: () => {},
+ },
+ ];
+ }
+
+ /**
+ * Check if the paymentlines are empty. Also provide the amount to pay.
+ * @param {String} amountToPay
+ */
+ emptyPaymentlines(amountToPay) {
+ return [
+ {
+ content: `there are no paymentlines`,
+ trigger: `.paymentlines-empty`,
+ run: () => {},
+ },
+ {
+ content: `amount to pay is '${amountToPay}'`,
+ trigger: `.paymentlines-empty .total:contains("${amountToPay}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ /**
+ * Check if the selected paymentline has the given payment method and amount.
+ * @param {String} paymentMethodName
+ * @param {String} amount
+ */
+ selectedPaymentlineHas(paymentMethodName, amount) {
+ return [
+ {
+ content: `line paid via '${paymentMethodName}' is selected`,
+ trigger: `.paymentlines .paymentline.selected .payment-name:contains("${paymentMethodName}")`,
+ run: () => {},
+ },
+ {
+ content: `amount tendered in the line is '${amount}'`,
+ trigger: `.paymentlines .paymentline.selected .payment-amount:contains("${amount}")`,
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ class Execute {
+ pay(method, amount) {
+ const steps = [];
+ steps.push(...this._do.clickPaymentMethod(method));
+ for (let char of amount.split('')) {
+ steps.push(...this._do.pressNumpad(char));
+ }
+ steps.push(...this._check.validateButtonIsHighlighted());
+ steps.push(...this._do.clickValidate());
+ return steps;
+ }
+ }
+
+ return createTourMethods('PaymentScreen', Do, Check, Execute);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js
new file mode 100644
index 00000000..5d10f9fd
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ProductConfiguratorTourMethods.js
@@ -0,0 +1,77 @@
+odoo.define('point_of_sale.tour.ProductConfiguratorTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ pickRadio(name) {
+ return [
+ {
+ content: `picking radio attribute with name ${name}`,
+ trigger: `.product-configurator-popup .radio_attribute_label:contains('${name}')`,
+ },
+ ];
+ }
+
+ pickSelect(name) {
+ return [
+ {
+ content: `picking select attribute with name ${name}`,
+ trigger: `.product-configurator-popup .configurator_select:has(option:contains('${name}'))`,
+ run: `text ${name}`,
+ },
+ ];
+ }
+
+ pickColor(name) {
+ return [
+ {
+ content: `picking color attribute with name ${name}`,
+ trigger: `.product-configurator-popup .configurator_color[data-color='${name}']`,
+ },
+ ];
+ }
+
+ fillCustomAttribute(value) {
+ return [
+ {
+ content: `filling custom attribute with value ${value}`,
+ trigger: `.product-configurator-popup .custom_value`,
+ run: `text ${value}`,
+ },
+ ];
+ }
+
+ confirmAttributes() {
+ return [
+ {
+ content: `confirming product configuration`,
+ trigger: `.product-configurator-popup .button.confirm`,
+ },
+ ];
+ }
+
+ cancelAttributes() {
+ return [
+ {
+ content: `canceling product configuration`,
+ trigger: `.product-configurator-popup .button.cancel`,
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'product configurator is shown',
+ trigger: '.product-configurator-popup:not(:has(.oe_hidden))',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return createTourMethods('ProductConfigurator', Do, Check);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js
new file mode 100644
index 00000000..69aab18b
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ProductScreenTourMethods.js
@@ -0,0 +1,254 @@
+odoo.define('point_of_sale.tour.ProductScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickDisplayedProduct(name) {
+ return [
+ {
+ content: `click product '${name}'`,
+ trigger: `.product-list .product-name:contains("${name}")`,
+ },
+ ];
+ }
+
+ clickOrderline(name, quantity) {
+ return [
+ {
+ content: `selecting orderline with product '${name}' and quantity '${quantity}'`,
+ trigger: `.order .orderline:not(:has(.selected)) .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
+ },
+ {
+ content: `orderline with product '${name}' and quantity '${quantity}' has been selected`,
+ trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ clickSubcategory(name) {
+ return [
+ {
+ content: `selecting '${name}' subcategory`,
+ trigger: `.products-widget > .products-widget-control .category-simple-button:contains("${name}")`,
+ },
+ {
+ content: `'${name}' subcategory selected`,
+ trigger: `.breadcrumbs .breadcrumb-button:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ clickHomeCategory() {
+ return [
+ {
+ content: `click Home subcategory`,
+ trigger: `.breadcrumbs .breadcrumb-home`,
+ },
+ ];
+ }
+
+ /**
+ * Press the numpad in sequence based on the given space-separated keys.
+ * NOTE: Maximum of 2 characters because NumberBuffer only allows 2 consecutive
+ * fast inputs. Fast inputs is the case in tours.
+ *
+ * @param {String} keys space-separated numpad keys
+ */
+ pressNumpad(keys) {
+ const numberChars = '. 0 1 2 3 4 5 6 7 8 9'.split(' ');
+ const modeButtons = 'Qty Price Disc'.split(' ');
+ function generateStep(key) {
+ let trigger;
+ if (numberChars.includes(key)) {
+ trigger = `.numpad .number-char:contains("${key}")`;
+ } else if (modeButtons.includes(key)) {
+ trigger = `.numpad .mode-button:contains("${key}")`;
+ } else if (key === 'Backspace') {
+ trigger = `.numpad .numpad-backspace`;
+ } else if (key === '+/-') {
+ trigger = `.numpad .numpad-minus`;
+ }
+ return {
+ content: `'${key}' pressed in product screen numpad`,
+ trigger,
+ };
+ }
+ return keys.split(' ').map(generateStep);
+ }
+
+ clickPayButton() {
+ return [
+ { content: 'click pay button', trigger: '.actionpad .button.pay' },
+ {
+ content: 'now in payment screen',
+ trigger: '.pos-content .payment-screen',
+ run: () => {},
+ },
+ ];
+ }
+
+ clickCustomerButton() {
+ return [
+ { content: 'click customer button', trigger: '.actionpad .button.set-customer' },
+ {
+ content: 'customer screen is shown',
+ trigger: '.pos-content .clientlist-screen',
+ run: () => {},
+ },
+ ];
+ }
+
+ clickCustomer(name) {
+ return [
+ {
+ content: `select customer '${name}'`,
+ trigger: `.clientlist-screen .client-line td:contains("${name}")`,
+ },
+ {
+ content: `client line '${name}' is highlighted`,
+ trigger: `.clientlist-screen .client-line.highlight td:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ clickSetCustomer() {
+ return [
+ {
+ content: 'click set customer',
+ trigger: '.clientlist-screen .button.next.highlight',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'product screen is shown',
+ trigger: '.product-screen:not(:has(.oe_hidden))',
+ run: () => {},
+ },
+ ];
+ }
+ selectedOrderlineHas(name, quantity, price) {
+ const res = [
+ {
+ // check first if the order widget is there and has orderlines
+ content: 'order widget has orderlines',
+ trigger: '.order .orderlines',
+ run: () => {},
+ },
+ {
+ content: `'${name}' is selected`,
+ trigger: `.order .orderline.selected .product-name:contains("${name}")`,
+ run: function () {}, // it's a check
+ },
+ ];
+ if (quantity) {
+ res.push({
+ content: `selected line has ${quantity} quantity`,
+ trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .info-list em:contains("${quantity}")`,
+ run: function () {}, // it's a check
+ });
+ }
+ if (price) {
+ res.push({
+ content: `selected line has total price of ${price}`,
+ trigger: `.order .orderline.selected .product-name:contains("${name}") ~ .price:contains("${price}")`,
+ run: function () {}, // it's a check
+ });
+ }
+ return res;
+ }
+ orderIsEmpty() {
+ return [
+ {
+ content: `order is empty`,
+ trigger: `.order .order-empty`,
+ run: () => {},
+ },
+ ];
+ }
+
+ productIsDisplayed(name) {
+ return [
+ {
+ content: `'${name}' should be displayed`,
+ trigger: `.product-list .product-name:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+ totalAmountIs(amount) {
+ return [
+ {
+ content: `order total amount is '${amount}'`,
+ trigger: `.order-container .order .summary .value:contains("${amount}")`,
+ run: () => {},
+ }
+ ]
+ }
+ modeIsActive(mode) {
+ return [
+ {
+ content: `'${mode}' is active`,
+ trigger: `.numpad button.selected-mode:contains('${mode}')`,
+ run: function () {},
+ },
+ ];
+ }
+ }
+
+ class Execute {
+ /**
+ * Create an orderline for the given `productName` and `quantity`.
+ * - If `unitPrice` is provided, price of the product of the created line
+ * is changed to that value.
+ * - If `expectedTotal` is provided, the created orderline (which is the currently
+ * selected orderline) is checked if it contains the correct quantity and total
+ * price.
+ *
+ * @param {string} productName
+ * @param {string} quantity
+ * @param {string} unitPrice
+ * @param {string} expectedTotal
+ */
+ addOrderline(productName, quantity, unitPrice = undefined, expectedTotal = undefined) {
+ const res = this._do.clickDisplayedProduct(productName);
+ if (unitPrice) {
+ res.push(...this._do.pressNumpad('Price'));
+ res.push(...this._check.modeIsActive('Price'));
+ res.push(...this._do.pressNumpad(unitPrice.toString().split('').join(' ')));
+ res.push(...this._do.pressNumpad('Qty'));
+ res.push(...this._check.modeIsActive('Qty'));
+ }
+ for (let char of quantity.toString()) {
+ if ('.0123456789'.includes(char)) {
+ res.push(...this._do.pressNumpad(char));
+ } else if ('-'.includes(char)) {
+ res.push(...this._do.pressNumpad('+/-'));
+ }
+ }
+ if (expectedTotal) {
+ res.push(...this._check.selectedOrderlineHas(productName, quantity, expectedTotal));
+ } else {
+ res.push(...this._check.selectedOrderlineHas(productName, quantity));
+ }
+ return res;
+ }
+ addMultiOrderlines(...list) {
+ const steps = [];
+ for (let [product, qty, price] of list) {
+ steps.push(...this.addOrderline(product, qty, price));
+ }
+ return steps;
+ }
+ }
+
+ return createTourMethods('ProductScreen', Do, Check, Execute);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js
new file mode 100644
index 00000000..49c26703
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/ReceiptScreenTourMethods.js
@@ -0,0 +1,79 @@
+odoo.define('point_of_sale.tour.ReceiptScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickNextOrder() {
+ return [
+ {
+ content: 'go to next screen',
+ trigger: '.receipt-screen .button.next.highlight',
+ },
+ ];
+ }
+ setEmail(email) {
+ return [
+ {
+ trigger: '.receipt-screen .input-email input',
+ run: `text ${email}`,
+ },
+ ];
+ }
+ clickSend(isHighlighted = true) {
+ return [
+ {
+ trigger: `.receipt-screen .input-email .send${isHighlighted ? '.highlight' : ''}`,
+ },
+ ];
+ }
+ }
+
+ class Check {
+ isShown() {
+ return [
+ {
+ content: 'receipt screen is shown',
+ trigger: '.pos .receipt-screen',
+ run: () => {},
+ },
+ ];
+ }
+
+ receiptIsThere() {
+ return [
+ {
+ content: 'there should be the receipt',
+ trigger: '.receipt-screen .pos-receipt',
+ run: () => {},
+ },
+ ];
+ }
+
+ totalAmountContains(value) {
+ return [
+ {
+ trigger: `.receipt-screen .top-content h1:contains("${value}")`,
+ run: () => {},
+ },
+ ];
+ }
+
+ emailIsSuccessful() {
+ return [
+ {
+ trigger: `.receipt-screen .notice.successful`,
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ class Execute {
+ nextOrder() {
+ return [...this._check.isShown(), ...this._do.clickNextOrder()];
+ }
+ }
+
+ return createTourMethods('ReceiptScreen', Do, Check, Execute);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js
new file mode 100644
index 00000000..bbe4fc2d
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/SelectionPopupTourMethods.js
@@ -0,0 +1,39 @@
+odoo.define('point_of_sale.tour.SelectionPopupTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickItem(name) {
+ return [
+ {
+ content: `click selection '${name}'`,
+ trigger: `.selection-item:contains("${name}")`,
+ },
+ ];
+ }
+ }
+
+ class Check {
+ hasSelectionItem(name) {
+ return [
+ {
+ content: `selection popup has '${name}'`,
+ trigger: `.selection-item:contains("${name}")`,
+ run: () => {},
+ },
+ ];
+ }
+ isShown() {
+ return [
+ {
+ content: 'selection popup is shown',
+ trigger: '.modal-dialog .popup-selection',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ return createTourMethods('SelectionPopup', Do, Check);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js
new file mode 100644
index 00000000..fe8f8127
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/TicketScreenTourMethods.js
@@ -0,0 +1,107 @@
+odoo.define('point_of_sale.tour.TicketScreenTourMethods', function (require) {
+ 'use strict';
+
+ const { createTourMethods } = require('point_of_sale.tour.utils');
+
+ class Do {
+ clickNewTicket() {
+ return [{ trigger: '.ticket-screen .highlight' }];
+ }
+ clickDiscard() {
+ return [{ trigger: '.ticket-screen button.discard' }];
+ }
+ selectOrder(orderName) {
+ return [
+ {
+ trigger: `.ticket-screen .order-row > .col:nth-child(2):contains("${orderName}")`,
+ },
+ ];
+ }
+ deleteOrder(orderName) {
+ return [
+ {
+ trigger: `.ticket-screen .orders > .order-row > .col:contains("${orderName}") ~ .col[name="delete"]`,
+ },
+ ];
+ }
+ selectFilter(name) {
+ return [
+ {
+ trigger: `.pos-search-bar .filter`,
+ },
+ {
+ trigger: `.pos-search-bar .filter ul`,
+ run: () => {},
+ },
+ {
+ trigger: `.pos-search-bar .filter ul li:contains("${name}")`,
+ },
+ ];
+ }
+ search(field, searchWord) {
+ return [
+ {
+ trigger: '.pos-search-bar input',
+ run: `text ${searchWord}`,
+ },
+ {
+ /**
+ * Manually trigger keydown event to show the search field list
+ * because the previous step do not trigger keydown event.
+ */
+ trigger: '.pos-search-bar input',
+ run: function () {
+ document
+ .querySelector('.pos-search-bar input')
+ .dispatchEvent(new KeyboardEvent('keydown', { key: '' }));
+ },
+ },
+ {
+ trigger: `.pos-search-bar .search ul li:contains("${field}")`,
+ },
+ ];
+ }
+ settleTips() {
+ return [
+ {
+ trigger: '.ticket-screen .buttons .settle-tips',
+ },
+ ];
+ }
+ }
+
+ class Check {
+ checkStatus(orderName, status) {
+ return [
+ {
+ trigger: `.ticket-screen .order-row > .col:nth-child(2):contains("${orderName}") ~ .col:nth-child(6):contains(${status})`,
+ run: () => {},
+ },
+ ];
+ }
+ /**
+ * Check if the nth row contains the given string.
+ * Note that 1st row is the header-row.
+ */
+ nthRowContains(n, string) {
+ return [
+ {
+ trigger: `.ticket-screen .orders > .order-row:nth-child(${n}):contains("${string}")`,
+ run: () => {},
+ },
+ ];
+ }
+ noNewTicketButton() {
+ return [
+ {
+ trigger: '.ticket-screen .controls .buttons:nth-child(1):has(.discard)',
+ run: () => {},
+ },
+ ];
+ }
+ }
+
+ class Execute {}
+
+ return createTourMethods('TicketScreen', Do, Check, Execute);
+});
diff --git a/addons/point_of_sale/static/tests/tours/helpers/utils.js b/addons/point_of_sale/static/tests/tours/helpers/utils.js
new file mode 100644
index 00000000..e8fcc591
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/helpers/utils.js
@@ -0,0 +1,153 @@
+odoo.define('point_of_sale.tour.utils', function (require) {
+ 'use strict';
+
+ const config = require('web.config');
+
+ /**
+ * USAGE
+ * -----
+ *
+ * ```
+ * const { startSteps, getSteps, createTourMethods } = require('point_of_sale.utils');
+ * const { Other } = require('point_of_sale.tour.OtherMethods');
+ *
+ * // 1. Define classes Do, Check and Execute having methods that
+ * // each return array of tour steps.
+ * class Do {
+ * click() {
+ * return [{ content: 'click button', trigger: '.button' }];
+ * }
+ * }
+ * class Check {
+ * isHighligted() {
+ * return [{ content: 'button is highlighted', trigger: '.button.highlight', run: () => {} }];
+ * }
+ * }
+ * // Notice that Execute has access to methods defined in Do and Check classes
+ * // Also, we can compose steps from other module.
+ * class Execute {
+ * complexSteps() {
+ * return [...this._do.click(), ...this._check.isHighlighted(), ...Other._exec.complicatedSteps()];
+ * }
+ * }
+ *
+ * // 2. Instantiate these class definitions using `createTourMethods`.
+ * // The returned object gives access to the defined methods above
+ * // thru the do, check and exec properties.
+ * // - do gives access to the methods defined in Do class
+ * // - check gives access to the methods defined in Check class
+ * // - exec gives access to the methods defined in Execute class
+ * const Screen = createTourMethods('Screen', Do, Check, Execute);
+ *
+ * // 3. Call `startSteps` to start empty steps.
+ * startSteps();
+ *
+ * // 4. Call the tour methods to populate the steps created by `startSteps`.
+ * Screen.do.click(); // return of this method call is added to steps created by startSteps
+ * Screen.check.isHighlighted() // same as above
+ * Screen.exec.complexSteps() // same as above
+ *
+ * // 5. Call `getSteps` which returns the generated tour steps.
+ * const steps = getSteps();
+ * ```
+ */
+ let steps = [];
+
+ function startSteps() {
+ // always start by waiting for loading to finish
+ steps = [
+ {
+ content: 'wait for loading to finish',
+ trigger: 'body:not(:has(.loader))',
+ run: function () {},
+ },
+ ];
+ }
+
+ function getSteps() {
+ return steps;
+ }
+
+ // this is the method decorator
+ // when the method is called, the generated steps are added
+ // to steps
+ const methodProxyHandler = {
+ apply(target, thisArg, args) {
+ const res = target.call(thisArg, ...args);
+ if (config.isDebug()) {
+ // This step is added before the real steps.
+ // Very useful when debugging because we know which
+ // method call failed and what were the parameters.
+ const constructor = thisArg.constructor.name.split(' ')[1];
+ const methodName = target.name.split(' ')[1];
+ const argList = args
+ .map((a) => (typeof a === 'string' ? `'${a}'` : `${a}`))
+ .join(', ');
+ steps.push({
+ content: `DOING "${constructor}.${methodName}(${argList})"`,
+ trigger: '.pos',
+ run: () => {},
+ });
+ }
+ steps.push(...res);
+ return res;
+ },
+ };
+
+ // we proxy get of the method to decorate the method call
+ const proxyHandler = {
+ get(target, key) {
+ const method = target[key];
+ if (!method) {
+ throw new Error(`Tour method '${key}' is not available.`);
+ }
+ return new Proxy(method.bind(target), methodProxyHandler);
+ },
+ };
+
+ /**
+ * Creates an object with `do`, `check` and `exec` properties which are instances of
+ * the given `Do`, `Check` and `Execute` classes, respectively. Calling methods
+ * automatically adds the returned steps to the steps created by `startSteps`.
+ *
+ * There are however underscored version (_do, _check, _exec).
+ * Calling methods thru the underscored version does not automatically
+ * add the returned steps to the current steps array. Useful when composing
+ * steps from other methods.
+ *
+ * @param {String} name
+ * @param {Function} Do class containing methods which return array of tour steps
+ * @param {Function} Check similar to Do class but the steps are mainly for checking
+ * @param {Function} Execute class containing methods which return array of tour steps
+ * but has access to methods of Do and Check classes via .do and .check,
+ * respectively. Here, we define methods that return tour steps based
+ * on the combination of steps from Do and Check.
+ */
+ function createTourMethods(name, Do, Check = class {}, Execute = class {}) {
+ Object.defineProperty(Do, 'name', { value: `${name}.do` });
+ Object.defineProperty(Check, 'name', { value: `${name}.check` });
+ Object.defineProperty(Execute, 'name', {
+ value: `${name}.exec`,
+ });
+ const methods = { do: new Do(), check: new Check(), exec: new Execute() };
+ // Allow Execute to have access to methods defined in Do and Check
+ // via do and exec, respectively.
+ methods.exec._do = methods.do;
+ methods.exec._check = methods.check;
+ return {
+ Do,
+ Check,
+ Execute,
+ [name]: {
+ do: new Proxy(methods.do, proxyHandler),
+ check: new Proxy(methods.check, proxyHandler),
+ exec: new Proxy(methods.exec, proxyHandler),
+ _do: methods.do,
+ _check: methods.check,
+ _exec: methods.exec,
+ },
+ };
+ }
+
+ return { startSteps, getSteps, createTourMethods };
+});
diff --git a/addons/point_of_sale/static/tests/tours/point_of_sale.js b/addons/point_of_sale/static/tests/tours/point_of_sale.js
new file mode 100644
index 00000000..25f88d3a
--- /dev/null
+++ b/addons/point_of_sale/static/tests/tours/point_of_sale.js
@@ -0,0 +1,436 @@
+odoo.define('point_of_sale.tour.pricelist', function (require) {
+ "use strict";
+
+ var Tour = require('web_tour.tour');
+ var rpc = require('web.rpc');
+ var utils = require('web.utils');
+ var round_di = utils.round_decimals;
+
+ function assert (condition, message) {
+ if (! condition) {
+ throw message || "Assertion failed";
+ }
+ }
+
+ function _build_pricelist_context (pricelist, quantity, date) {
+ return {
+ pricelist: pricelist.id,
+ quantity: quantity,
+ };
+ }
+
+ function compare_backend_frontend (product, pricelist_name, quantity) {
+ return function () {
+ var pricelist = _.findWhere(posmodel.pricelists, {name: pricelist_name});
+ var frontend_price = product.get_price(pricelist, quantity);
+ // ORM applies digits= on non-stored computed field when
+ // reading. It does not however truncate like it does when
+ // storing the field.
+ frontend_price = round_di(frontend_price, posmodel.dp['Product Price']);
+
+ var context = _build_pricelist_context(pricelist, quantity);
+ return rpc.query({model: 'product.product', method: 'read', args: [[product.id], ['price']], context: context})
+ .then(function (backend_result) {
+ var debug_info = _.extend(context, {
+ product: product.id,
+ product_display_name: product.display_name,
+ pricelist_name: pricelist.name,
+ });
+ var backend_price = backend_result[0].price;
+ assert(frontend_price === backend_price,
+ JSON.stringify(debug_info) + ' DOESN\'T MATCH -> ' + backend_price + ' (backend) != ' + frontend_price + ' (frontend)');
+ return Promise.resolve();
+ });
+ };
+ }
+
+ // The global posmodel is only present when the posmodel is instanciated
+ // So, wait for everythiong to be loaded
+ var steps = [{ // Leave category displayed by default
+ content: 'waiting for loading to finish',
+ extra_trigger: 'body .pos:not(:has(.loader))', // Pos has finished loading
+ trigger: 'body:not(.oe_wait)', // WebClient has finished Loading
+ run: function () {
+ var product_wall_shelf = posmodel.db.search_product_in_category(0, 'Wall Shelf Unit')[0];
+ var product_small_shelf = posmodel.db.search_product_in_category(0, 'Small Shelf')[0];
+ var product_magnetic_board = posmodel.db.search_product_in_category(0, 'Magnetic Board')[0];
+ var product_monitor_stand = posmodel.db.search_product_in_category(0, 'Monitor Stand')[0];
+ var product_desk_pad = posmodel.db.search_product_in_category(0, 'Desk Pad')[0];
+ var product_letter_tray = posmodel.db.search_product_in_category(0, 'Letter Tray')[0];
+ var product_whiteboard = posmodel.db.search_product_in_category(0, 'Whiteboard')[0];
+
+ compare_backend_frontend(product_letter_tray, 'Public Pricelist', 0, undefined)()
+ .then(compare_backend_frontend(product_letter_tray, 'Public Pricelist', 1, undefined))
+ .then(compare_backend_frontend(product_letter_tray, 'Fixed', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'Fixed', 1, undefined))
+ .then(compare_backend_frontend(product_small_shelf, 'Fixed', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'Percentage', 1, undefined))
+ .then(compare_backend_frontend(product_small_shelf, 'Percentage', 1, undefined))
+ .then(compare_backend_frontend(product_magnetic_board, 'Percentage', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'Formula', 1, undefined))
+ .then(compare_backend_frontend(product_small_shelf, 'Formula', 1, undefined))
+ .then(compare_backend_frontend(product_magnetic_board, 'Formula', 1, undefined))
+ .then(compare_backend_frontend(product_monitor_stand, 'Formula', 1, undefined))
+ .then(compare_backend_frontend(product_desk_pad, 'Formula', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'min_quantity ordering', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'min_quantity ordering', 2, undefined))
+ .then(compare_backend_frontend(product_letter_tray, 'Category vs no category', 1, undefined))
+ .then(compare_backend_frontend(product_letter_tray, 'Category', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'Product template', 1, undefined))
+ .then(compare_backend_frontend(product_wall_shelf, 'Dates', 1, undefined))
+ .then(compare_backend_frontend(product_small_shelf, 'Pricelist base rounding', 1, undefined))
+ .then(compare_backend_frontend(product_whiteboard, 'Public Pricelist', 1, undefined))
+ .then(function () {
+ $('.pos').addClass('done-testing');
+ });
+ },
+ }];
+
+ steps = steps.concat([{
+ content: "wait for unit tests to finish",
+ trigger: ".pos.done-testing",
+ run: function () {}, // it's a check
+ }, {
+ content: "click category switch",
+ trigger: ".breadcrumb-home",
+ run: 'click',
+ }, {
+ content: "click pricelist button",
+ trigger: ".control-button.o_pricelist_button",
+ }, {
+ content: "verify default pricelist is set",
+ trigger: ".selection-item.selected:contains('Public Pricelist')",
+ run: function () {}, // it's a check
+ }, {
+ content: "select fixed pricelist",
+ trigger: ".selection-item:contains('Fixed')",
+ }, {
+ content: "open customer list",
+ trigger: "button.set-customer",
+ }, {
+ content: "select Deco Addict",
+ trigger: ".client-line:contains('Deco Addict')",
+ }, {
+ content: "confirm selection",
+ trigger: ".clientlist-screen .next",
+ }, {
+ content: "click pricelist button",
+ trigger: ".control-button.o_pricelist_button",
+ }, {
+ content: "verify pricelist changed",
+ trigger: ".selection-item.selected:contains('Public Pricelist')",
+ run: function () {}, // it's a check
+ }, {
+ content: "cancel pricelist dialog",
+ trigger: ".button.cancel:visible",
+ }, {
+ content: "open customer list",
+ trigger: "button.set-customer",
+ }, {
+ content: "select Lumber Inc",
+ trigger: ".client-line:contains('Lumber Inc')",
+ }, {
+ content: "confirm selection",
+ trigger: ".clientlist-screen .next",
+ }, {
+ content: "click pricelist button",
+ trigger: ".control-button.o_pricelist_button",
+ }, {
+ content: "verify pricelist remained public pricelist ('Not loaded' is not available)",
+ trigger: ".selection-item.selected:contains('Public Pricelist')",
+ run: function () {}, // it's a check
+ }, {
+ content: "cancel pricelist dialog",
+ trigger: ".button.cancel:visible",
+ }, {
+ content: "click pricelist button",
+ trigger: ".control-button.o_pricelist_button",
+ }, {
+ content: "select fixed pricelist",
+ trigger: ".selection-item:contains('min_quantity ordering')",
+ }, {
+ content: "order 1 kg shelf",
+ trigger: ".product:contains('Wall Shelf')",
+ }, {
+ content: "change qty to 2 kg",
+ trigger: ".numpad button.input-button:visible:contains('2')",
+ }, {
+ content: "qty of Wall Shelf line should be 2",
+ trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Wall Shelf')",
+ extra_trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Wall Shelf') ~ .info-list .info em:contains('2.0')",
+ run: function() {},
+ }, {
+ content: "verify that unit price of shelf changed to $1",
+ trigger: ".total > .value:contains('$ 2.00')",
+ run: function() {},
+ }, {
+ content: "order different shelf",
+ trigger: ".product:contains('Small Shelf')",
+ }, {
+ content: "Small Shelf line should be selected with quantity 1",
+ trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf')",
+ extra_trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf') ~ .info-list .info em:contains('1.0')",
+ run: function() {}
+ }, {
+ content: "change to price mode",
+ trigger: ".numpad button:contains('Price')",
+ }, {
+ content: "make sure price mode is activated",
+ trigger: ".numpad button.selected-mode:contains('Price')",
+ run: function() {},
+ }, {
+ content: "manually override the unit price of these shelf to $5",
+ trigger: ".numpad button.input-button:visible:contains('5')",
+ }, {
+ content: "Small Shelf line should be selected with unit price of 5",
+ trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf')",
+ extra_trigger: ".order-container .orderlines .orderline.selected .product-name:contains('Small Shelf') ~ .price:contains('5.0')",
+ }, {
+ content: "change back to qty mode",
+ trigger: ".numpad button:contains('Qty')",
+ }, {
+ content: "make sure qty mode is activated",
+ trigger: ".numpad button.selected-mode:contains('Qty')",
+ run: function() {},
+ }, {
+ content: "click pricelist button",
+ trigger: ".control-button.o_pricelist_button",
+ }, {
+ content: "select public pricelist",
+ trigger: ".selection-item:contains('Public Pricelist')",
+ }, {
+ content: "verify that the boni shelf have been recomputed and the shelf have not (their price was manually overridden)",
+ trigger: ".total > .value:contains('$ 8.96')",
+ }, {
+ content: "click pricelist button",
+ trigger: ".control-button.o_pricelist_button",
+ }, {
+ content: "select fixed pricelist",
+ trigger: ".selection-item:contains('min_quantity ordering')",
+ }, {
+ content: "close the Point of Sale frontend",
+ trigger: ".header-button",
+ }, {
+ content: "confirm closing the frontend",
+ trigger: ".header-button",
+ run: function() {}, //it's a check,
+ }]);
+
+ Tour.register('pos_pricelist', { test: true, url: '/pos/ui' }, steps);
+});
+
+odoo.define('point_of_sale.tour.acceptance', function (require) {
+ "use strict";
+
+ var Tour = require("web_tour.tour");
+
+ function add_product_to_order(product_name) {
+ return [{
+ content: 'buy ' + product_name,
+ trigger: '.product-list .product-name:contains("' + product_name + '")',
+ }, {
+ content: 'the ' + product_name + ' have been added to the order',
+ trigger: '.order .product-name:contains("' + product_name + '")',
+ run: function () {},
+ }];
+ }
+
+ function set_fiscal_position_on_order(fp_name) {
+ return [{
+ content: 'set fiscal position',
+ trigger: '.control-button.o_fiscal_position_button',
+ }, {
+ content: 'choose fiscal position ' + fp_name + ' to add to the order',
+ trigger: '.popups .popup .selection .selection-item:contains("' + fp_name + '")',
+ }, {
+ content: 'the fiscal position ' + fp_name + ' has been set to the order',
+ trigger: '.control-button.o_fiscal_position_button:contains("' + fp_name + '")',
+ run: function () {},
+ }];
+ }
+
+ function generate_keypad_steps(amount_str, keypad_selector) {
+ var i, steps = [], current_char;
+ for (i = 0; i < amount_str.length; ++i) {
+ current_char = amount_str[i];
+ steps.push({
+ content: 'press ' + current_char + ' on payment keypad',
+ trigger: keypad_selector + ' .input-button:contains("' + current_char + '"):visible'
+ });
+ }
+ return steps;
+ }
+
+ function press_payment_numpad(val) {
+ return [{
+ content: `press ${val} on payment screen numpad`,
+ trigger: `.payment-numpad .input-button:contains("${val}"):visible`,
+ }]
+ }
+
+ function press_product_numpad(val) {
+ return [{
+ content: `press ${val} on product screen numpad`,
+ trigger: `.numpad .input-button:contains("${val}"):visible`,
+ }]
+ }
+
+ function selected_payment_has(name, val) {
+ return [{
+ content: `selected payment is ${name} and has ${val}`,
+ trigger: `.paymentlines .paymentline.selected .payment-name:contains("${name}")`,
+ extra_trigger: `.paymentlines .paymentline.selected .payment-name:contains("${name}") ~ .payment-amount:contains("${val}")`,
+ run: function () {},
+ }]
+ }
+
+ function selected_orderline_has({ product, price = null, quantity = null }) {
+ const result = [];
+ if (price !== null) {
+ result.push({
+ content: `Selected line has product '${product}' and price '${price}'`,
+ trigger: `.order-container .orderlines .orderline.selected .product-name:contains("${product}") ~ span.price:contains("${price}")`,
+ run: function () {},
+ });
+ }
+ if (quantity !== null) {
+ result.push({
+ content: `Selected line has product '${product}' and quantity '${quantity}'`,
+ trigger: `.order-container .orderlines .orderline.selected .product-name:contains('${product}') ~ .info-list .info em:contains('${quantity}')`,
+ run: function () {},
+ });
+ }
+ return result;
+ }
+
+ function verify_order_total(total_str) {
+ return [{
+ content: 'order total contains ' + total_str,
+ trigger: '.order .total .value:contains("' + total_str + '")',
+ run: function () {}, // it's a check
+ }];
+ }
+
+ function goto_payment_screen_and_select_payment_method() {
+ return [{
+ content: "go to payment screen",
+ trigger: '.button.pay',
+ }, {
+ content: "pay with cash",
+ trigger: '.paymentmethod:contains("Cash")',
+ }];
+ }
+
+ function finish_order() {
+ return [{
+ content: "validate the order",
+ trigger: '.payment-screen .button.next.highlight:visible',
+ }, {
+ content: "verify that the order has been successfully sent to the backend",
+ trigger: ".js_connected:visible",
+ run: function () {},
+ }, {
+ content: "click Next Order",
+ trigger: '.receipt-screen .button.next.highlight:visible',
+ }, {
+ content: "check if we left the receipt screen",
+ trigger: '.pos-content .screen:not(:has(.receipt-screen))',
+ run: function () {},
+ }];
+ }
+
+ var steps = [{
+ content: 'waiting for loading to finish',
+ trigger: 'body:not(:has(.loader))',
+ run: function () {},
+ }, { // Leave category displayed by default
+ content: "click category switch",
+ trigger: ".breadcrumb-home",
+ }];
+
+ steps = steps.concat(add_product_to_order('Desk Organizer'));
+ steps = steps.concat(verify_order_total('5.10'));
+
+ steps = steps.concat(add_product_to_order('Desk Organizer'));
+ steps = steps.concat(verify_order_total('10.20'));
+ steps = steps.concat(goto_payment_screen_and_select_payment_method());
+
+ /* add payment line of only 5.20
+ status:
+ order-total := 10.20
+ total-payment := 11.70
+ expect:
+ remaining := 0.00
+ change := 1.50
+ */
+ steps = steps.concat(press_payment_numpad('5'));
+ steps = steps.concat(selected_payment_has('Cash', '5.0'));
+ steps = steps.concat([{
+ content: "verify remaining",
+ trigger: '.payment-status-remaining .amount:contains("5.20")',
+ run: function () {},
+ }, {
+ content: "verify change",
+ trigger: '.payment-status-change .amount:contains("0.00")',
+ run: function () {},
+ }]);
+
+ /* make additional payment line of 6.50
+ status:
+ order-total := 10.20
+ total-payment := 11.70
+ expect:
+ remaining := 0.00
+ change := 1.50
+ */
+ steps = steps.concat([{
+ content: "pay with cash",
+ trigger: '.paymentmethod:contains("Cash")',
+ }]);
+ steps = steps.concat(selected_payment_has('Cash', '5.2'));
+ steps = steps.concat(press_payment_numpad('6'))
+ steps = steps.concat(selected_payment_has('Cash', '6.0'));
+ steps = steps.concat([{
+ content: "verify remaining",
+ trigger: '.payment-status-remaining .amount:contains("0.00")',
+ run: function () {},
+ }, {
+ content: "verify change",
+ trigger: '.payment-status-change .amount:contains("0.80")',
+ run: function () {},
+ }]);
+
+ steps = steps.concat(finish_order());
+
+ // test opw-672118 orderline subtotal rounding
+ steps = steps.concat(add_product_to_order('Desk Organizer'));
+ steps = steps.concat(selected_orderline_has({product: 'Desk Organizer', quantity: '1.0'}));
+ steps = steps.concat(press_product_numpad('.'))
+ steps = steps.concat(selected_orderline_has({product: 'Desk Organizer', quantity: '0.0', price: '0.0'}));
+ steps = steps.concat(press_product_numpad('9'))
+ steps = steps.concat(selected_orderline_has({product: 'Desk Organizer', quantity: '0.9', price: '4.59'}));
+ steps = steps.concat(press_product_numpad('9'))
+ steps = steps.concat(selected_orderline_has({product: 'Desk Organizer', quantity: '0.99', price: '5.05'}));
+ steps = steps.concat(goto_payment_screen_and_select_payment_method());
+ steps = steps.concat(selected_payment_has('Cash', '5.05'));
+ steps = steps.concat(finish_order());
+
+ // Test fiscal position one2many map (align with backend)
+ steps = steps.concat(add_product_to_order('Letter Tray'));
+ steps = steps.concat(selected_orderline_has({product: 'Letter Tray', quantity: '1.0'}));
+ steps = steps.concat(verify_order_total('5.28'));
+ steps = steps.concat(set_fiscal_position_on_order('FP-POS-2M'));
+ steps = steps.concat(verify_order_total('5.52'));
+
+ steps = steps.concat([{
+ content: "close the Point of Sale frontend",
+ trigger: ".header-button",
+ }, {
+ content: "confirm closing the frontend",
+ trigger: ".header-button.confirm",
+ run: function() {}, //it's a check,
+ }]);
+
+ Tour.register('pos_basic_order', { test: true, url: '/pos/ui' }, steps);
+
+});
diff --git a/addons/point_of_sale/static/tests/unit/helpers/test_env.js b/addons/point_of_sale/static/tests/unit/helpers/test_env.js
new file mode 100644
index 00000000..c4b0b3ec
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/helpers/test_env.js
@@ -0,0 +1,46 @@
+odoo.define('point_of_sale.test_env', async function (require) {
+ 'use strict';
+
+ /**
+ * Many components in PoS are dependent on the PosModel instance (pos).
+ * Therefore, for unit tests that require pos in the Components' env, we
+ * prepared here a test env maker (makePosTestEnv) based on
+ * makeTestEnvironment of web.
+ */
+
+ const makeTestEnvironment = require('web.test_env');
+ const env = require('web.env');
+ const models = require('point_of_sale.models');
+ const Registries = require('point_of_sale.Registries');
+
+ Registries.Component.add(owl.misc.Portal);
+
+ await env.session.is_bound;
+ const pos = new models.PosModel({
+ rpc: env.services.rpc,
+ session: env.session,
+ do_action: async () => {},
+ setLoadingMessage: () => {},
+ setLoadingProgress: () => {},
+ showLoadingSkip: () => {},
+ });
+ await pos.ready;
+
+ /**
+ * @param {Object} env default env
+ * @param {Function} providedRPC mock rpc
+ * @param {Function} providedDoAction mock do_action
+ */
+ function makePosTestEnv(env = {}, providedRPC = null, providedDoAction = null) {
+ env = Object.assign(env, { pos });
+ let posEnv = makeTestEnvironment(env, providedRPC);
+ // Replace rpc in the PosModel instance after loading
+ // data from the server so that every succeeding rpc calls
+ // made by pos are mocked by the providedRPC.
+ pos.rpc = posEnv.rpc;
+ pos.do_action = providedDoAction;
+ return posEnv;
+ }
+
+ return makePosTestEnv;
+});
diff --git a/addons/point_of_sale/static/tests/unit/helpers/test_main.js b/addons/point_of_sale/static/tests/unit/helpers/test_main.js
new file mode 100644
index 00000000..f42e01cb
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/helpers/test_main.js
@@ -0,0 +1,23 @@
+odoo.define('web.web_client', function (require) {
+ // this module is required by the test
+ const { bus } = require('web.core');
+ const WebClient = require('web.AbstractWebClient');
+
+ // listen to unhandled rejected promises, and when the rejection is not due
+ // to a crash, prevent the browser from displaying an 'unhandledrejection'
+ // error in the console, which would make tests crash on each Promise.reject()
+ // something similar is done by the CrashManagerService, but by default, it
+ // isn't deployed in tests
+ bus.on('crash_manager_unhandledrejection', this, function (ev) {
+ if (!ev.reason || !(ev.reason instanceof Error)) {
+ ev.stopPropagation();
+ ev.stopImmediatePropagation();
+ ev.preventDefault();
+ }
+ });
+
+ owl.config.mode = "dev";
+
+ const webClient = new WebClient();
+ return webClient;
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js b/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js
new file mode 100644
index 00000000..a0df97fd
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_ChromeWidgets.js
@@ -0,0 +1,89 @@
+odoo.define('point_of_sale.tests.ChromeWidgets', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const PopupControllerMixin = require('point_of_sale.PopupControllerMixin');
+ const testUtils = require('web.test_utils');
+ const makePosTestEnv = require('point_of_sale.test_env');
+ const { xml } = owl.tags;
+
+ QUnit.module('unit tests for Chrome Widgets', {});
+
+ QUnit.test('CashierName', async function (assert) {
+ assert.expect(1);
+
+ class Parent extends PosComponent {}
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div><CashierName></CashierName></div>
+ `;
+ Parent.env.pos.employee.name = 'Test Employee';
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ assert.strictEqual(parent.el.querySelector('span.username').innerText, 'Test Employee');
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('HeaderButton', async function (assert) {
+ assert.expect(1);
+
+ class Parent extends PosComponent {}
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div><HeaderButton></HeaderButton></div>
+ `;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const headerButton = parent.el.querySelector('.header-button');
+ await testUtils.dom.click(headerButton);
+ await testUtils.nextTick();
+ assert.ok(headerButton.classList.contains('confirm'));
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('SyncNotification', async function (assert) {
+ assert.expect(5);
+
+ class Parent extends PosComponent {}
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <SyncNotification></SyncNotification>
+ </div>
+ `;
+
+ const pos = Parent.env.pos;
+ pos.set('synch', { status: 'connected', pending: false });
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+ assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_connected'));
+
+ pos.set('synch', { status: 'connecting', pending: false });
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_connecting'));
+
+ pos.set('synch', { status: 'disconnected', pending: false });
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_disconnected'));
+
+ pos.set('synch', { status: 'error', pending: false });
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('i.fa').parentElement.classList.contains('js_error'));
+
+ pos.set('synch', { status: 'error', pending: 10 });
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.js_msg').innerText.includes('10'));
+
+ parent.unmount();
+ parent.destroy();
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js b/addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js
new file mode 100644
index 00000000..4b2217cb
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_ComponentRegistry.js
@@ -0,0 +1,414 @@
+odoo.define('point_of_sale.tests.ComponentRegistry', function(require) {
+ 'use strict';
+
+ const Registries = require('point_of_sale.Registries');
+
+ QUnit.module('unit tests for ComponentRegistry', {
+ before() {},
+ });
+
+ QUnit.test('basic extend', async function(assert) {
+ assert.expect(5);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ Registries.Component.freeze();
+
+ const RegA = Registries.Component.get(A);
+ let a = new RegA();
+ assert.verifySteps(['A', 'A1']);
+ assert.ok(a instanceof RegA);
+ assert.ok(RegA.name === 'A');
+ });
+
+ QUnit.test('addByExtending', async function(assert) {
+ assert.expect(8);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ let A2 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A2');
+ }
+ };
+ Registries.Component.extend(A, A2);
+
+ Registries.Component.freeze();
+
+ const RegA = Registries.Component.get(A);
+ const RegB = Registries.Component.get(B);
+ let b = new RegB();
+ assert.verifySteps(['A', 'A1', 'A2', 'B']);
+ assert.ok(b instanceof RegA);
+ assert.ok(b instanceof RegB);
+ assert.ok(RegB.name === 'B');
+ });
+
+ QUnit.test('extend the one that is added by extending', async function(assert) {
+ assert.expect(6);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let B1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B1');
+ }
+ };
+ Registries.Component.extend(B, B1);
+
+ let B2 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B2');
+ }
+ };
+ Registries.Component.extend(B, B2);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ Registries.Component.freeze();
+
+ const RegB = Registries.Component.get(B);
+ new RegB();
+ assert.verifySteps(['A', 'A1', 'B', 'B1', 'B2']);
+ });
+
+ QUnit.test('addByExtending based on added by extending', async function(assert) {
+ assert.expect(10);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ let C = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('C');
+ }
+ };
+ Registries.Component.addByExtending(C, B);
+
+ let B7 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B7');
+ }
+ };
+ Registries.Component.extend(B, B7);
+
+ Registries.Component.freeze();
+
+ const RegA = Registries.Component.get(A);
+ const RegB = Registries.Component.get(B);
+ const RegC = Registries.Component.get(C);
+ let c = new RegC();
+ assert.verifySteps(['A', 'A1', 'B', 'B7', 'C']);
+ assert.ok(c instanceof RegA);
+ assert.ok(c instanceof RegB);
+ assert.ok(c instanceof RegC);
+ assert.ok(RegC.name === 'C');
+ });
+
+ QUnit.test('deeper inheritance', async function(assert) {
+ assert.expect(9);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ let C = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('C');
+ }
+ };
+ Registries.Component.addByExtending(C, B);
+
+ let B2 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B2');
+ }
+ };
+ Registries.Component.extend(B, B2);
+
+ let B3 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('B3');
+ }
+ };
+ Registries.Component.extend(B, B3);
+
+ let A9 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A9');
+ }
+ };
+ Registries.Component.extend(A, A9);
+
+ let E = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('E');
+ }
+ };
+ Registries.Component.addByExtending(E, C);
+
+ Registries.Component.freeze();
+
+ // |A| => A9 -> A1 -> A
+ // |B| => B3 -> B2 -> B -> |A|
+ // |C| => C -> |B|
+ // |E| => E -> |C|
+
+ new (Registries.Component.get(E))();
+ assert.verifySteps(['A', 'A1', 'A9', 'B', 'B2', 'B3', 'C', 'E']);
+ });
+
+ QUnit.test('mixins?', async function(assert) {
+ assert.expect(12);
+
+ class A {
+ constructor() {
+ assert.step('A');
+ }
+ }
+ Registries.Component.add(A);
+
+ let Mixin = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('Mixin');
+ }
+ mixinMethod() {
+ return 'mixinMethod';
+ }
+ get mixinGetter() {
+ return 'mixinGetter';
+ }
+ };
+
+ // use the mixin when declaring B.
+ let B = x =>
+ class extends Mixin(x) {
+ constructor() {
+ super();
+ assert.step('B');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ constructor() {
+ super();
+ assert.step('A1');
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ Registries.Component.freeze();
+
+ B = Registries.Component.get(B);
+ const b = new B();
+ assert.verifySteps(['A', 'A1', 'Mixin', 'B']);
+ // instance of B should have the mixin properties
+ assert.strictEqual(b.mixinMethod(), 'mixinMethod');
+ assert.strictEqual(b.mixinGetter, 'mixinGetter');
+
+ // instance of A should not have the mixin properties
+ A = Registries.Component.get(A);
+ const a = new A();
+ assert.verifySteps(['A', 'A1']);
+ assert.notOk(a.mixinMethod);
+ assert.notOk(a.mixinGetter);
+ });
+
+ QUnit.test('extending methods', async function(assert) {
+ assert.expect(16);
+
+ class A {
+ foo() {
+ assert.step('A foo');
+ }
+ }
+ Registries.Component.add(A);
+
+ let B = x =>
+ class extends x {
+ bar() {
+ assert.step('B bar');
+ }
+ };
+ Registries.Component.addByExtending(B, A);
+
+ let A1 = x =>
+ class extends x {
+ bar() {
+ assert.step('A1 bar');
+ // should only be for A.
+ }
+ };
+ Registries.Component.extend(A, A1);
+
+ let B1 = x =>
+ class extends x {
+ foo() {
+ super.foo();
+ assert.step('B1 foo');
+ }
+ };
+ Registries.Component.extend(B, B1);
+
+ let C = x =>
+ class extends x {
+ foo() {
+ super.foo();
+ assert.step('C foo');
+ }
+ bar() {
+ super.bar();
+ assert.step('C bar');
+ }
+ };
+ Registries.Component.addByExtending(C, B);
+
+ Registries.Component.freeze();
+
+ A = Registries.Component.get(A);
+ B = Registries.Component.get(B);
+ C = Registries.Component.get(C);
+ const a = new A();
+ const b = new B();
+ const c = new C();
+
+ a.foo();
+ assert.verifySteps(['A foo']);
+ b.foo();
+ assert.verifySteps(['A foo', 'B1 foo']);
+ c.foo();
+ assert.verifySteps(['A foo', 'B1 foo', 'C foo']);
+
+ a.bar();
+ assert.verifySteps(['A1 bar']);
+ b.bar();
+ assert.verifySteps(['B bar']);
+ c.bar();
+ assert.verifySteps(['B bar', 'C bar']);
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_NumberBuffer.js b/addons/point_of_sale/static/tests/unit/test_NumberBuffer.js
new file mode 100644
index 00000000..1e9da1e6
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_NumberBuffer.js
@@ -0,0 +1,65 @@
+odoo.define('point_of_sale.tests.NumberBuffer', function(require) {
+ 'use strict';
+
+ const { Component, useState } = owl;
+ const { xml } = owl.tags;
+ const NumberBuffer = require('point_of_sale.NumberBuffer');
+ const makeTestEnvironment = require('web.test_env');
+ const testUtils = require('web.test_utils');
+
+ QUnit.module('unit tests for NumberBuffer', {
+ before() {},
+ });
+
+ QUnit.test('simple fast inputs with capture in between', async function(assert) {
+ assert.expect(3);
+
+ class Root extends Component {
+ constructor() {
+ super();
+ this.state = useState({ buffer: '' });
+ NumberBuffer.activate();
+ NumberBuffer.use({
+ nonKeyboardInputEvent: 'numpad-click-input',
+ state: this.state,
+ });
+ }
+ resetBuffer() {
+ NumberBuffer.capture();
+ NumberBuffer.reset();
+ }
+ }
+ Root.env = makeTestEnvironment();
+ Root.template = xml/* html */ `
+ <div>
+ <p><t t-esc="state.buffer" /></p>
+ <button class="one" t-on-click="trigger('numpad-click-input', { key: '1' })">1</button>
+ <button class="two" t-on-click="trigger('numpad-click-input', { key: '2' })">2</button>
+ <button class="reset" t-on-click="resetBuffer">reset</button>
+ </div>
+ `;
+
+ const root = new Root();
+ await root.mount(testUtils.prepareTarget());
+
+ const oneButton = root.el.querySelector('button.one');
+ const twoButton = root.el.querySelector('button.two');
+ const resetButton = root.el.querySelector('button.reset');
+ const bufferEl = root.el.querySelector('p');
+
+ testUtils.dom.click(oneButton);
+ testUtils.dom.click(twoButton);
+ await testUtils.nextTick();
+ assert.strictEqual(bufferEl.textContent, '12');
+ testUtils.dom.click(resetButton);
+ await testUtils.nextTick();
+ assert.strictEqual(bufferEl.textContent, '');
+ testUtils.dom.click(twoButton);
+ testUtils.dom.click(oneButton);
+ await testUtils.nextTick();
+ assert.strictEqual(bufferEl.textContent, '21');
+
+ root.unmount();
+ root.destroy();
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_PaymentScreen.js b/addons/point_of_sale/static/tests/unit/test_PaymentScreen.js
new file mode 100644
index 00000000..48d3b55d
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_PaymentScreen.js
@@ -0,0 +1,309 @@
+odoo.define('point_of_sale.tests.PaymentScreen', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const { useListener } = require('web.custom_hooks');
+ const testUtils = require('web.test_utils');
+ const makePosTestEnv = require('point_of_sale.test_env');
+ const { xml } = owl.tags;
+ const { useState } = owl;
+
+ QUnit.module('unit tests for PaymentScreen components', {});
+
+ QUnit.test('PaymentMethodButton', async function (assert) {
+ assert.expect(2);
+
+ class Parent extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('new-payment-line', this._newPaymentLine);
+ }
+ _newPaymentLine() {
+ assert.step('new-payment-line');
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <PaymentMethodButton paymentMethod="{ name: 'Cash', id: 1 }" />
+ </div>
+ `;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const button = parent.el.querySelector('.paymentmethod');
+ await testUtils.dom.click(button);
+ assert.verifySteps(['new-payment-line']);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('PSNumpadInputButton', async function (assert) {
+ assert.expect(15);
+
+ class Parent extends PosComponent {
+ constructor({ value, text, changeClassTo }) {
+ super();
+ this.state = useState({ value, text, changeClassTo });
+ useListener('input-from-numpad', this._inputFromNumpad);
+ }
+ _inputFromNumpad({ detail: { key } }) {
+ assert.step(`${key}-input`);
+ }
+ setState(obj) {
+ Object.assign(this.state, obj);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <PSNumpadInputButton value="state.value" text="state.text" changeClassTo="state.changeClassTo" />
+ </div>
+ `;
+
+ let parent = new Parent({ value: '1' });
+ await parent.mount(testUtils.prepareTarget());
+
+ let button = parent.el.querySelector('button');
+ assert.ok(button.textContent.includes('1'));
+ assert.ok(button.classList.contains('number-char'));
+ await testUtils.dom.click(button);
+ await testUtils.nextTick();
+ assert.verifySteps(['1-input']);
+
+ parent.setState({ value: '2', text: 'Two' });
+ await testUtils.nextTick();
+ assert.ok(button.textContent.includes('Two'));
+ await testUtils.dom.click(button);
+ await testUtils.nextTick();
+ assert.verifySteps(['2-input']);
+
+ parent.setState({ value: '+12', text: null, changeClassTo: 'not-number-char' });
+ await testUtils.nextTick();
+ assert.ok(button.textContent.includes('+12'));
+ assert.ok(button.classList.contains('not-number-char'));
+ // class number-char should have been replaced
+ assert.notOk(button.classList.contains('number-char'));
+ await testUtils.dom.click(button);
+ await testUtils.nextTick();
+ assert.verifySteps(['+12-input']);
+
+ parent.unmount();
+ parent.destroy();
+
+ // using the slot should ignore value and text props of the component
+ Parent.template = xml/* html */ `
+ <div>
+ <PSNumpadInputButton value="state.value" text="state.text" changeClassTo="state.changeClassTo">
+ <span>UseSlot</span>
+ </PSNumpadInputButton>
+ </div>
+ `;
+ parent = new Parent({ value: 'slotted', text: 'Text' });
+ await parent.mount(testUtils.prepareTarget());
+
+ button = parent.el.querySelector('button');
+ assert.ok(button.textContent.includes('UseSlot'));
+ await testUtils.dom.click(button);
+ await testUtils.nextTick();
+ assert.verifySteps(['slotted-input']);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('PaymentScreenPaymentLines', async function (assert) {
+ assert.expect(12);
+
+ class Parent extends PosComponent {
+ constructor() {
+ super();
+ useListener('delete-payment-line', this._onDeletePaymentLine);
+ useListener('select-payment-line', this._onSelectPaymentLine);
+ }
+ get paymentLines() {
+ return this.order.get_paymentlines();
+ }
+ get order() {
+ return this.env.pos.get_order();
+ }
+ mounted() {
+ this.order.paymentlines.on('change', this.render, this);
+ }
+ willUnmount() {
+ this.order.paymentlines.off('change', null, this);
+ }
+ _onDeletePaymentLine() {
+ assert.step('delete-click');
+ }
+ _onSelectPaymentLine() {
+ assert.step('select-click');
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <PaymentScreenPaymentLines paymentLines="paymentLines" />
+ </div>
+ `;
+
+ let parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const order = parent.env.pos.get_order();
+ const cashPM = { id: 0, name: 'Cash', is_cash_count: true, use_payment_terminal: false };
+ const bankPM = { id: 0, name: 'Bank', is_cash_count: false, use_payment_terminal: false };
+
+ let paymentline1 = order.add_paymentline(cashPM);
+ await testUtils.nextTick();
+
+ let statusContainer = parent.el.querySelector('.payment-status-container');
+ let linesEl = parent.el.querySelector('.paymentlines');
+ assert.ok(linesEl, 'payment lines are shown');
+ let newLine = linesEl.querySelector('.selected');
+ assert.ok(newLine, 'the new line is automatically selected');
+
+ let paymentline2 = order.add_paymentline(bankPM);
+ await testUtils.nextTick();
+ assert.notOk(
+ linesEl.querySelector('.selected') === newLine,
+ 'the previously added paymentline should not be selected anymore'
+ );
+ assert.ok(
+ linesEl.querySelectorAll('.paymentline:not(.heading)').length === 2,
+ 'there should be two paymentlines'
+ );
+
+ let paymentline3 = order.add_paymentline(cashPM);
+ await testUtils.nextTick();
+ assert.ok(
+ linesEl.querySelectorAll('.paymentline:not(.heading)').length === 3,
+ 'there should be three paymentlines'
+ );
+ assert.ok(
+ linesEl.querySelectorAll('.paymentline.selected').length === 1,
+ 'there should only be one selected paymentline'
+ );
+
+ await testUtils.dom.click(linesEl.querySelector('.paymentline.selected .delete-button'));
+ await testUtils.nextTick();
+ assert.verifySteps(['delete-click', 'select-click']);
+
+ // click the 2nd payment line
+ await testUtils.dom.click(linesEl.querySelectorAll('.paymentline:not(.heading)')[1]);
+ await testUtils.nextTick();
+ assert.verifySteps(['select-click']);
+
+ // remove paymentline3 (the selected)
+ order.remove_paymentline(paymentline3);
+ await testUtils.nextTick();
+ assert.notOk(
+ linesEl.querySelector('.paymentline.selected'),
+ 'no more selected payment line'
+ );
+
+ order.remove_paymentline(paymentline1);
+ order.remove_paymentline(paymentline2);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('PaymentScreenElectronicPayment', async function (assert) {
+ assert.expect(17);
+
+ class SimulatedPaymentLine extends Backbone.Model {
+ constructor() {
+ super();
+ this.payment_status = 'pending';
+ this.can_be_reversed = false;
+ }
+ canBeAdjusted() {
+ return false;
+ }
+ setPaymentStatus(status) {
+ this.payment_status = status;
+ this.trigger('change');
+ }
+ toggleCanBeReversed() {
+ this.can_be_reversed = !this.can_be_reversed;
+ this.trigger('change');
+ }
+ }
+
+ class Parent extends PosComponent {
+ constructor() {
+ super();
+ this.line = new SimulatedPaymentLine();
+ useListener('send-payment-request', () => assert.step('send-payment-request'));
+ useListener('send-force-done', () => assert.step('send-force-done'));
+ useListener('send-payment-cancel', () => assert.step('send-payment-cancel'));
+ useListener('send-payment-reverse', () => assert.step('send-payment-reverse'));
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <PaymentScreenElectronicPayment line="line" />
+ </div>
+ `;
+
+ let parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ assert.ok(parent.el.querySelector('.paymentline .send_payment_request'));
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_request'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-payment-request']);
+
+ parent.line.setPaymentStatus('retry');
+ await testUtils.nextTick();
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_request'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-payment-request']);
+
+ parent.line.setPaymentStatus('force_done');
+ await testUtils.nextTick();
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_force_done'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-force-done']);
+
+ parent.line.setPaymentStatus('waitingCard');
+ await testUtils.nextTick();
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_cancel'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-payment-cancel']);
+
+ parent.line.setPaymentStatus('waiting');
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline i.fa-spinner'));
+
+ parent.line.setPaymentStatus('waitingCancel');
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline i.fa-spinner'));
+
+ parent.line.setPaymentStatus('reversing');
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline i.fa-spinner'));
+
+ parent.line.setPaymentStatus('done');
+ await testUtils.nextTick();
+ assert.notOk(parent.el.querySelector('.paymentline .send_payment_reversal'));
+
+ parent.line.toggleCanBeReversed();
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline .send_payment_reversal'));
+ await testUtils.dom.click(parent.el.querySelector('.paymentline .send_payment_reversal'));
+ await testUtils.nextTick();
+ assert.verifySteps(['send-payment-reverse']);
+
+ parent.line.setPaymentStatus('reversed');
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.paymentline'));
+
+ parent.unmount();
+ parent.destroy();
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_ProductScreen.js b/addons/point_of_sale/static/tests/unit/test_ProductScreen.js
new file mode 100644
index 00000000..bdd9b732
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_ProductScreen.js
@@ -0,0 +1,603 @@
+odoo.define('point_of_sale.tests.ProductScreen', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+ const { useListener } = require('web.custom_hooks');
+ const testUtils = require('web.test_utils');
+ const makePosTestEnv = require('point_of_sale.test_env');
+ const { xml } = owl.tags;
+ const { useState } = owl;
+
+ QUnit.module('unit tests for ProductScreen components', {});
+
+ QUnit.test('ActionpadWidget', async function (assert) {
+ assert.expect(7);
+
+ class Parent extends PosComponent {
+ constructor() {
+ super();
+ useListener('click-customer', () => assert.step('click-customer'));
+ useListener('click-pay', () => assert.step('click-pay'));
+ this.state = useState({ client: null });
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <ActionpadWidget client="state.client" />
+ </div>
+ `;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const setCustomerButton = parent.el.querySelector('button.set-customer');
+ const payButton = parent.el.querySelector('button.pay');
+
+ await testUtils.nextTick();
+ assert.ok(setCustomerButton.innerText.includes('Customer'));
+
+ // change to customer with short name
+ parent.state.client = { name: 'Test' };
+ await testUtils.nextTick();
+ assert.ok(setCustomerButton.innerText.includes('Test'));
+
+ // change to customer with long name
+ parent.state.client = { name: 'Change Customer' };
+ await testUtils.nextTick();
+ assert.ok(setCustomerButton.classList.contains('decentered'));
+
+ parent.state.client = null;
+
+ // click set-customer button
+ await testUtils.dom.click(setCustomerButton);
+ await testUtils.nextTick();
+ assert.verifySteps(['click-customer']);
+
+ // click pay button
+ await testUtils.dom.click(payButton);
+ await testUtils.nextTick();
+ assert.verifySteps(['click-pay']);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('NumpadWidget', async function (assert) {
+ assert.expect(25);
+
+ class Parent extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('set-numpad-mode', this.setNumpadMode);
+ useListener('numpad-click-input', this.numpadClickInput);
+ this.state = useState({ mode: 'quantity' });
+ }
+ setNumpadMode({ detail: { mode } }) {
+ this.state.mode = mode;
+ assert.step(mode);
+ }
+ numpadClickInput({ detail: { key } }) {
+ assert.step(key);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div><NumpadWidget activeMode="state.mode"></NumpadWidget></div>
+ `;
+
+ const pos = Parent.env.pos;
+ // set this old values back after testing
+ const old_config = pos.config;
+ const old_cashier = pos.get('cashier');
+
+ // set dummy values in pos.config and pos.get('cashier')
+ pos.config = {
+ restrict_price_control: false,
+ manual_discount: true
+ };
+ pos.set('cashier', { role: 'manager' });
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ const modeButtons = parent.el.querySelectorAll('.mode-button');
+ let qtyButton, discButton, priceButton;
+ for (let button of modeButtons) {
+ if (button.textContent.includes('Qty')) {
+ qtyButton = button;
+ }
+ if (button.textContent.includes('Disc')) {
+ discButton = button;
+ }
+ if (button.textContent.includes('Price')) {
+ priceButton = button;
+ }
+ }
+
+ // initially, qty button is active
+ assert.ok(qtyButton.classList.contains('selected-mode'));
+ assert.ok(!discButton.classList.contains('selected-mode'));
+ assert.ok(!priceButton.classList.contains('selected-mode'));
+
+ await testUtils.dom.click(discButton);
+ await testUtils.nextTick();
+ assert.ok(!qtyButton.classList.contains('selected-mode'));
+ assert.ok(discButton.classList.contains('selected-mode'));
+ assert.ok(!priceButton.classList.contains('selected-mode'));
+ assert.verifySteps(['discount']);
+
+ await testUtils.dom.click(priceButton);
+ await testUtils.nextTick();
+ assert.ok(!qtyButton.classList.contains('selected-mode'));
+ assert.ok(!discButton.classList.contains('selected-mode'));
+ assert.ok(priceButton.classList.contains('selected-mode'));
+ assert.verifySteps(['price']);
+
+ const numpadOne = [...parent.el.querySelectorAll('.number-char').values()].find((el) =>
+ el.textContent.includes('1')
+ );
+ const numpadMinus = parent.el.querySelector('.numpad-minus');
+ const numpadBackspace = parent.el.querySelector('.numpad-backspace');
+
+ await testUtils.dom.click(numpadOne);
+ await testUtils.nextTick();
+ assert.verifySteps(['1']);
+
+ await testUtils.dom.click(numpadMinus);
+ await testUtils.nextTick();
+ assert.verifySteps(['-']);
+
+ await testUtils.dom.click(numpadBackspace);
+ await testUtils.nextTick();
+ assert.verifySteps(['Backspace']);
+
+ await testUtils.dom.click(priceButton);
+ await testUtils.nextTick();
+ assert.verifySteps(['price']);
+
+ // change to price control restriction and the cashier is not manager
+ pos.config.restrict_price_control = true;
+ pos.set('cashier', { role: 'not manager' });
+ await testUtils.nextTick();
+
+ assert.ok(priceButton.classList.contains('disabled-mode'));
+ assert.ok(qtyButton.classList.contains('selected-mode'));
+ // after the cashier is changed, since it is not a manager,
+ // the 'set-numpad-mode' is triggered, setting the mode to
+ // 'quantity'.
+ assert.verifySteps(['quantity']);
+
+ // reset old config and cashier values to pos
+ pos.config = old_config;
+ pos.set('cashier', old_cashier);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('ProductsWidgetControlPanel', async function (assert) {
+ assert.expect(32);
+
+ // This test incorporates the following components:
+ // CategoryBreadcrumb
+ // CategoryButton
+ // CategorySimpleButton
+ // HomeCategoryBreadcrumb
+
+ // Create dummy category data
+ //
+ // Root
+ // | Test1
+ // | | Test2
+ // | ` Test3
+ // | | Test5
+ // | ` Test6
+ // ` Test4
+
+ const rootCategory = { id: 0, name: 'Root', parent: null };
+ const testCategory1 = { id: 1, name: 'Test1', parent: 0 };
+ const testCategory2 = { id: 2, name: 'Test2', parent: 1 };
+ const testCategory3 = { id: 3, name: 'Test3', parent: 1 };
+ const testCategory4 = { id: 4, name: 'Test4', parent: 0 };
+ const testCategory5 = { id: 5, name: 'Test5', parent: 3 };
+ const testCategory6 = { id: 6, name: 'Test6', parent: 3 };
+ const categories = {
+ 0: rootCategory,
+ 1: testCategory1,
+ 2: testCategory2,
+ 3: testCategory3,
+ 4: testCategory4,
+ 5: testCategory5,
+ 6: testCategory6,
+ };
+
+ class Parent extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.state = useState({ selectedCategoryId: 0 });
+ useListener('switch-category', this.switchCategory);
+ useListener('update-search', this.updateSearch);
+ useListener('clear-search', this.clearSearch);
+ }
+ get breadcrumbs() {
+ if (this.state.selectedCategoryId === 0) return [];
+ let current = categories[this.state.selectedCategoryId];
+ const res = [current];
+ while (current.parent != 0) {
+ const toAdd = categories[current.parent];
+ res.push(toAdd);
+ current = toAdd;
+ }
+ return res.reverse();
+ }
+ get subcategories() {
+ return Object.values(categories).filter(
+ ({ parent }) => parent == this.state.selectedCategoryId
+ );
+ }
+ switchCategory({ detail: id }) {
+ this.state.selectedCategoryId = id;
+ assert.step(`${id}`);
+ }
+ updateSearch(event) {
+ assert.step(event.detail);
+ }
+ clearSearch() {
+ assert.step('cleared');
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div class="pos">
+ <div class="search-bar-portal">
+ <ProductsWidgetControlPanel breadcrumbs="breadcrumbs" subcategories="subcategories" />
+ </div>
+ </div>
+ `;
+
+ const pos = Parent.env.pos;
+ const old_config = pos.config;
+ // set dummy config
+ pos.config = { iface_display_categ_images: false };
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ // The following tests the breadcrumbs and subcategory buttons
+
+ // check if HomeCategoryBreadcrumb is rendered
+ assert.ok(
+ parent.el.querySelector('.breadcrumb-home'),
+ 'Home category should always be there'
+ );
+ let subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ assert.ok(subcategorySpans.length === 2, 'There should be 2 subcategories for Root.');
+ assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test1')));
+ assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test4')));
+
+ // click Test1
+ let test1Span = subcategorySpans.find((span) => span.textContent.includes('Test1'));
+ await testUtils.dom.click(test1Span);
+ await testUtils.nextTick();
+ assert.verifySteps(['1']);
+ assert.ok(
+ [...parent.el.querySelectorAll('.breadcrumb-button')][1].textContent.includes('Test1')
+ );
+ subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ assert.ok(subcategorySpans.length === 2, 'There should be 2 subcategories for Root.');
+ assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test2')));
+ assert.ok(subcategorySpans.find((span) => span.textContent.includes('Test3')));
+
+ // click Test2
+ let test2Span = subcategorySpans.find((span) => span.textContent.includes('Test2'));
+ await testUtils.dom.click(test2Span);
+ await testUtils.nextTick();
+ assert.verifySteps(['2']);
+ subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ assert.ok(subcategorySpans.length === 0, 'Test2 should not have subcategories');
+
+ // go back to Test1
+ let breadcrumb1 = [...parent.el.querySelectorAll('.breadcrumb-button')].find((el) =>
+ el.textContent.includes('Test1')
+ );
+ await testUtils.dom.click(breadcrumb1);
+ await testUtils.nextTick();
+ assert.verifySteps(['1']);
+
+ // click Test3
+ subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ let test3Span = subcategorySpans.find((span) => span.textContent.includes('Test3'));
+ await testUtils.dom.click(test3Span);
+ await testUtils.nextTick();
+ assert.verifySteps(['3']);
+ subcategorySpans = [...parent.el.querySelectorAll('.category-simple-button')];
+ assert.ok(subcategorySpans.length === 2);
+
+ // click Test6
+ let test6Span = subcategorySpans.find((span) => span.textContent.includes('Test6'));
+ await testUtils.dom.click(test6Span);
+ await testUtils.nextTick();
+ assert.verifySteps(['6']);
+ let breadcrumbButtons = [...parent.el.querySelectorAll('.breadcrumb-button')];
+ assert.ok(breadcrumbButtons.length === 4);
+
+ // Now check subcategory buttons with images
+ pos.config.iface_display_categ_images = true;
+
+ let breadcrumbHome = parent.el.querySelector('.breadcrumb-home');
+ await testUtils.dom.click(breadcrumbHome);
+ await testUtils.nextTick();
+ assert.verifySteps(['0']);
+ assert.ok(
+ !parent.el.querySelector('.category-list').classList.contains('simple'),
+ 'Category list should not have simple class'
+ );
+ let categoryButtons = [...parent.el.querySelectorAll('.category-button')];
+ assert.ok(categoryButtons.length === 2, 'There should be 2 subcategories for Root');
+
+ // The following tests the search bar
+
+ const wait = (ms) => {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+ };
+
+ const inputEl = parent.el.querySelector('.search-box input');
+ await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'A' });
+ // Triggering keyup event doesn't type the key to the input
+ // so we manually assign the value of the input.
+ inputEl.value = 'A';
+ await wait(30);
+ await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'B' });
+ inputEl.value = 'AB';
+ await wait(30);
+ await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'C' });
+ inputEl.value = 'ABC';
+ await wait(110);
+ // Only after waiting for more than 100ms that update-search is triggered
+ // because the method is debounced.
+ assert.verifySteps(['ABC']);
+ await testUtils.dom.triggerEvent(inputEl, 'keyup', { key: 'D' });
+ inputEl.value = 'ABCD';
+ await wait(110);
+ assert.verifySteps(['ABCD']);
+
+ // clear the search bar
+ await testUtils.dom.click(parent.el.querySelector('.search-box .clear-icon'));
+ await testUtils.nextTick();
+ assert.verifySteps(['cleared']);
+ assert.ok(inputEl.value === '', 'value of the input element should be empty');
+
+ pos.config = old_config;
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('ProductList, ProductItem', async function (assert) {
+ assert.expect(10);
+
+ // patch imageUrl and price of ProductItem component
+ const MockProductItemExt = (X) =>
+ class extends X {
+ get imageUrl() {
+ return 'data:,';
+ }
+ get price() {
+ return this.props.product.price;
+ }
+ };
+
+ const extension = Registries.Component.extend('ProductItem', MockProductItemExt);
+ extension.compile();
+
+ const dummyProducts = [
+ { id: 0, display_name: 'Burger', price: '$10' },
+ { id: 1, display_name: 'Water', price: '$2' },
+ { id: 2, display_name: 'Chair', price: '$25' },
+ ];
+
+ class Parent extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.state = useState({ searchWord: '', products: dummyProducts });
+ useListener('click-product', this._clickProduct);
+ }
+ _clickProduct({ detail: product }) {
+ assert.step(product.display_name);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <ProductList products="state.products" searchWord="state.searchWord" />
+ </div>
+ `;
+
+ const parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ // Check if there are 3 products listed
+ assert.strictEqual(
+ parent.el.querySelectorAll('article.product').length,
+ 3,
+ 'There should be 3 products listed'
+ );
+
+ // Check contents of product item and click
+ const product1el = parent.el.querySelector(
+ 'article.product[aria-labelledby="article_product_1"]'
+ );
+ assert.ok(product1el.querySelector('.product-img img[alt="Water"]'));
+ assert.ok(product1el.querySelector('.product-img .price-tag').textContent.includes('$2'));
+ await testUtils.dom.click(product1el);
+ await testUtils.nextTick();
+ assert.verifySteps(['Water']);
+
+ // Remove one product, check if only two is listed
+ parent.state.products.splice(0, 1);
+ await testUtils.nextTick();
+ assert.strictEqual(
+ parent.el.querySelectorAll('article.product').length,
+ 2,
+ 'There should be 2 products listed after removing the first item'
+ );
+
+ // Remove all products, check if empty message is There are no products in this category
+ parent.state.products.splice(0, parent.state.products.length);
+ await testUtils.nextTick();
+ assert.strictEqual(
+ parent.el.querySelectorAll('article.product').length,
+ 0,
+ 'There should be 0 products listed after removing everything'
+ );
+ assert.ok(
+ parent.el
+ .querySelector('.product-list-empty p')
+ .textContent.includes('There are no products in this category.')
+ );
+
+ // change the searchWord to 'something', check if empty message is No results found
+ parent.state.searchWord = 'something';
+ await testUtils.nextTick();
+ assert.ok(
+ parent.el
+ .querySelector('.product-list-empty p')
+ .textContent.includes('No results found for')
+ );
+ assert.ok(
+ parent.el.querySelector('.product-list-empty p b').textContent.includes('something')
+ );
+
+ extension.remove();
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('Orderline', async function (assert) {
+ assert.expect(10);
+
+ class Parent extends PosComponent {
+ constructor(product) {
+ super();
+ useListener('select-line', this._selectLine);
+ useListener('edit-pack-lot-lines', this._editPackLotLines);
+ this.order.add_product(product);
+ }
+ get order() {
+ return this.env.pos.get_order();
+ }
+ get line() {
+ return this.env.pos.get_order().get_orderlines()[0];
+ }
+ _selectLine() {
+ assert.step('select-line');
+ }
+ _editPackLotLines() {
+ assert.step('edit-pack-lot-lines');
+ }
+ willUnmount() {
+ this.order.remove_orderline(this.line);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <Orderline line="line" />
+ </div>
+ `;
+
+ const [chair1, chair2] = Parent.env.pos.db.search_product_in_category(0, 'Office Chair');
+ // patch chair2 to have tracking
+ chair2.tracking = 'serial';
+
+ // 1. Test orderline without lot icon
+
+ let parent = new Parent(chair1);
+ await parent.mount(testUtils.prepareTarget());
+
+ let line = parent.el.querySelector('li.orderline');
+ assert.ok(line);
+ assert.notOk(line.querySelector('.line-lot-icon'), 'there should be no lot icon');
+ await testUtils.dom.click(line);
+ assert.verifySteps(['select-line']);
+
+ parent.unmount();
+ parent.destroy();
+
+ // 2. Test orderline with lot icon
+
+ parent = new Parent(chair2);
+ await parent.mount(testUtils.prepareTarget());
+
+ line = parent.el.querySelector('li.orderline');
+ const lotIcon = line.querySelector('.line-lot-icon');
+ assert.ok(line);
+ assert.ok(lotIcon, 'there should be lot icon');
+ await testUtils.dom.click(line);
+ assert.verifySteps(['select-line']);
+ await testUtils.dom.click(lotIcon);
+ assert.verifySteps(['edit-pack-lot-lines']);
+
+ parent.unmount();
+ parent.destroy();
+ });
+
+ QUnit.test('OrderWidget', async function (assert) {
+ assert.expect(8);
+
+ // OrderWidget is dependent on its parent's rerendering
+ class Parent extends PosComponent {
+ mounted() {
+ this.env.pos.on('change:selectedOrder', this.render, this);
+ }
+ willUnmount() {
+ this.env.pos.off('change:selectedOrder', null, this);
+ }
+ }
+ Parent.env = makePosTestEnv();
+ Parent.template = xml/* html */ `
+ <div>
+ <OrderWidget />
+ </div>
+ `;
+
+ const [chair1, chair2] = Parent.env.pos.db.search_product_in_category(0, 'Office Chair');
+
+ let parent = new Parent();
+ await parent.mount(testUtils.prepareTarget());
+
+ // current order is empty
+ assert.notOk(parent.el.querySelector('.summary'));
+ assert.ok(parent.el.querySelector('.order-empty'));
+
+ // add line to the current order
+ const order1 = parent.env.pos.get_order();
+ order1.add_product(chair1);
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.summary'));
+ assert.notOk(parent.el.querySelector('.order-empty'));
+
+ // selected new order, new order is empty
+ const order2 = parent.env.pos.add_new_order();
+ await testUtils.nextTick();
+ assert.notOk(parent.el.querySelector('.summary'));
+ assert.ok(parent.el.querySelector('.order-empty'));
+
+ // add line to the current order
+ order2.add_product(chair2);
+ await testUtils.nextTick();
+ assert.ok(parent.el.querySelector('.summary'));
+ assert.notOk(parent.el.querySelector('.order-empty'));
+
+ parent.env.pos.delete_current_order();
+ parent.env.pos.delete_current_order();
+
+ parent.unmount();
+ parent.destroy();
+ });
+});
diff --git a/addons/point_of_sale/static/tests/unit/test_popups.js b/addons/point_of_sale/static/tests/unit/test_popups.js
new file mode 100644
index 00000000..205d1b24
--- /dev/null
+++ b/addons/point_of_sale/static/tests/unit/test_popups.js
@@ -0,0 +1,180 @@
+odoo.define('point_of_sale.test_popups', function(require) {
+ 'use strict';
+
+ const Registries = require('point_of_sale.Registries');
+ const testUtils = require('web.test_utils');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const PopupControllerMixin = require('point_of_sale.PopupControllerMixin');
+ const makePosTestEnv = require('point_of_sale.test_env');
+ const { xml } = owl.tags;
+
+ QUnit.module('unit tests for Popups', {
+ before() {
+ class Root extends PopupControllerMixin(PosComponent) {
+ static template = xml`
+ <div>
+ <t t-if="popup.isShown" t-component="popup.component" t-props="popupProps" t-key="popup.name" />
+ </div>
+ `;
+ }
+ Root.env = makePosTestEnv();
+ this.Root = Root;
+ Registries.Component.freeze();
+ },
+ });
+
+ QUnit.test('ConfirmPopup', async function(assert) {
+ assert.expect(6);
+
+ const root = new this.Root();
+ await root.mount(testUtils.prepareTarget());
+
+ let promResponse, userResponse;
+
+ // Step: show popup and confirm
+ promResponse = root.showPopup('ConfirmPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+
+ // Step: show popup then cancel
+ promResponse = root.showPopup('ConfirmPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.cancel'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, false);
+
+ // Step: check texts
+ promResponse = root.showPopup('ConfirmPopup', {
+ title: 'Are you sure?',
+ body: 'Are you having fun?',
+ confirmText: 'Hell Yeah!',
+ cancelText: 'Are you kidding me?',
+ });
+ await testUtils.nextTick();
+ assert.strictEqual(root.el.querySelector('.title').innerText.trim(), 'Are you sure?');
+ assert.strictEqual(root.el.querySelector('.body').innerText.trim(), 'Are you having fun?');
+ assert.strictEqual(root.el.querySelector('.confirm').innerText.trim(), 'Hell Yeah!');
+ assert.strictEqual(
+ root.el.querySelector('.cancel').innerText.trim(),
+ 'Are you kidding me?'
+ );
+
+ root.unmount();
+ root.destroy();
+ });
+
+ QUnit.test('NumberPopup', async function(assert) {
+ assert.expect(8);
+
+ const root = new this.Root();
+ await root.mount(testUtils.prepareTarget());
+
+ let promResponse, userResponse;
+
+ // Step: show NumberPopup and confirm with empty buffer
+ promResponse = root.showPopup('NumberPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.triggerEvent(root.el.querySelector('.confirm'), 'mousedown');
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+ assert.strictEqual(userResponse.payload, "");
+
+ // Step: show NumberPopup and cancel
+ promResponse = root.showPopup('NumberPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.triggerEvent(root.el.querySelector('.cancel'), 'mousedown');
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, false);
+
+ // Step: show NumberPopup and confirm with filled buffer, new title, new text
+ promResponse = root.showPopup('NumberPopup', {
+ title: 'Are you sure?',
+ confirmText: 'Hell Yeah!',
+ cancelText: 'Are you kidding me?',
+ });
+ await testUtils.nextTick();
+ let nodes = Array.from(root.el.querySelectorAll('button'));
+ testUtils.dom.triggerEvent(nodes.find(elem => elem.innerHTML === "7"), 'mousedown');
+ await testUtils.nextTick();
+ testUtils.dom.triggerEvent(nodes.find(elem => elem.innerHTML === "+10"), 'mousedown');
+ await testUtils.nextTick();
+ assert.strictEqual(root.el.querySelector('.title').innerText.trim(), 'Are you sure?');
+ assert.strictEqual(root.el.querySelector('.confirm').innerText.trim(), 'Hell Yeah!');
+ assert.strictEqual(root.el.querySelector('.cancel').innerText.trim(), 'Are you kidding me?');
+ testUtils.dom.triggerEvent(root.el.querySelector('.confirm'), 'mousedown');
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+ assert.strictEqual(userResponse.payload, "17");
+
+ root.unmount();
+ root.destroy();
+ });
+
+ QUnit.test('EditListPopup', async function(assert) {
+ assert.expect(7);
+
+ const root = new this.Root();
+ await root.mount(testUtils.prepareTarget());
+
+ let promResponse, userResponse;
+
+ // Step: show popup and confirm
+ promResponse = root.showPopup('EditListPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+ assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify([]));
+
+ // Step: show popup and cancel
+ promResponse = root.showPopup('EditListPopup', {});
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.cancel'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, false);
+
+ // Step: show popup and confirm with a default array
+ let defaultArray = ["Banana", "Cherry"];
+ promResponse = root.showPopup('EditListPopup', {
+ title: "Fruits",
+ isSingleItem: false,
+ array: defaultArray,
+ });
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+
+ assert.strictEqual(userResponse.confirmed, true);
+ let i = 0;
+ defaultArray = defaultArray.map((item) => Object.assign({}, { _id: i++ }, { 'text': item}));
+ assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify(defaultArray));
+
+ // Step: show popup and confirm with a new array
+ promResponse = root.showPopup('EditListPopup', {
+ title: "Fruits",
+ isSingleItem: false,
+ array: ["Banana", "Cherry"],
+ });
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.fa-trash-o'));
+ await testUtils.nextTick();
+ testUtils.dom.click(root.el.querySelector('.confirm'));
+ await testUtils.nextTick();
+ userResponse = await promResponse;
+ assert.strictEqual(userResponse.confirmed, true);
+ assert.strictEqual(JSON.stringify(userResponse.payload.newArray), JSON.stringify([{ _id: 1, text: "Cherry"}]));
+
+ root.unmount();
+ root.destroy();
+ });
+});