diff --git a/src/components/Navbar.ts b/src/components/Navbar.ts index 42e07ab..35f62b3 100644 --- a/src/components/Navbar.ts +++ b/src/components/Navbar.ts @@ -116,6 +116,10 @@ class Navbar extends BasePageModel { get inventory() { return this.getNavItem('Inventory'); } + + get performCycleCount() { + return this.getNavItem('Perform Cycle Count'); + } } export default Navbar; diff --git a/src/constants/applicationUrls.ts b/src/constants/applicationUrls.ts index 353a438..52746c6 100644 --- a/src/constants/applicationUrls.ts +++ b/src/constants/applicationUrls.ts @@ -41,6 +41,7 @@ const INVENTORY_URL = { const CYCLE_COUNT_URL = { base: './inventory/cycleCount', + performCycleCount: () => `${CYCLE_COUNT_URL.base}?tab=TO_COUNT`, }; const INVENTORY_ITEM_URL = { diff --git a/src/pages/manageCycleCount/ManageCycleCountPage.ts b/src/pages/manageCycleCount/ManageCycleCountPage.ts index afe3333..9cea97e 100644 --- a/src/pages/manageCycleCount/ManageCycleCountPage.ts +++ b/src/pages/manageCycleCount/ManageCycleCountPage.ts @@ -8,17 +8,23 @@ import ToCountTable from '@/pages/manageCycleCount/components/ToCountTable'; class ManageCycleCountPage extends BasePageModel { allProductsTable: AllProductsTable; toCountTable: ToCountTable; + toResolveTable: ToCountTable; constructor(page: Page) { super(page); this.allProductsTable = new AllProductsTable(page); this.toCountTable = new ToCountTable(page); + this.toResolveTable = new ToCountTable(page); } async goToPage() { await this.page.goto(CYCLE_COUNT_URL.base); } + async goToPerformCycleCount() { + await this.page.goto(CYCLE_COUNT_URL.performCycleCount()); + } + async isLoaded() { await expect(this.allProductsTab).toBeVisible(); } @@ -64,10 +70,32 @@ class ManageCycleCountPage extends BasePageModel { } async searchProduct(productName: string) { - await this.searchInput.fill(productName); + await this.searchInput.click(); + // select any existing text first, so pressSequentially replaces it + // instead of appending to it + await this.page.keyboard.press('ControlOrMeta+A'); + await this.searchInput.pressSequentially(productName, { delay: 20 }); + await expect(this.searchInput).toHaveValue(productName); await this.filterButton.click(); } + get negativeQuantityFilterGroup() { + return this.page.locator('.filter-group', { + has: this.page.locator('label[for="negativeQuantity"]'), + }); + } + + get negativeQuantityCheckbox() { + return this.negativeQuantityFilterGroup.getByRole('checkbox'); + } + + async openNegativeQuantityTooltip() { + const trigger = this.negativeQuantityFilterGroup.locator('[data-tooltipped]'); + await trigger.hover(); + const describedBy = await trigger.getAttribute('aria-describedby'); + return this.page.locator(`#${describedBy}`); + } + // ACTIONS get markAsToCountButton() { return this.page.getByRole('button', { name: 'Mark as To Count' }); @@ -76,6 +104,10 @@ class ManageCycleCountPage extends BasePageModel { get startCountButton() { return this.page.getByRole('button', { name: 'Start Count' }); } + + get startResolutionButton() { + return this.page.getByRole('button', { name: 'Start resolution' }); + } } export default ManageCycleCountPage; diff --git a/src/pages/manageCycleCount/components/CountStepTable.ts b/src/pages/manageCycleCount/components/CountStepTable.ts index 0571e1c..c52787a 100644 --- a/src/pages/manageCycleCount/components/CountStepTable.ts +++ b/src/pages/manageCycleCount/components/CountStepTable.ts @@ -1,6 +1,7 @@ import { expect, Locator, Page } from '@playwright/test'; import BasePageModel from '@/pages/BasePageModel'; +import { findRowIndexByText } from '@/utils/tableUtils'; class CountStepTable extends BasePageModel { get table() { @@ -14,6 +15,10 @@ class CountStepTable extends BasePageModel { row(index: number) { return new Row(this.page, this.rows.nth(index)); } + + getRowIndexByBinLocation(binLocationName: string) { + return findRowIndexByText(this.rows, binLocationName); + } } class Row extends BasePageModel { diff --git a/src/pages/manageCycleCount/components/RecountStepTable.ts b/src/pages/manageCycleCount/components/RecountStepTable.ts index 194bdf0..ca32209 100644 --- a/src/pages/manageCycleCount/components/RecountStepTable.ts +++ b/src/pages/manageCycleCount/components/RecountStepTable.ts @@ -1,6 +1,7 @@ import { expect, Locator, Page } from '@playwright/test'; import BasePageModel from '@/pages/BasePageModel'; +import { findRowIndexByText } from '@/utils/tableUtils'; class RecountStepTable extends BasePageModel { get table() { @@ -14,6 +15,10 @@ class RecountStepTable extends BasePageModel { row(index: number) { return new Row(this.page, this.rows.nth(index)); } + + getRowIndexByBinLocation(binLocationName: string) { + return findRowIndexByText(this.rows, binLocationName); + } } class Row extends BasePageModel { diff --git a/src/pages/product/productShow/tabs/InStockTabSection.ts b/src/pages/product/productShow/tabs/InStockTabSection.ts index 9b7ee9a..75cb84d 100644 --- a/src/pages/product/productShow/tabs/InStockTabSection.ts +++ b/src/pages/product/productShow/tabs/InStockTabSection.ts @@ -3,6 +3,7 @@ import { expect, Locator, Page } from '@playwright/test'; import BasePageModel from '@/pages/BasePageModel'; import EditItemDialog from '@/pages/product/productShow/sections/components/EditItemDialog'; import StockTransferDialog from '@/pages/product/productShow/sections/components/StockTransferDialog'; +import { findRowIndexByText } from '@/utils/tableUtils'; class InStockTabSection extends BasePageModel { stockTransferDialog: StockTransferDialog; @@ -32,6 +33,10 @@ class InStockTabSection extends BasePageModel { return new Row(this.page, this.rows.nth(index)); } + getRowIndexByBinLocation(binLocationName: string) { + return findRowIndexByText(this.rows, binLocationName); + } + get stockTransferButton() { return this.page.getByRole('link', { name: 'Transfer Stock' }); } diff --git a/src/setup/dataImport/cycleCountInventory.csv b/src/setup/dataImport/cycleCountInventory.csv index 34b3e0a..6ff6373 100644 --- a/src/setup/dataImport/cycleCountInventory.csv +++ b/src/setup/dataImport/cycleCountInventory.csv @@ -1,2 +1,3 @@ Product code,Product,Lot number,Expiration date,Bin location,Quantity,Comment 7,CC-product-one,,,CC-BIN-1,100, +9,CC-product-three,,,CC-BIN-2,10, diff --git a/src/setup/dataImport/products.csv b/src/setup/dataImport/products.csv index 74b82c9..ec90ba2 100644 --- a/src/setup/dataImport/products.csv +++ b/src/setup/dataImport/products.csv @@ -7,5 +7,5 @@ Id,Active,ProductCode,ProductType,Name,ProductFamily,Category,GLAccount,Descript ,true,6,Default,E2E-product-six,,ARVS,,,,,,,,,,,,,,,,,,,,, ,true,7,Default,CC-product-one,,Anesteshia,,,,,,,,,,,,,,,,,,,,, ,true,8,Default,CC-product-two,,Anesteshia,,,,,,,,,,,,,,,,,,,,, - +,true,9,Default,CC-product-three,,ARVS,,,,,,,,,,,,,,,,,,,,, diff --git a/src/tests/cycleCount/performCC.test.ts b/src/tests/cycleCount/performCC.test.ts index 6fae34d..6a7b505 100644 --- a/src/tests/cycleCount/performCC.test.ts +++ b/src/tests/cycleCount/performCC.test.ts @@ -46,6 +46,11 @@ test.describe('Perform cycle count for item', () => { confirmToRecountStepPage, productShowPage, }) => { + // openAllProductsTab()'s own internal retry can take up to 45s on a slow + // tab load, and the search/filter step has its own 30s retry on top of + // that - comfortably over the default 60s test timeout in the worst case + test.setTimeout(120_000); + const USER = await mainUserService.getUser(); await test.step('Assert content of inventory menu', async () => { @@ -69,8 +74,13 @@ test.describe('Perform cycle count for item', () => { await test.step('Search for product on All Products tab', async () => { await manageCycleCountPage.openAllProductsTab(); - await manageCycleCountPage.searchProduct(productName); - await expect(manageCycleCountPage.allProductsTable.rows).toHaveCount(1); + + await expect(async () => { + await manageCycleCountPage.searchProduct(productName); + await expect(manageCycleCountPage.allProductsTable.rows).toHaveCount( + 1 + ); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); }); await test.step('Assert Last Counted date on All Products tab matches stock card', async () => { diff --git a/src/tests/cycleCount/performCCitemWithNegativeInventory.test.ts b/src/tests/cycleCount/performCCitemWithNegativeInventory.test.ts new file mode 100644 index 0000000..6f377f9 --- /dev/null +++ b/src/tests/cycleCount/performCCitemWithNegativeInventory.test.ts @@ -0,0 +1,485 @@ +import AppConfig from '@/config/AppConfig'; +import { expect, test } from '@/fixtures/fixtures'; +import { StockMovementResponse } from '@/types'; +import { deleteShipment, receiveInbound } from '@/utils/shipmentUtils'; + +test.describe('Perform cycle count when item has negative inventory', () => { + const productCode = '9'; + const productName = AppConfig.instance.products[productCode].name; + const facilityId = AppConfig.instance.locations.ccDepot.readId(); + + let STOCK_MOVEMENT: StockMovementResponse; + let cycleCountId: string | undefined; + + test.beforeEach( + async ({ + supplierLocationService, + stockMovementService, + receivingService, + }) => { + const supplierLocation = await supplierLocationService.getLocation(); + const productId = AppConfig.instance.products[productCode].readId(); + + STOCK_MOVEMENT = await stockMovementService.createInbound({ + originId: supplierLocation.id, + }); + + await stockMovementService.addItemsToInboundStockMovement( + STOCK_MOVEMENT.id, + [{ productId, quantity: 20 }] + ); + + await receiveInbound( + { stockMovementService, receivingService }, + STOCK_MOVEMENT, + [20] + ); + } + ); + + test.afterEach( + async ({ + cycleCountService, + stockMovementService, + productShowPage, + transactionService, + }) => { + if (cycleCountId) { + await cycleCountService.deleteCycleCount(facilityId, cycleCountId); + } + await deleteShipment({ stockMovementService, STOCK_MOVEMENT }); + + const productId = AppConfig.instance.products[productCode].readId(); + await productShowPage.goToPage(productId); + await productShowPage.stockHistoryTab.click(); + await productShowPage.stockHistoryTabSection.isLoaded(); + + const rows = productShowPage.stockHistoryTabSection.rows; + const count = await rows.count(); + + const transactionIds: string[] = []; + for (let i = 2; i < count; i++) { + const row = productShowPage.stockHistoryTabSection.row(i); + const links = await row.transactionLink.all(); + for (const link of links) { + const href = await link.getAttribute('href'); + const id = href?.match(/showTransaction\/(\w+)/)?.[1]; + if (id && !transactionIds.includes(id)) { + transactionIds.push(id); + } + } + } + + for (const id of transactionIds.reverse()) { + await transactionService.deleteTransaction(id); + } + } + ); + + test('Perform cycle count when item has negative inventory', async ({ + page, + navbar, + manageCycleCountPage, + countStepPage, + confirmToCountStepPage, + recountStepPage, + confirmToRecountStepPage, + productShowPage, + ccDepotService, + transactionService, + }) => { + test.setTimeout(120_000); + + const productId = AppConfig.instance.products[productCode].readId(); + const receivingBin = + AppConfig.instance.receivingBinPrefix + STOCK_MOVEMENT.identifier; + let inboundTransferInRowIndex: number; + + await test.step('Open All Products tab and assert product is not displayed first', async () => { + await expect(async () => { + await manageCycleCountPage.goToPage(); + await manageCycleCountPage.isLoaded(); + await manageCycleCountPage.openAllProductsTab(); + + const row = manageCycleCountPage.allProductsTable.row(0); + await expect(row.product).not.toContainText(productName); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + }); + + await test.step('Go to stock card and note the inbound Transfer In transaction', async () => { + await productShowPage.goToPage(productId); + await productShowPage.stockHistoryTab.click(); + await productShowPage.stockHistoryTabSection.isLoaded(); + + inboundTransferInRowIndex = + (await productShowPage.stockHistoryTabSection.rows.count()) - 1; + }); + + await test.step('Perform Stock Transfer of inventory from receiving bin to CC-BIN-1', async () => { + const ccDepot = await ccDepotService.getLocation(); + + // known app bug: selecting an option in the "chosen" location dropdown can + // trigger an unintended form submit that navigates away from this dialog + // back to the product's Stock history tab. Retry the whole flow from the + // top if that happens; checking for CC-BIN-1 upfront makes retries + // idempotent in case the stray submit actually went through. + await expect(async () => { + await productShowPage.inStockTab.click(); + await productShowPage.inStockTabSection.isLoaded(); + + const alreadyTransferred = await productShowPage.inStockTabSection.rows + .filter({ hasText: 'CC-BIN-1' }) + .count(); + if (alreadyTransferred > 0) { + return; + } + + const receivingBinRowIndex = + await productShowPage.inStockTabSection.getRowIndexByBinLocation( + receivingBin + ); + const row = productShowPage.inStockTabSection.row(receivingBinRowIndex); + await row.actionsButton.click(); + await productShowPage.inStockTabSection.stockTransferButton.click(); + await productShowPage.inStockTabSection.stockTransferDialog.isLoaded(); + + await productShowPage.inStockTabSection.stockTransferDialog.locationSelect.click(); + // selecting a location triggers an AJAX call (template=transferStock) + // that replaces the whole dialog's markup with fresh bin-location + // options; wait for it to settle before opening the next dropdown, to + // reduce the odds of racing that replacement (bounded - never blocks + // the test on its own if the response doesn't match for some reason) + await Promise.all([ + page + .waitForResponse((res) => res.url().includes('template=transferStock'), { + timeout: 5000, + }) + .catch(() => undefined), + productShowPage.inStockTabSection.stockTransferDialog.selectLocation( + ccDepot.name + ), + ]); + await productShowPage.inStockTabSection.stockTransferDialog.binLocationSelect.click(); + await productShowPage.inStockTabSection.stockTransferDialog.selectLocation( + 'CC-BIN-1' + ); + await productShowPage.inStockTabSection.stockTransferDialog.transferStockButton.click(); + + await productShowPage.inStockTab.click(); + await productShowPage.inStockTabSection.isLoaded(); + await expect( + productShowPage.inStockTabSection.rows.filter({ + hasText: 'CC-BIN-1', + }) + ).toHaveCount(1); + }).toPass({ timeout: 60_000, intervals: [3000, 5000, 8000] }); + + await productShowPage.inStockTab.click(); + await productShowPage.inStockTabSection.isLoaded(); + const ccBin1RowIndex = + await productShowPage.inStockTabSection.getRowIndexByBinLocation( + 'CC-BIN-1' + ); + await expect( + productShowPage.inStockTabSection.row(ccBin1RowIndex).binLocation + ).toContainText('CC-BIN-1'); + }); + + await test.step('Delete the Transfer In transaction created by the inbound receipt', async () => { + await productShowPage.stockHistoryTab.click(); + await productShowPage.stockHistoryTabSection.isLoaded(); + + const row = productShowPage.stockHistoryTabSection.row( + inboundTransferInRowIndex + ); + const href = await row.transactionLink.getAttribute('href'); + const transactionId = href?.match(/showTransaction\/(\w+)/)?.[1]; + // eslint-disable-next-line playwright/no-conditional-in-test + if (!transactionId) { + throw new Error( + 'Could not find id of the inbound Transfer In transaction' + ); + } + await transactionService.deleteTransaction(transactionId); + }); + + await test.step('Assert negative inventory in the receiving bin', async () => { + await productShowPage.goToPage(productId); + await productShowPage.inStockTab.click(); + await productShowPage.inStockTabSection.isLoaded(); + + const receivingBinRowIndex = + await productShowPage.inStockTabSection.getRowIndexByBinLocation( + receivingBin + ); + const row = productShowPage.inStockTabSection.row(receivingBinRowIndex); + await expect(row.quantityOnHand).toHaveText('-20'); + }); + + await test.step('Assert product shows first on All Products tab due to negative inv', async () => { + await expect(async () => { + await manageCycleCountPage.goToPage(); + await manageCycleCountPage.isLoaded(); + await manageCycleCountPage.openAllProductsTab(); + + const row = manageCycleCountPage.allProductsTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.quantity).not.toBeEmpty(); + await expect(row.binLocation).toContainText('CC-BIN-1'); + await expect(row.binLocation).toContainText('CC-BIN-2'); + await expect(row.binLocation).toContainText(receivingBin); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + }); + + await test.step('Assert Negative quantity filter tooltip and apply the filter', async () => { + const tooltip = await manageCycleCountPage.openNegativeQuantityTooltip(); + await expect(tooltip).toContainText( + 'Returns products with negative inventory items in stock. Unselected for all products.' + ); + + await manageCycleCountPage.negativeQuantityCheckbox.check(); + await manageCycleCountPage.filterButton.click(); + await manageCycleCountPage.allProductsTable.isLoaded(); + + await expect(manageCycleCountPage.allProductsTable.rows).toHaveCount(1); + const row = manageCycleCountPage.allProductsTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.quantity).not.toBeEmpty(); + }); + + await test.step('Select item and mark as To Count', async () => { + const row = manageCycleCountPage.allProductsTable.row(0); + await row.checkbox.check(); + await manageCycleCountPage.markAsToCountButton.click(); + }); + + await test.step('Start count from the To Count tab', async () => { + await expect(manageCycleCountPage.toCountTab).toHaveClass('active-tab'); + await manageCycleCountPage.toCountTable.isLoaded(); + + const [response] = await Promise.all([ + page.waitForResponse((res) => res.url().includes('/start/batch')), + manageCycleCountPage.startCountButton.click(), + ]); + const body = await response.json(); + cycleCountId = body.data[0].id; + await countStepPage.isLoaded(); + }); + + await test.step('Assert bin locations on count step and fill quantities', async () => { + await expect(countStepPage.countStepTable.rows).toHaveCount(3); + + const ccBin1Row = countStepPage.countStepTable.row( + await countStepPage.countStepTable.getRowIndexByBinLocation('CC-BIN-1') + ); + await expect(ccBin1Row.binLocation).toContainText('CC-BIN-1'); + await ccBin1Row.fillQuantityCounted('0'); + + const ccBin2Row = countStepPage.countStepTable.row( + await countStepPage.countStepTable.getRowIndexByBinLocation('CC-BIN-2') + ); + await expect(ccBin2Row.binLocation).toContainText('CC-BIN-2'); + await ccBin2Row.fillQuantityCounted('10'); + + const receivingBinRow = countStepPage.countStepTable.row( + await countStepPage.countStepTable.getRowIndexByBinLocation(receivingBin) + ); + await expect(receivingBinRow.binLocation).toContainText(receivingBin); + await receivingBinRow.fillQuantityCounted('0'); + }); + + await test.step('Save progress and go to Perform Cycle Count', async () => { + await countStepPage.saveProgressButton.click(); + await navbar.inventory.click(); + await expect(navbar.performCycleCount).toBeVisible(); + await page.keyboard.press('Escape'); + + await manageCycleCountPage.goToPerformCycleCount(); + await expect(manageCycleCountPage.toCountTab).toHaveClass('active-tab'); + }); + + await test.step('Assert product present on To Count tab with In progress status', async () => { + await expect(async () => { + await manageCycleCountPage.goToPerformCycleCount(); + await manageCycleCountPage.toCountTable.isLoaded(); + await expect(manageCycleCountPage.toCountTable.rows).toHaveCount(1); + + const row = manageCycleCountPage.toCountTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.status).toHaveText('In progress'); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + }); + + await test.step('Apply Negative quantity filter on To Count tab', async () => { + await manageCycleCountPage.negativeQuantityCheckbox.check(); + await manageCycleCountPage.filterButton.click(); + + await expect(manageCycleCountPage.toCountTable.rows).toHaveCount(1); + const row = manageCycleCountPage.toCountTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.quantity).not.toBeEmpty(); + }); + + await test.step('Select item and start count again', async () => { + const row = manageCycleCountPage.toCountTable.row(0); + await row.checkbox.check(); + await manageCycleCountPage.startCountButton.click(); + await countStepPage.isLoaded(); + }); + + await test.step('Assert previously entered quantities were not lost', async () => { + await expect(async () => { + await page.reload(); + await countStepPage.isLoaded(); + + const ccBin1Row = countStepPage.countStepTable.row( + await countStepPage.countStepTable.getRowIndexByBinLocation( + 'CC-BIN-1' + ) + ); + await expect(ccBin1Row.quantityCountedInput).toHaveValue('0'); + + const ccBin2Row = countStepPage.countStepTable.row( + await countStepPage.countStepTable.getRowIndexByBinLocation( + 'CC-BIN-2' + ) + ); + await expect(ccBin2Row.quantityCountedInput).toHaveValue('10'); + + const receivingBinRow = countStepPage.countStepTable.row( + await countStepPage.countStepTable.getRowIndexByBinLocation( + receivingBin + ) + ); + await expect(receivingBinRow.quantityCountedInput).toHaveValue('0'); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + }); + + await test.step('Save count and choose Not now on the resolve discrepancies dialog', async () => { + await countStepPage.nextButton.click(); + await confirmToCountStepPage.isLoaded(); + await confirmToCountStepPage.saveButton.click(); + + await confirmToCountStepPage.resolveDiscrepancyDialog.isLoaded(); + await confirmToCountStepPage.resolveDiscrepancyDialog.notNowButton.click(); + }); + + await test.step('Assert product present on To Resolve tab with To resolve status', async () => { + await expect(manageCycleCountPage.toResolveTab).toHaveClass('active-tab'); + await expect(manageCycleCountPage.toResolveTable.rows).toHaveCount(1); + + const row = manageCycleCountPage.toResolveTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.status).toHaveText('To resolve'); + }); + + await test.step('Apply Negative quantity filter on To Resolve tab', async () => { + await manageCycleCountPage.negativeQuantityCheckbox.check(); + await manageCycleCountPage.filterButton.click(); + + await expect(manageCycleCountPage.toResolveTable.rows).toHaveCount(1); + const row = manageCycleCountPage.toResolveTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.quantity).not.toBeEmpty(); + }); + + await test.step('Select item and start resolution', async () => { + const row = manageCycleCountPage.toResolveTable.row(0); + await row.checkbox.check(); + await manageCycleCountPage.startResolutionButton.click(); + await recountStepPage.isLoaded(); + }); + + await test.step('Assert bin locations and quantity counted on recount step, then fill quantity recounted', async () => { + await expect(recountStepPage.recountStepTable.rows).toHaveCount(3); + + const ccBin1Row = recountStepPage.recountStepTable.row( + await recountStepPage.recountStepTable.getRowIndexByBinLocation( + 'CC-BIN-1' + ) + ); + await expect(ccBin1Row.binLocation).toContainText('CC-BIN-1'); + await expect(ccBin1Row.quantityCounted).toHaveText('0'); + await expect(ccBin1Row.countDifferenceValue).toHaveText('20'); + await expect(ccBin1Row.countDifferenceValue).toHaveCSS( + 'color', + 'rgb(199, 22, 16)' + ); + await expect(ccBin1Row.countDifferenceIcon).toBeVisible(); + await ccBin1Row.fillQuantityRecounted('0'); + + const ccBin2Row = recountStepPage.recountStepTable.row( + await recountStepPage.recountStepTable.getRowIndexByBinLocation( + 'CC-BIN-2' + ) + ); + await expect(ccBin2Row.binLocation).toContainText('CC-BIN-2'); + await expect(ccBin2Row.quantityCounted).toHaveText('10'); + await expect(ccBin2Row.countDifferenceValue).toHaveText('EQUAL'); + await expect(ccBin2Row.countDifferenceValue).toHaveCSS( + 'color', + 'rgb(0, 82, 204)' + ); + await expect(ccBin2Row.countDifferenceIcon).toBeHidden(); + await ccBin2Row.fillQuantityRecounted('10'); + + const receivingBinRow = recountStepPage.recountStepTable.row( + await recountStepPage.recountStepTable.getRowIndexByBinLocation( + receivingBin + ) + ); + await expect(receivingBinRow.binLocation).toContainText(receivingBin); + await expect(receivingBinRow.quantityCounted).toHaveText('0'); + await expect(receivingBinRow.countDifferenceValue).toHaveText('20'); + await expect(receivingBinRow.countDifferenceValue).toHaveCSS( + 'color', + 'rgb(19, 173, 96)' + ); + await expect(receivingBinRow.countDifferenceIcon).toBeVisible(); + await receivingBinRow.fillQuantityRecounted('0'); + }); + + await test.step('Click Next, validate empty root cause message, click Next again', async () => { + await recountStepPage.nextButton.click(); + + await expect(recountStepPage.emptyRootCauseAlert).toBeVisible(); + await recountStepPage.closeAlert(); + await recountStepPage.nextButton.click(); + await confirmToRecountStepPage.isLoaded(); + }); + + await test.step('Save recount to finalize the cycle count', async () => { + await confirmToRecountStepPage.saveButton.click(); + await manageCycleCountPage.isLoaded(); + }); + + await test.step('Assert only one inventory item in stock on the stock card', async () => { + await productShowPage.goToPage(productId); + await productShowPage.inStockTab.click(); + await productShowPage.inStockTabSection.isLoaded(); + + await expect(productShowPage.inStockTabSection.rows).toHaveCount(3); + const row = productShowPage.inStockTabSection.row(1); + await expect(row.binLocation).toContainText('CC-BIN-2'); + await expect(row.quantityOnHand).toHaveText('10'); + }); + + await test.step('Assert product no longer shows first on All Products tab', async () => { + await expect(async () => { + await manageCycleCountPage.goToPage(); + await manageCycleCountPage.isLoaded(); + await manageCycleCountPage.openAllProductsTab(); + + const row = manageCycleCountPage.allProductsTable.row(0); + await expect(row.product).not.toContainText(productName); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + }); + + await test.step('Assert Negative quantity filter now returns an empty table', async () => { + await manageCycleCountPage.negativeQuantityCheckbox.check(); + await manageCycleCountPage.filterButton.click(); + + await expect(manageCycleCountPage.allProductsTable.rows).toHaveCount(0); + }); + }); +}); diff --git a/src/utils/tableUtils.ts b/src/utils/tableUtils.ts index c6f0c0b..8b30a00 100644 --- a/src/utils/tableUtils.ts +++ b/src/utils/tableUtils.ts @@ -1,4 +1,4 @@ -import { Locator } from '@playwright/test'; +import { expect, Locator } from '@playwright/test'; export const captureRowValues = async ( rowCount: number, @@ -16,3 +16,22 @@ export const captureRowValues = async ( .map((v) => v?.trim()) .filter((v): v is string => Boolean(v)); }; + +// the row list can be briefly stale/empty right after a reload, so retry +// the scan the same way a locator-based lookup would implicitly do via +// expect()'s auto-retrying assertions +export const findRowIndexByText = async ( + rows: Locator, + text: string, + { timeout = 10000 } = {} +): Promise => { + let matchedIndex = -1; + await expect(async () => { + const texts = await rows.allTextContents(); + matchedIndex = texts.findIndex((rowText) => rowText.includes(text)); + if (matchedIndex === -1) { + throw new Error(`Row with text "${text}" not found`); + } + }).toPass({ timeout }); + return matchedIndex; +};