diff options
Diffstat (limited to 'addons/point_of_sale/static/tests')
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(); + }); +}); |
