diff --git a/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx b/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx index 1db5eddc90a5..99e80e9375f4 100644 --- a/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx +++ b/src/components/Search/FilterDropdowns/SearchFooterPopup.tsx @@ -7,7 +7,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getFooterTotalItems} from '@libs/SearchUIUtils'; +import {getFooterCountItems, getFooterTotalItems} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -84,10 +84,7 @@ function SearchFooterPopup({ const styles = useThemeStyles(); const [selectedFooterFilter, setSelectedFooterFilter] = useState(null); - const countItems: Array> = [ - {text: translate('common.expenses'), value: CONST.SEARCH.FOOTER_COUNT.EXPENSES}, - {text: translate('common.reports'), value: CONST.SEARCH.FOOTER_COUNT.REPORTS}, - ]; + const countItems: Array> = getFooterCountItems(translate); const totalItems = getFooterTotalItems(translate); if (!selectedFooterFilter) { @@ -104,9 +101,7 @@ function SearchFooterPopup({ )} {shouldShowTotalSelector && !!totalType && ( item.value === totalType)?.text} @@ -115,7 +110,7 @@ function SearchFooterPopup({ /> )} ); - const countLabel = translate(countType === CONST.SEARCH.FOOTER_COUNT.REPORTS ? 'common.reports' : 'common.expenses'); + const countLabel = getFooterCountItems(translate).find((item) => item.value === (countType ?? CONST.SEARCH.FOOTER_COUNT.EXPENSES))?.text; // The default reads as "Total spend" in the footer but as plain "Spend" inside the menu, where the row it sits in is // already labelled Total. diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 90140bc9bf99..8a9c5378cc55 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -47,6 +47,9 @@ type FooterCurrencyState = { /** The query's currency when the pick was made. Once the query moves off it, the query's own currency wins */ queryCurrency: string | undefined; + + /** The currency the pick wrote into the query. The default is left out, so a Reset writes none */ + writtenQueryCurrency: string | undefined; }; const EMPTY_FOOTER_SELECTION = {footerCount: undefined, footerTotal: undefined, footerCurrency: undefined}; @@ -171,6 +174,7 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp selectedCurrency: undefined, defaultCurrency: undefined, queryCurrency: undefined, + writtenQueryCurrency: undefined, }); // Whether the backend is still answering the breakdown the footer asked for. The request keys no hash of its own — // it refreshes the figures of the search already on screen — so the wait is held here and ends with the response. @@ -183,9 +187,10 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp const pendingReportConversionSources = useRef>>({}); const isCurrentFooterState = footerCurrencyState.searchHash === currentSearchHash; // The query carries the currency across a reload and into a saved search, so it is what an untouched footer starts - // from. This session's own pick bridges the moment until the query catches up with it. Once the query's currency - // moves (the pick landing, an edit, or a saved search on the same hash), the query wins. - const isPickAheadOfQuery = isCurrentFooterState && footerCurrencyState.queryCurrency === footerSelection.footerCurrency; + // from. This session's own pick holds while the query is on the currency it was made on or the one it wrote. Once + // the query moves anywhere else (an edit, or a saved search on the same hash), the query wins. + const isPickAheadOfQuery = + isCurrentFooterState && (footerCurrencyState.queryCurrency === footerSelection.footerCurrency || footerCurrencyState.writtenQueryCurrency === footerSelection.footerCurrency); const selectedCurrency = (isPickAheadOfQuery ? footerCurrencyState.selectedCurrency : undefined) ?? footerSelection.footerCurrency; const defaultFooterCurrency = isCurrentFooterState ? footerCurrencyState.defaultCurrency : undefined; @@ -667,13 +672,15 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp }; const handleFooterCurrencyChange = (currency: string) => { + const writtenQueryCurrency = currency === searchTargetCurrency ? undefined : currency; setFooterCurrencyState({ searchHash: currentSearchHash, selectedCurrency: currency, defaultCurrency: effectiveDefaultCurrency, queryCurrency: footerSelection.footerCurrency, + writtenQueryCurrency, }); - applyFooterSelection({footerCurrency: currency}); + applyFooterSelection({footerCurrency: writtenQueryCurrency}); }; const handleFooterTotalChange = (nextTotalType: SearchFooterTotal) => { @@ -851,10 +858,16 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp // The reports a selection covers. The server's report count describes the whole search, so a selection needs its own: // on a Reports search that is the selected reports, elsewhere the distinct reports the selected expenses sit on. - // An unreported expense sits on no report — it carries the unreported placeholder ID — so it adds none. + // An unreported expense sits on no report — it carries the unreported placeholder ID — so it adds none. Neither does + // an IOU report, which the server's count leaves out, so the count holds once the server's figures take over. + const isIOUReportID = (reportID: string) => currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.type === CONST.REPORT.TYPE.IOU; const selectedReportCount = isReportsSearch - ? selectedReports.length - : new Set(selectedTransactionsKeys.map((key) => selectedTransactions[key]?.reportID).filter((reportID) => !!reportID && reportID !== CONST.REPORT.UNREPORTED_REPORT_ID)).size; + ? selectedReports.filter((report) => report.type !== CONST.REPORT.TYPE.IOU).length + : new Set( + selectedTransactionsKeys + .map((key) => selectedTransactions[key]?.reportID) + .filter((reportID): reportID is string => !!reportID && reportID !== CONST.REPORT.UNREPORTED_REPORT_ID && !isIOUReportID(reportID)), + ).size; // The count follows whatever the footer is describing: the selection's own reports when rows were hand-picked, the // server's report count otherwise. footerData.count already carries the expense side of both cases. diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index d97328d375b5..d290330a10fd 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -1221,7 +1221,14 @@ const NON_FILTER_CHIP_KEYS = new Set([ CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL, CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY, ]); -const NON_SAVABLE_FILTER_KEYS = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY]); +// Keys whose change alone doesn't offer Save. The Spend footer's selections are still saved with a search, but changing +// them is undone from the footer's own Reset, so they offer neither Save nor Reset in the header. +const NON_SAVABLE_FILTER_KEYS = new Set([ + CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_COUNT, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_TOTAL, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FOOTER_CURRENCY, +]); function buildQueryStringWithResetFilters(currentQueryJSON: SearchQueryJSON, defaultQueryJSON: SearchQueryJSON | undefined) { const resetFilters = (defaultQueryJSON?.flatFilters ?? []).filter((filter) => !NON_FILTER_CHIP_KEYS.has(filter.key)); @@ -3136,7 +3143,9 @@ function doesQueryMatchDefaultFilterKeysAndType(queryJSON: SearchQueryJSON | und } const queryFilterKeys = new Set(queryJSON.flatFilters.map((filter) => filter.key)); - const defaultQueryFilterKeys = new Set(defaultQueryJSON.flatFilters.map((filter) => filter.key)); + // The Spend footer's keys pick what the footer shows, not which rows match, and a default choice is left out of the + // query. So they never decide whether a last query still belongs to its default one. + const defaultQueryFilterKeys = new Set(defaultQueryJSON.flatFilters.map((filter) => filter.key).filter((key) => !FOOTER_FILTER_KEYS.has(key))); return [...defaultQueryFilterKeys].every((value) => queryFilterKeys.has(value)) && queryJSON.type === defaultQueryJSON.type; } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 039fcf201e04..59ec42b80614 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -41,6 +41,7 @@ import type { SearchDateFilterKeys, SearchDatePreset, SearchFilterKey, + SearchFooterCount, SearchFooterTotal, SearchGroupBy, SearchPaidStatus, @@ -5405,6 +5406,14 @@ function getViewOptions(translate: LocalizedTranslate) { return Object.values(CONST.SEARCH.VIEW).map>((value) => ({text: translate(`search.view.${value}`), value})); } +/** The options the Spend footer's count selector offers, in display order. */ +function getFooterCountItems(translate: LocalizedTranslate) { + return [ + {text: translate('common.expenses'), value: CONST.SEARCH.FOOTER_COUNT.EXPENSES}, + {text: translate('common.reports'), value: CONST.SEARCH.FOOTER_COUNT.REPORTS}, + ] satisfies Array>; +} + /** * The options the Spend footer's total selector offers, in display order. `Total spend` is the default and always * applies. The four aggregates are computed by the backend, so picking one re-runs the search. @@ -7479,6 +7488,7 @@ export { getSortOrderOptions, getGroupBySections, getViewOptions, + getFooterCountItems, getFooterTotalItems, getCurrencyOptions, getFeedOptions, diff --git a/src/pages/Search/SearchSavePage.tsx b/src/pages/Search/SearchSavePage.tsx index d8befb33b33e..b32ecd1f5436 100644 --- a/src/pages/Search/SearchSavePage.tsx +++ b/src/pages/Search/SearchSavePage.tsx @@ -26,7 +26,7 @@ import {saveSearch} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import {savedSearchIDToSearchKey} from '@libs/SearchKeyUtils'; -import {getCustomColumnDefault, getSearchColumnTranslationKey, getValidGroupBy, mapFiltersFormToLabelValueList} from '@libs/SearchUIUtils'; +import {getCustomColumnDefault, getFooterCountItems, getFooterTotalItems, getSearchColumnTranslationKey, getValidGroupBy, mapFiltersFormToLabelValueList} from '@libs/SearchUIUtils'; import type {SearchFilter} from '@libs/SearchUIUtils'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; @@ -136,6 +136,22 @@ function getAppliedDisplays(searchAdvancedFiltersForm: Partial item.value === footerCount)?.text ?? footerCount}); + } + + if (searchAdvancedFiltersForm.footerTotal) { + const footerTotal = searchAdvancedFiltersForm.footerTotal; + appliedDisplays.push({label: translate('common.total'), value: getFooterTotalItems(translate).find((item) => item.value === footerTotal)?.text ?? footerTotal}); + } + + if (searchAdvancedFiltersForm.footerCurrency) { + appliedDisplays.push({label: translate('common.currency'), value: searchAdvancedFiltersForm.footerCurrency}); + } + if (queryJSON?.sortBy) { appliedDisplays.push({label: translate('search.display.sortBy'), value: translate(getSearchColumnTranslationKey(queryJSON.sortBy, queryJSON.type))}); } diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 9e80544b68e4..2cd8c04042a7 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -3145,6 +3145,28 @@ describe('SearchQueryUtils', () => { expect(isSearchQuerySavable(undefined, defaultQueryJSON)).toBe(false); }); + it('returns false when only a Spend footer selection differs from the default query', () => { + // Given a saved search on the reimbursable total + const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel footerTotal:reimbursable'); + + // When only the footer selections change, which the footer's own Reset undoes + const currentQueryJSON = buildSearchQueryJSON('type:expense category:travel footerTotal:billable footerCount:reports footerCurrency:EUR'); + + // Then the header doesn't offer Save + expect(isSearchQuerySavable(currentQueryJSON, defaultQueryJSON)).toBe(false); + }); + + it('still returns true when a row filter changes alongside a footer selection', () => { + // Given a search on one category + const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel'); + + // When the category changes along with the footer total + const currentQueryJSON = buildSearchQueryJSON('type:expense category:meals footerTotal:billable'); + + // Then the header offers Save, since a row filter changed + expect(isSearchQuerySavable(currentQueryJSON, defaultQueryJSON)).toBe(true); + }); + it('returns false when the query equals the default query', () => { // Given a query that matches its default const defaultQueryJSON = buildSearchQueryJSON('type:expense category:travel'); @@ -5991,6 +6013,42 @@ describe('SearchQueryUtils', () => { const lastQuery = 'type:invoice status:all'; expect(getValidLastQuery(lastQuery, defaultSearchQuery)).toBe(defaultSearchQuery); }); + + describe('with the Spend footer selections', () => { + it('keeps a last query switched back to the default total, which leaves the total out', () => { + // Given a saved search on the reimbursable total + const savedQuery = 'type:expense status:all footerTotal:reimbursable'; + + // When it is switched back to the plain total, which leaves the total out of the last query + const lastQuery = 'type:expense status:all'; + + // Then the last choice comes back, the same as when switching to any other total + expect(getValidLastQuery(lastQuery, savedQuery)).toBe(lastQuery); + expect(getValidLastQuery('type:expense status:all footerTotal:non-reimbursable', savedQuery)).toBe('type:expense status:all footerTotal:non-reimbursable'); + }); + + it('keeps a last query switched back to the default count, which leaves the count out', () => { + // Given a saved search on the report count + const savedQuery = 'type:expense status:all footerCount:reports'; + + // When it is switched back to the expense count, which leaves the count out of the last query + const lastQuery = 'type:expense status:all'; + + // Then the last choice comes back + expect(getValidLastQuery(lastQuery, savedQuery)).toBe(lastQuery); + }); + + it('still drops a last query that lost a real filter', () => { + // Given a saved search with a footer total + const savedQuery = 'type:expense status:all footerTotal:reimbursable'; + + // When its last query drops the status filter + const lastQuery = 'type:expense footerTotal:reimbursable'; + + // Then the saved query comes back, since a row filter still decides + expect(getValidLastQuery(lastQuery, savedQuery)).toBe(savedQuery); + }); + }); }); describe('doesQueryMatchDefaultFilterKeysAndType', () => { diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 623ee2fbdf76..51e43a08e390 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -4,7 +4,7 @@ import SearchSelectionFooter, {resetAnsweredFooterTotalsForTesting} from '@compo import type {SearchFooterCount, SearchFooterTotal, SearchQueryJSON, SelectedReports, SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types'; import {getFooterConvertedAmounts} from '@libs/actions/Search'; -import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {buildSearchQueryJSON, isSearchQuerySavable} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -13,6 +13,7 @@ import type {SearchResults} from '@src/types/onyx'; import Onyx from 'react-native-onyx'; import createRandomTransaction from '../../utils/collections/transaction'; +import createMock from '../../utils/createMock'; import {makeSettlementGroup} from '../../utils/ExpensifyCardStatementTestUtils'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -1046,6 +1047,67 @@ describe('SearchSelectionFooter', () => { expect(mockSetParams.mock.calls.at(0)?.at(0)?.q).toContain('footerCurrency:EUR'); }); + it('leaves the default currency out of the query, so a Reset no longer offers to save the search', async () => { + // Given a search whose footer was switched to EUR + setSearchQuery(`type:expense footerCurrency:${CONST.CURRENCY.EUR}`); + mockSelectedTransactions.current = {}; + render(); + await waitForBatchedUpdates(); + const defaultCurrency = mockCapturedFooterProps.current?.defaultCurrency ?? ''; + + // When the currency is reset, which picks the default currency explicitly + await act(async () => { + mockCapturedFooterProps.current?.onCurrencyChange?.(defaultCurrency); + await waitForBatchedUpdates(); + }); + + // Then the query carries no currency, so it matches the default search and the Save button goes away + const nextQuery = mockSetParams.mock.calls.at(-1)?.at(0)?.q ?? ''; + expect(nextQuery).not.toContain('footerCurrency'); + expect(isSearchQuerySavable(buildSearchQueryJSON(nextQuery), buildSearchQueryJSON('type:expense'))).toBe(false); + }); + + it('keeps converting to the reset currency once the query lands without one', async () => { + // Given a search on EUR whose snapshot is still in another currency than the default it resets to + setSearchQuery(`type:expense footerCurrency:${CONST.CURRENCY.EUR}`); + mockSelectedTransactions.current = {}; + const {rerender} = render(); + await waitForBatchedUpdates(); + const defaultCurrency = mockCapturedFooterProps.current?.defaultCurrency ?? ''; + expect(defaultCurrency).not.toBe(CONST.CURRENCY.USD); + + // When the currency is reset and the query, now carrying no currency, lands + await act(async () => { + mockCapturedFooterProps.current?.onCurrencyChange?.(defaultCurrency); + await waitForBatchedUpdates(); + }); + jest.mocked(getFooterConvertedAmounts).mockClear(); + setSearchQuery(mockSetParams.mock.calls.at(-1)?.at(0)?.q ?? ''); + rerender(); + await waitForBatchedUpdates(); + + // Then the footer still converts to the reset currency instead of falling back to the snapshot's + expect(getFooterConvertedAmounts).toHaveBeenCalledWith(expect.objectContaining({targetCurrency: defaultCurrency})); + }); + + it('still writes a currency other than the default into the query', async () => { + // Given a search with no footer currency + setSearchQuery('type:expense'); + mockSelectedTransactions.current = {}; + render(); + await waitForBatchedUpdates(); + + // When a currency other than the default is picked + const otherCurrency = mockCapturedFooterProps.current?.defaultCurrency === CONST.CURRENCY.EUR ? CONST.CURRENCY.GBP : CONST.CURRENCY.EUR; + await act(async () => { + mockCapturedFooterProps.current?.onCurrencyChange?.(otherCurrency); + await waitForBatchedUpdates(); + }); + + // Then it is kept in the query, so it survives a reload and a saved search + expect(mockSetParams.mock.calls.at(-1)?.at(0)?.q).toContain(`footerCurrency:${otherCurrency}`); + }); + it('lets the query currency take over once it moves off the one the pick was made on', async () => { // Given EUR picked on a query that carried no currency setSearchQuery('type:expense'); @@ -1141,6 +1203,50 @@ describe('SearchSelectionFooter', () => { expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 2})); }); + it('counts no report for a selected expense on an IOU report, which the server count leaves out too', async () => { + // Given one expense on an expense report and one on an IOU report, both in the loaded data + setSearchQuery('type:expense footerCount:reports'); + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'expenseReport'}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'iouReport'}, + }; + const searchResults = buildSearchResults(CONST.CURRENCY.USD, 1204, 36000, CONST.SEARCH.DATA_TYPES.EXPENSE, 87); + mockCurrentSearchResults.current = createMock({ + ...searchResults, + data: { + [`${ONYXKEYS.COLLECTION.REPORT}expenseReport` as const]: {reportID: 'expenseReport', type: CONST.REPORT.TYPE.EXPENSE}, + [`${ONYXKEYS.COLLECTION.REPORT}iouReport` as const]: {reportID: 'iouReport', type: CONST.REPORT.TYPE.IOU}, + }, + }); + + // When the footer counts the reports the selection covers + render(); + await waitForBatchedUpdates(); + + // Then only the expense report counts, so the figure holds once the server's count takes over + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 1})); + }); + + it('counts no selected IOU report on a Reports search either', async () => { + // Given a selected expense report and a selected IOU report on a Reports search + setSearchQuery('type:expense-report footerCount:reports'); + mockSelectedTransactions.current = { + transaction1: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'expenseReport'}, + transaction2: {...buildSelectedTransaction(CONST.CURRENCY.USD), reportID: 'iouReport'}, + }; + mockSelectedReports.current = [ + {...buildSelectedReport('expenseReport', -100), type: CONST.REPORT.TYPE.EXPENSE}, + {...buildSelectedReport('iouReport', -100), type: CONST.REPORT.TYPE.IOU}, + ]; + + // When the footer counts the selected reports + render(); + await waitForBatchedUpdates(); + + // Then the IOU report is left out + expect(mockCapturedFooterProps.current).toEqual(expect.objectContaining({countType: CONST.SEARCH.FOOTER_COUNT.REPORTS, count: 1})); + }); + it('counts no report for a selected unreported expense, which sits on none', async () => { setSearchQuery('type:expense footerCount:reports'); // Two expenses from one report, one unreported: three expenses, one report. diff --git a/tests/unit/pages/Search/SearchSavePageTest.tsx b/tests/unit/pages/Search/SearchSavePageTest.tsx index 258c06a1d59e..12992acbccf4 100644 --- a/tests/unit/pages/Search/SearchSavePageTest.tsx +++ b/tests/unit/pages/Search/SearchSavePageTest.tsx @@ -82,3 +82,23 @@ it('renders scalar feed and tax values without calling array display hooks', () expect(['feed-scalar', 'tax-scalar'].every((text) => output.includes(text))).toBe(true); expect([jest.mocked(useFilterFeedValue).mock.calls.length, jest.mocked(useFilterTaxRateValue).mock.calls.length]).toEqual([0, 0]); }); +it("lists the Spend footer's choices with the display settings, in the footer's own wording", () => { + // Given a search saved with the footer switched to the report count, the reimbursable total and EUR + form = {footerCount: CONST.SEARCH.FOOTER_COUNT.REPORTS, footerTotal: CONST.SEARCH.FOOTER_TOTAL.REIMBURSABLE, footerCurrency: CONST.CURRENCY.EUR}; + + // When the save page renders its summary + const output = JSON.stringify(render().toJSON()); + + // Then each choice shows under its own label, so the user sees what the saved search will reopen on + expect(['Count', 'Reports', 'Total', 'Reimbursable', 'Currency', CONST.CURRENCY.EUR].every((text) => output.includes(text))).toBe(true); +}); +it('lists no footer rows for a search that keeps the footer defaults', () => { + // Given a search whose footer was left on its defaults, which the query leaves out + form = {}; + + // When the save page renders its summary + const output = JSON.stringify(render().toJSON()); + + // Then no footer choice is listed + expect(['Count', 'Reimbursable', 'Currency'].some((text) => output.includes(text))).toBe(false); +});