From 560f027675ccbcb07713266796004cc141bbe462 Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Wed, 31 Jul 2024 11:07:45 -0500 Subject: [PATCH 01/10] Layouts --- test/src/layouts.test.tsx | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/src/layouts.test.tsx diff --git a/test/src/layouts.test.tsx b/test/src/layouts.test.tsx new file mode 100644 index 000000000..9dc08b8d0 --- /dev/null +++ b/test/src/layouts.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import {describe, it} from '@jest/globals'; +import LandingLayout from '~/layouts/landing/landing'; +import { MemoryRouter } from 'react-router-dom'; + +describe('layouts/landing', () => { + const data: Parameters[0]['data'] = { + title: 'the-title', + layout: [] + }; + + it('renders without layout values', () => { + render( + + +
child contents
+
+
+ ); + expect(screen.getAllByRole('img')).toHaveLength(2); + expect(screen.getAllByRole('link')).toHaveLength(3); + }); + it('renders with layout values', () => { + data.layout.push({ + value: { + navLinks: [ + { + text: 'link-name', + target: { + type: 'link-type', + value: 'link-value' + } + } + ] + } + }); + render( + + +
child contents
+
+
+ ); + expect(screen.getAllByRole('link')).toHaveLength(4); + }); +}); From 6671221cdd8acb431852bdca68fd20d406d1620d Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Wed, 31 Jul 2024 15:42:40 -0500 Subject: [PATCH 02/10] Flex-page --- src/app/pages/flex-page/flex-page.tsx | 7 +- test/src/layouts.test.tsx | 2 +- test/src/pages/flex-page.test.tsx | 293 ++++++++++++++++++++++++++ 3 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 test/src/pages/flex-page.test.tsx diff --git a/src/app/pages/flex-page/flex-page.tsx b/src/app/pages/flex-page/flex-page.tsx index e6620901e..765809fba 100644 --- a/src/app/pages/flex-page/flex-page.tsx +++ b/src/app/pages/flex-page/flex-page.tsx @@ -1,10 +1,13 @@ import React from 'react'; import { LoadedPage } from '~/components/jsx-helpers/loader-page'; -import { Data } from '~/helpers/use-page-data'; import { ContentBlocks, ContentBlockConfig } from './blocks/ContentBlock'; +type Data = { + body: ContentBlockConfig[]; +} + function FlexPageBody({data}: {data: Data}) { - return ; + return ; } export default function FlexPage({data}: {data: Data}) { diff --git a/test/src/layouts.test.tsx b/test/src/layouts.test.tsx index 9dc08b8d0..c7f40e377 100644 --- a/test/src/layouts.test.tsx +++ b/test/src/layouts.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; import {render, screen} from '@testing-library/preact'; import {describe, it} from '@jest/globals'; -import LandingLayout from '~/layouts/landing/landing'; import { MemoryRouter } from 'react-router-dom'; +import LandingLayout from '~/layouts/landing/landing'; describe('layouts/landing', () => { const data: Parameters[0]['data'] = { diff --git a/test/src/pages/flex-page.test.tsx b/test/src/pages/flex-page.test.tsx new file mode 100644 index 000000000..b491bdb54 --- /dev/null +++ b/test/src/pages/flex-page.test.tsx @@ -0,0 +1,293 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import {describe, it} from '@jest/globals'; +import userEvent from '@testing-library/user-event'; +import {MemoryRouter} from 'react-router-dom'; +import FlexPage from '~/pages/flex-page/flex-page'; +import {CTALinkFields} from '~/pages/flex-page/blocks/CTABlock'; +import {ContentBlockConfig} from '~/pages/flex-page/blocks/ContentBlock'; + +const emptyTarget = { + type: '', + value: '' +}; +const ctaActions: CTALinkFields[] = [ + { + config: [ + { + type: 'style', + value: 'string' + } + ], + text: 'cta-text', + target: { + type: 'cta-target-type', + value: 'cta-target-value' + } + }, + { + config: [], + text: 'cta-text2', + target: emptyTarget + } +]; + +type Data = Parameters[0]['data']; +let body: Data['body']; + +function Component() { + return ( + + + + ); +} + +describe('flex-page', () => { + beforeAll(() => { + const el = document.createElement('meta'); + + el.setAttribute('name', 'description'); + document.head.appendChild(el); + }); + it('renders heroBlock', () => { + body = [heroBlock()]; + render(); + expect(screen.getAllByRole('img')).toHaveLength(1); + }); + it('renders ctaBlock', () => { + body = [ctaBlock()]; + render(); + expect(screen.getAllByRole('link')).toHaveLength(2); + }); + it('renders cardsBlock', () => { + body = [cardsBlock(false), cardsBlock(true)]; + render(); + expect(screen.getAllByRole('link')).toHaveLength(1); + expect(screen.getAllByText('first card')).toHaveLength(2); + }); + it('renders dividerBlock', () => { + body = [dividerBlock(false), dividerBlock(true)]; + render(); + expect(screen.getAllByRole('img')).toHaveLength(2); + }); + it('renders faqBlock', () => { + body = [faqBlock()]; + render(); + expect(screen.getAllByRole('heading')).toHaveLength(1); + expect(screen.getAllByRole('button')).toHaveLength(1); + }); + it('renders htmlBlock', () => { + body = [htmlBlock()]; + render(); + expect(screen.getAllByText('Some html')).toHaveLength(1); + }); + it('renders linksBlock and sectionBlock', async () => { + jest.spyOn(window, 'scrollBy').mockImplementation(() => null); + body = [linksBlock(), sectionBlock()]; + render(); + const anchor = screen.getByText('link-text'); + const nonAnchor = screen.getByText('link2-text'); + const missingTarget = screen.getByText('link3-text'); + const user = userEvent.setup(); + + await user.click(anchor); + expect(window.scrollBy).toHaveBeenCalled(); + + await user.click(nonAnchor); + await user.click(missingTarget); + }); + it('renders quoteBlock', () => { + body = [quoteBlock(), quoteBlock('quote-title')]; + render(); + expect(screen.getAllByText('quote-content')).toHaveLength(2); + expect(screen.getAllByText('quote-title')).toHaveLength(1); + }); + it('renders rtBlock', () => { + body = [rtBlock()]; + render(); + expect(screen.getAllByText('Some text with')).toHaveLength(1); + expect(screen.getAllByText('formatting')).toHaveLength(1); + }); +}); + +function imageBlock(name: string) { + return { + id: `${name}-image-id`, + file: `/foo/${name}-image.jpg`, + height: 400, + width: 300 + }; +} + +function heroBlock(): ContentBlockConfig { + return { + id: 'hero-id', + type: 'hero', + value: { + content: [], + config: [], + image: imageBlock('hero'), + imageAlt: '' + } + }; +} + +function ctaBlock(): ContentBlockConfig { + return { + id: 'cta-id', + type: 'cta_block', + value: { + actions: ctaActions, + config: [] + } + }; +} + +function cardsBlock(withStyle?: boolean): ContentBlockConfig { + return { + id: 'cards-id', + type: 'cards_block', + value: { + cards: [ + { + text: 'first card', + ctaBlock: withStyle ? ctaActions : [] + } + ], + config: withStyle + ? [ + { + type: 'card_style', + id: '', + value: 'rounded' + } + ] + : [] + } + }; +} + +function dividerBlock(aligned: boolean): ContentBlockConfig { + return { + id: 'divider-id', + type: 'divider', + value: { + image: imageBlock('divider'), + config: aligned + ? [ + { + type: 'alignment', + value: 'left' + } + ] + : [] + } + }; +} + +function faqBlock(): ContentBlockConfig { + return { + id: 'faq-id', + type: 'faq', + value: [ + { + id: 'q1', + value: { + question: 'what?', + slug: 'q1', + answer: 'hush', + document: '' + } + } + ] + }; +} + +function htmlBlock(): ContentBlockConfig { + return { + id: 'html-id', + type: 'html', + value: '

Some html

' + }; +} + +function linksBlock(): ContentBlockConfig { + return { + id: 'links-id', + type: 'links_group', + value: { + links: [ + { + text: 'link-text', + target: { + type: 'anchor', + value: '#anchor-target' + } + }, + { + text: 'link2-text', + target: { + type: 'not-anchor', + value: '' + } + }, + { + text: 'link3-text', + target: { + type: 'anchor', + value: '#not-found' + } + } + ], + config: [] + } + }; +} + +function quoteBlock(title?: string): ContentBlockConfig { + return { + id: 'quote-id', + type: 'quote', + value: { + image: imageBlock('quote'), + content: 'quote-content', + name: 'quote-name', + title + } + }; +} + +function rtBlock(): ContentBlockConfig { + return { + id: 'rt-id', + type: 'text', + value: 'Some text with formatting' + }; +} + +function sectionBlock(): ContentBlockConfig { + return { + id: 'section-id', + type: 'section', + value: { + content: [ + { + id: 'oops-id', + type: 'mistake', + value: 'This is invalid content' + } as unknown as ContentBlockConfig + ], + config: [ + { + type: 'background_color', + value: '#f1f1f1' + }, + { + type: 'id', + value: 'anchor-target' + } + ] + } + }; +} From 8e22e4c68738a6ddc28637df24f8e29cee907d24 Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Thu, 1 Aug 2024 09:49:20 -0500 Subject: [PATCH 03/10] Details page, part 1 --- README.md | 10 +++-- src/app/pages/details/dual-view.tsx | 2 +- src/app/pages/details/title-image.tsx | 10 +++-- test/src/pages/details/dual-view.test.tsx | 46 +++++++++++++++++++++ test/src/pages/details/promo.test.tsx | 27 ++++++++++++ test/src/pages/details/title-image.test.tsx | 33 +++++++++++++++ 6 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 test/src/pages/details/dual-view.test.tsx create mode 100644 test/src/pages/details/promo.test.tsx create mode 100644 test/src/pages/details/title-image.test.tsx diff --git a/README.md b/README.md index db44e7404..405108bf0 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,7 @@ To build the site for development and load it in your default web browser with [ script/dev ``` -That will create a new `dev` directory from which the site is served. Changes should be made to files in the `src` directory. Gulp will automatically watch for changes in `src`, perform any compilation and transpilation necessary, and update the result in `dev`. - -You can also run individual tasks. Enter `$(npm bin)/gulp --tasks` to see the full list. +That will create a new `dev` directory from which the site is served. Changes should be made to files in the `src` directory. Webpack will automatically watch for changes in `src`, perform any compilation and transpilation necessary, and update the result in `dev`. ## Testing @@ -56,7 +54,11 @@ script/build script/test ``` -You can also just run the linters (`$(npm bin)/gulp lint`) individually without rebuilding. +You can also just run the linters (`yarn lint`) individually without rebuilding. +You can run individual tests by name (`yarn jest layout.test`). + +Check code coverage by loading into your browser `/coverage/index.html` from the +root of the git repository. **Note:** The unit tests require the dev build to be built (in the `dev` directory). diff --git a/src/app/pages/details/dual-view.tsx b/src/app/pages/details/dual-view.tsx index 22d5cd13d..bc7ee82fd 100644 --- a/src/app/pages/details/dual-view.tsx +++ b/src/app/pages/details/dual-view.tsx @@ -24,7 +24,7 @@ export default function DualView() { } function useViewsUsed() { - const {innerWidth} = useWindowContext() as typeof window; + const {innerWidth} = useWindowContext(); const [phone, setPhone] = React.useState(false); const [desktop, setDesktop] = React.useState(false); diff --git a/src/app/pages/details/title-image.tsx b/src/app/pages/details/title-image.tsx index 51391573e..d2a4d55ee 100644 --- a/src/app/pages/details/title-image.tsx +++ b/src/app/pages/details/title-image.tsx @@ -4,7 +4,7 @@ import cn from 'classnames'; export default function TitleImage() { const {reverseGradient, title, titleImageUrl} = useDetailsContext(); - const titleLogo = ''; // For future use + // const titleLogo = ''; // For future use if (!title || !titleImageUrl) { return null; @@ -21,9 +21,11 @@ export default function TitleImage() { height='130' width='392' /> - {titleLogo && ( - - )} + {/* + titleLogo && ( + + ) + */} diff --git a/test/src/pages/details/dual-view.test.tsx b/test/src/pages/details/dual-view.test.tsx new file mode 100644 index 000000000..27e915205 --- /dev/null +++ b/test/src/pages/details/dual-view.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import {describe, it} from '@jest/globals'; +import DualView from '~/pages/details/dual-view'; +import { WindowContextProvider } from '~/contexts/window'; +import BookDetailsLoader from './book-details-context'; + +const mockUseWindowContext = jest.fn(); + +jest.mock('~/contexts/window', () => ({ + ...jest.requireActual('~/contexts/window'), + __esModule: true, + default: () => mockUseWindowContext() +})); +jest.mock('~/pages/details/common/links-to-translations', () => jest.fn()); + +describe('details/dual-view', () => { + it('renders at phone width', async () => { + mockUseWindowContext.mockReturnValue({innerWidth: 480}); + render( + + + + + + ); + + await screen.findAllByRole('button'); + expect(document?.querySelector('.phone-view')?.textContent?.length).toBeGreaterThan(200); + expect(document?.querySelector('.bigger-view')?.textContent?.length).toBe(0); + }); + it('renders at desktop width', async () => { + mockUseWindowContext.mockReturnValue({innerWidth: 1280}); + render( + + + + + + ); + + await screen.findAllByRole('button'); + expect(document?.querySelector('.bigger-view')?.textContent?.length).toBeGreaterThan(200); + expect(document?.querySelector('.phone-view')?.textContent?.length).toBe(0); + }); +}); diff --git a/test/src/pages/details/promo.test.tsx b/test/src/pages/details/promo.test.tsx new file mode 100644 index 000000000..c02c201ae --- /dev/null +++ b/test/src/pages/details/promo.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import {render} from '@testing-library/preact'; +import {describe, it} from '@jest/globals'; +import Promo from '~/pages/details/desktop-view/promo'; + +describe('details/promo', () => { + it('returns null when no snippet content', () => { + const snippet = [{}]; + + render( + + ); + expect(document.body.textContent).toBe(''); + }); + it('returns the description from the snippet', () => { + const snippet = { + content: { + description: 'snippet description' + } + }; + + render( + + ); + expect(document.body.textContent).toBe('snippet description'); + }); +}); diff --git a/test/src/pages/details/title-image.test.tsx b/test/src/pages/details/title-image.test.tsx new file mode 100644 index 000000000..83cc4c66b --- /dev/null +++ b/test/src/pages/details/title-image.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import {describe, it} from '@jest/globals'; +import TitleImage from '~/pages/details/title-image'; + +const mockUseDetailsContext = jest.fn(); + +jest.mock('~/pages/details/context', () => ({ + ...jest.requireActual('~/pages/details/context'), + __esModule: true, + default: () => mockUseDetailsContext() +})); + +describe('title-image', () => { + it('returns null when no title or title-image', () => { + mockUseDetailsContext.mockReturnValue({ + reverseGradient: false, + title: '', + titleImageUrl: '' + }); + render(); + expect(document.body.innerHTML).toBe('
'); + }); + it('renders when title and title-image are available', async () => { + mockUseDetailsContext.mockReturnValue({ + reverseGradient: false, + title: 'mock-title', + titleImageUrl: 'mock-title-url' + }); + render(); + await screen.findByRole('img'); + }); +}); From 8a54393d32077d1aa4696051dad7d175ee785a2a Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Thu, 1 Aug 2024 16:05:13 -0500 Subject: [PATCH 04/10] details/links-to-translations --- .../details/common/links-to-translations.tsx | 7 ++-- .../src/pages/details/book-details-context.js | 12 ++++++- test/src/pages/details/dual-view.test.tsx | 6 ++++ .../details/links-to-translations.test.tsx | 33 +++++++++++++++++++ 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 test/src/pages/details/links-to-translations.test.tsx diff --git a/src/app/pages/details/common/links-to-translations.tsx b/src/app/pages/details/common/links-to-translations.tsx index a6abd52bf..137dbee94 100644 --- a/src/app/pages/details/common/links-to-translations.tsx +++ b/src/app/pages/details/common/links-to-translations.tsx @@ -45,14 +45,11 @@ function AnotherLanguage({ [translations, locale] ); - if (!translation) { - return null; - } - // translation is guaranteed to have a valid value, because the locale // is pulled from translations return ( - + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ); diff --git a/test/src/pages/details/book-details-context.js b/test/src/pages/details/book-details-context.js index 210d03125..a10ed10b9 100644 --- a/test/src/pages/details/book-details-context.js +++ b/test/src/pages/details/book-details-context.js @@ -4,6 +4,14 @@ import ShellContextProvider from '../../../helpers/shell-context'; import {DetailsContextProvider} from '~/pages/details/context'; import {MemoryRouter} from 'react-router-dom'; +// Tamp down meaningless errors +jest.mock('~/models/rex-release', () => jest.fn().mockReturnValue(Promise.resolve({ + webviewRexLink: '', + contents: [] +}))); +jest.mock('~/models/give-today', () => jest.fn().mockReturnValue({})); +jest.mock('~/models/table-of-contents-html', () => jest.fn().mockReturnValue(Promise.resolve({}))); + function BookDetailsWithContext({data, children}) { return ( @@ -15,8 +23,10 @@ function BookDetailsWithContext({data, children}) { } export default function BookDetailsLoader({slug, children}) { + const absoluteSlug = slug.startsWith('/') ? slug : `/${slug}`; + return ( - + ); diff --git a/test/src/pages/details/dual-view.test.tsx b/test/src/pages/details/dual-view.test.tsx index 27e915205..f80aaf478 100644 --- a/test/src/pages/details/dual-view.test.tsx +++ b/test/src/pages/details/dual-view.test.tsx @@ -15,6 +15,12 @@ jest.mock('~/contexts/window', () => ({ jest.mock('~/pages/details/common/links-to-translations', () => jest.fn()); describe('details/dual-view', () => { + beforeAll(() => { + const el = document.createElement('meta'); + + el.setAttribute('name', 'description'); + document.head.appendChild(el); + }); it('renders at phone width', async () => { mockUseWindowContext.mockReturnValue({innerWidth: 480}); render( diff --git a/test/src/pages/details/links-to-translations.test.tsx b/test/src/pages/details/links-to-translations.test.tsx new file mode 100644 index 000000000..0d2ee45dd --- /dev/null +++ b/test/src/pages/details/links-to-translations.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {render, screen, waitFor} from '@testing-library/preact'; +import {describe, it} from '@jest/globals'; +import BookDetailsLoader from './book-details-context'; +import LinksToTranslations from '~/pages/details/common/links-to-translations'; +import * as detailCtx from '~/pages/details/context'; + +describe('LinksToTranslations', () => { + it('renders translations', async () => { + render( + + + + ); + await screen.findByText('This textbook is available', {exact: false}); + }); + it('renders empty when there are no translations', async () => { + const mockUseDetailsContext = jest.spyOn(detailCtx, 'default'); + + mockUseDetailsContext.mockReturnValue({ + translations: [] + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + render( + + + + ); + + await waitFor(() => expect(mockUseDetailsContext).toHaveBeenCalled()); + expect(document.body.textContent).toBe(''); + mockUseDetailsContext.mockClear(); + }); +}); From 82f0f9786bb599c17513ec48748f149e833309fc Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Mon, 5 Aug 2024 14:52:57 -0500 Subject: [PATCH 05/10] details/resource-box --- .../{resource-boxes.js => resource-boxes.tsx} | 6 +- .../resource-box/video-resource-box.tsx | 6 +- test/src/pages/details/left-content.test.tsx | 143 +++++++++++++ test/src/pages/details/resource-boxes.test.js | 134 ------------ .../src/pages/details/resource-boxes.test.tsx | 190 ++++++++++++++++++ .../pages/details/video-resource-box.test.tsx | 83 ++++++++ 6 files changed, 424 insertions(+), 138 deletions(-) rename src/app/pages/details/common/resource-box/{resource-boxes.js => resource-boxes.tsx} (92%) create mode 100644 test/src/pages/details/left-content.test.tsx delete mode 100644 test/src/pages/details/resource-boxes.test.js create mode 100644 test/src/pages/details/resource-boxes.test.tsx create mode 100644 test/src/pages/details/video-resource-box.test.tsx diff --git a/src/app/pages/details/common/resource-box/resource-boxes.js b/src/app/pages/details/common/resource-box/resource-boxes.tsx similarity index 92% rename from src/app/pages/details/common/resource-box/resource-boxes.js rename to src/app/pages/details/common/resource-box/resource-boxes.tsx index d86778c7b..4885472d5 100644 --- a/src/app/pages/details/common/resource-box/resource-boxes.js +++ b/src/app/pages/details/common/resource-box/resource-boxes.tsx @@ -43,7 +43,11 @@ function CommonsHubBox() { ); } -export default function ResourceBoxes({ models, includeCommonsHub = false }) { +export default function ResourceBoxes({ models, includeCommonsHub = false }: { + models: {heading: string; + }[]; // Will be a real type when other stuff becomes TS + includeCommonsHub?: boolean; +}) { return ( {includeCommonsHub && } diff --git a/src/app/pages/details/common/resource-box/video-resource-box.tsx b/src/app/pages/details/common/resource-box/video-resource-box.tsx index 745d4dedf..4e20d6387 100644 --- a/src/app/pages/details/common/resource-box/video-resource-box.tsx +++ b/src/app/pages/details/common/resource-box/video-resource-box.tsx @@ -20,8 +20,8 @@ type BoxModel = { heading: string }; type VideoResourceBoxesArgs = { models: VideoResourceBoxModelType[]; - blogLinkModels: BoxModel[]; - referenceModels: BoxModel[]; + blogLinkModels?: BoxModel[]; + referenceModels?: BoxModel[]; }; export default function VideoResourceBoxes({ @@ -37,7 +37,7 @@ export default function VideoResourceBoxes({ {blogLinkModels?.map((model) => ( ))} - {referenceModels.map((model) => ( + {referenceModels?.map((model) => ( ))} diff --git a/test/src/pages/details/left-content.test.tsx b/test/src/pages/details/left-content.test.tsx new file mode 100644 index 000000000..ba23f3df3 --- /dev/null +++ b/test/src/pages/details/left-content.test.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; +import {LanguageContextProvider} from '~/contexts/language'; +import {MemoryRouter} from 'react-router-dom'; +import LeftContent from '~/pages/details/common/resource-box/left-content'; + +const mockUseUserContext = jest.fn(); + +jest.mock('~/contexts/user', () => ({ + ...jest.requireActual('~/contexts/user'), + __esModule: true, + default: () => mockUseUserContext() +})); + +describe('left-content', () => { + type ModelType = Parameters[0]['model']; + const baseModel: ModelType = { + comingSoon: false, + iconType: 'lock', + heading: 'heading' + }; + const link = {url: '#good-url', text: 'button-label'}; + // Setup option prevents await click from hanging when using faketimers + const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime}); + + function Component({ + model, + search = 'Instructor' + }: { + model: ModelType; + search?: string; + }) { + return ( + + + + + + ); + } + + mockUseUserContext.mockReturnValue({ + userStatus: { + isInstructor: false + } + }); + + it('returns Access Pending if no link is provided', () => { + render(); + screen.findByText('Access Pending'); + }); + it('returns MISSING LINK if link has no URL', () => { + jest.useFakeTimers(); + const model = {link: {text: 'whoops'}, ...baseModel}; + + render(); + jest.runAllTimers(); + screen.findByText('MISSING LINK'); + }); + it('returns a lock icon/no button if icon is lock and use is not an instructor', () => { + const model = {link, ...baseModel}; + + render(); + screen.findByText('Only available', {exact: false}); + }); + it('returns a link if user is instructor', () => { + mockUseUserContext.mockReturnValue({ + userStatus: { + isInstructor: true + } + }); + const model = {link, ...baseModel}; + + render(); + const foundLink = screen.getByRole('link'); + + expect(foundLink.textContent).toBe('button-label'); + }); + it('clicking download link opens dialog', async () => { + mockUseUserContext.mockReturnValue({ + userStatus: { + isInstructor: true + } + }); + const model = {link, ...baseModel, iconType: 'download'}; + + render(); + const foundLink = screen.getByRole('link'); + + expect(foundLink.textContent).toBe('button-label'); + await user.click(foundLink); + await screen.findByText('Give today'); + const downloadLink = await screen.findByText('Go to your resource'); + + await user.click(downloadLink); + }); + it("Doesn't track downloads if not instructor", async () => { + mockUseUserContext.mockReturnValue({ + userStatus: { + isInstructor: false + } + }); + const model = {link, ...baseModel, iconType: 'download'}; + + render(); + const foundLink = screen.getByRole('link'); + + expect(foundLink.textContent).toBe('button-label'); + await user.click(foundLink); + await screen.findByText('Give today'); + const downloadLink = await screen.findByText('Go to your resource'); + + await user.click(downloadLink); + }); + it('handles unknown search', async () => { + mockUseUserContext.mockReturnValue({ + userStatus: { + isInstructor: false + } + }); + const model = {link, ...baseModel, iconType: 'download'}; + + render(); + const foundLink = screen.getByRole('link'); + + expect(foundLink.textContent).toBe('button-label'); + await user.click(foundLink); + await screen.findByText('Give today'); + const downloadLink = await screen.findByText('Go to your resource'); + + await user.click(downloadLink); + }); + it('handles unknown icon and unknown search', async () => { + const model = {link, ...baseModel, iconType: 'unknown-icon'}; + + render(); + const foundLink = screen.getByRole('link'); + + expect(foundLink.textContent).toBe('button-label'); + await user.click(foundLink); + }); +}); diff --git a/test/src/pages/details/resource-boxes.test.js b/test/src/pages/details/resource-boxes.test.js deleted file mode 100644 index f9a1fd4dc..000000000 --- a/test/src/pages/details/resource-boxes.test.js +++ /dev/null @@ -1,134 +0,0 @@ -import React from 'react'; -import {render, screen} from '@testing-library/preact'; -import BookDetailsLoader from './book-details-context'; -import ResourceBoxes from '~/pages/details/common/resource-box/resource-boxes'; -import { - instructorResourceBoxPermissions, - studentResourceBoxPermissions -} from '~/pages/details/common/resource-box/resource-box'; -import {test, expect} from '@jest/globals'; - -// Test all the conditions in here: -// userStatus: isInstructor: true|false -// userStatus: pending_verification: true|false -// resourceStatus: isExternal: true|false -// resourceData: link_text, link_external, link_document_url - -const resourceData = { - resource: {resourceUnlocked: true}, - linkText: 'Click this', - lockedText: 'Login to unlock', - linkDocument: {file: '/download'} -}; -const userStatus = { - isInstructor: false -}; -const payload = { - heading: 'This is the heading', - description: 'This is a description in HTML' -}; - -function LangWrapResourceBoxes({models}) { - // console.info('*** MODELS', models); - return ( - - - - ); -} - -function instructorModels(resDelta, userDelta={}) { - const res = Object.assign({}, resourceData, resDelta); - const user = Object.assign({}, userStatus, userDelta); - - return [ - Object.assign(payload, instructorResourceBoxPermissions( - res, user, 'Instructor resources' - )) - ]; -} - -function studentModels(resDelta, userDelta={}) { - const res = Object.assign({}, resourceData, resDelta); - const user = Object.assign({}, userStatus, userDelta); - - return [ - Object.assign(payload, studentResourceBoxPermissions( - res, user, 'Student resource' - )) - ]; -} - -test('handles unlocked instructor resources', async () => { - render(); - expect((await screen.findByRole('heading')).textContent).toBe(payload.heading); - expect(screen.getAllByText('a description')).toHaveLength(1); - expect(screen.getByRole('link').textContent).toBe(resourceData.linkText); -}); - -test('handles locked instructor resources', async () => { - render(); - expect((await screen.findByRole('link')).textContent).toBe(resourceData.lockedText); -}); - -test('allows instructors access to locked resources', async () => { - const models = instructorModels( - {resource: {resourceUnlocked: false}}, - {isInstructor: true} - ); - - render(); - expect((await screen.findByRole('link')).textContent).toBe(resourceData.linkText); -}); - -test('handles locked student resources', async () => { - const models = studentModels( - {resource: {resourceUnlocked: false}}, - {isStudent: false, isInstructor: false} - ); - - render(); - expect((await screen.findByRole('link')).textContent).toBe(resourceData.lockedText); -}); - -test('allows students access to locked resources', async () => { - const models = studentModels( - {}, - { - isStudent: true, - isInstructor: false - } - ); - - render(); - expect((await screen.findByRole('link')).textContent).toBe(resourceData.linkText); -}); - -test('allows instructors access to locked student resources', async () => { - const models = studentModels({ - }, - { - isStudent: false, - isInstructor: true - }); - - render(); - const link = await screen.findByRole('link'); - - expect(link.textContent).toBe(resourceData.linkText); - expect(link.href).toMatch('/download'); -}); - -test('understands external links', async () => { - const models = studentModels({ - linkDocumentUrl: null, - linkExternal: 'http://example.com/external_link' - }, - { - isStudent: false, - isInstructor: true - }); - - render(); - expect((await screen.findByRole('link')).textContent).toBe(resourceData.linkText); -}); diff --git a/test/src/pages/details/resource-boxes.test.tsx b/test/src/pages/details/resource-boxes.test.tsx new file mode 100644 index 000000000..5475f9e46 --- /dev/null +++ b/test/src/pages/details/resource-boxes.test.tsx @@ -0,0 +1,190 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import BookDetailsLoader from './book-details-context'; +import * as detailCtx from '~/pages/details/context'; +import ResourceBoxes from '~/pages/details/common/resource-box/resource-boxes'; +import { + instructorResourceBoxPermissions, + studentResourceBoxPermissions +} from '~/pages/details/common/resource-box/resource-box'; + +// Test all the conditions in here: +// userStatus: isInstructor: true|false +// userStatus: pending_verification: true|false +// resourceStatus: isExternal: true|false +// resourceData: link_text, link_external, link_document_url + +const resourceData = { + resource: {resourceUnlocked: true}, + linkText: 'Click this', + lockedText: 'Login to unlock', + linkDocument: {file: '/download'} +}; +const userStatus = { + isInstructor: false +}; +const payload = { + heading: 'This is the heading', + description: 'This is a description in HTML' +}; + +function LangWrapResourceBoxes({ + models, includeCommonsHub +}: { + models: Parameters[0]['models']; + includeCommonsHub?: boolean; +}) { + return ( + + + + ); +} + +type ResDelta = Partial<{ + resource: {resourceUnlocked: boolean}; + isInstructor: boolean; + linkDocumentUrl: string | null; + linkExternal: string; +}>; +function instructorModels(resDelta: ResDelta, userDelta: ResDelta = {}) { + const res = Object.assign({}, resourceData, resDelta); + const user = Object.assign({}, userStatus, userDelta); + + return [ + Object.assign( + payload, + instructorResourceBoxPermissions(res, user, 'Instructor resources') + ) + ]; +} + +function studentModels(resDelta: ResDelta, userDelta = {}) { + const res = Object.assign({}, resourceData, resDelta); + const user = Object.assign({}, userStatus, userDelta); + + return [Object.assign(payload, studentResourceBoxPermissions(res, user))]; +} + +it('handles unlocked instructor resources', async () => { + render(); + expect((await screen.findByRole('heading')).textContent).toBe( + payload.heading + ); + expect(screen.getAllByText('a description')).toHaveLength(1); + expect(screen.getByRole('link').textContent).toBe(resourceData.linkText); +}); + +it('handles locked instructor resources', async () => { + const mockUseDetailsContext = jest.spyOn(detailCtx, 'default'); + + mockUseDetailsContext.mockReturnValueOnce({ + communityResourceHeading: 'cr-head', + communityResourceUrl: 'cr-url', + communityResourceLogoUrl: '', + communityResourceBlurb: 'cr-blurb', + communityResourceCta: 'cr-cta', + communityResourceFeatureLinkUrl: 'cr-feature-url', + communityResourceFeatureText: '' + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + render( + + ); + + const links = await screen.findAllByRole('link'); + + expect(links.find((el) => el.textContent === resourceData.lockedText)).toBeTruthy(); + expect(links).toHaveLength(3); +}); +it('handles empty community resource url', async () => { + const mockUseDetailsContext = jest.spyOn(detailCtx, 'default'); + + mockUseDetailsContext.mockReturnValueOnce({ + communityResourceUrl: '' + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + render( + + ); + const links = await screen.findAllByRole('link'); + + expect(links).toHaveLength(1); +}); + +it('allows instructors access to locked resources', async () => { + const models = instructorModels( + {resource: {resourceUnlocked: false}}, + {isInstructor: true} + ); + + render(); + expect((await screen.findByRole('link')).textContent).toBe( + resourceData.linkText + ); +}); + +it('handles locked student resources', async () => { + const models = studentModels( + {resource: {resourceUnlocked: false}}, + {isStudent: false, isInstructor: false} + ); + + render(); + expect((await screen.findByRole('link')).textContent).toBe( + resourceData.lockedText + ); +}); + +it('allows students access to locked resources', async () => { + const models = studentModels( + {}, + { + isStudent: true, + isInstructor: false + } + ); + + render(); + expect((await screen.findByRole('link')).textContent).toBe( + resourceData.linkText + ); +}); + +it('allows instructors access to locked student resources', async () => { + const models = studentModels( + {}, + { + isStudent: false, + isInstructor: true + } + ); + + render(); + const link = await screen.findByRole('link'); + + expect(link.textContent).toBe(resourceData.linkText); + expect(link.href).toMatch('/download'); +}); + +it('understands external links', async () => { + const models = studentModels( + { + linkDocumentUrl: null, + linkExternal: 'http://example.com/external_link' + }, + { + isStudent: false, + isInstructor: true + } + ); + + render(); + expect((await screen.findByRole('link')).textContent).toBe( + resourceData.linkText + ); +}); diff --git a/test/src/pages/details/video-resource-box.test.tsx b/test/src/pages/details/video-resource-box.test.tsx new file mode 100644 index 000000000..cd0483b95 --- /dev/null +++ b/test/src/pages/details/video-resource-box.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; +import VideoResourceBoxes from '~/pages/details/common/resource-box/video-resource-box'; + +describe('VideoResourceBox', () => { + type ModelsType = Parameters[0]['models']; + + it('renders non-video resource boxes', () => { + const bm = [{heading: 'box-model-heading'}]; + + render( + + ); + const videoTags = document.querySelectorAll('video'); + + expect(videoTags.length).toBe(0); + expect(screen.getAllByRole('heading')).toHaveLength(2); + }); + it('renders videos and dialog', async () => { + const models: ModelsType = [ + { + heading: 'vm-heading', + resourceHeading: 'res-head', + resourceDescription: 'res-desc', + videoFile: 'video-file', + videoTitle: 'video-title', + resourceCategory: 'category' + } + ]; + const user = userEvent.setup(); + + render( + + ); + const videoTags = document.querySelectorAll('video'); + + expect(videoTags.length).toBe(1); + await user.click(videoTags[0]); + expect( + document.querySelector('.aspect-16-by-9-container') + ).toBeTruthy(); + screen.getByText('res-head'); + }); + it('renders dialog with resourceHeading as title when videoTitle is empty', async () => { + const models: ModelsType = [ + { + heading: 'vm-heading', + resourceHeading: 'res-head', + resourceDescription: 'res-desc', + videoFile: 'video-file', + videoTitle: '', + resourceCategory: 'category' + } + ]; + const user = userEvent.setup(); + + render( + + ); + const videoTags = document.querySelectorAll('video'); + + expect(videoTags.length).toBe(1); + await user.click(videoTags[0]); + expect( + document.querySelector('.aspect-16-by-9-container') + ).toBeTruthy(); + expect(screen.getByRole('dialog')).toBeTruthy(); + expect(screen.getAllByText('res-head')).toHaveLength(2); + }); +}); From 1f6d7c7cab42d7d02b9443c4f2f12be7ac2220f8 Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Mon, 5 Aug 2024 17:12:50 -0500 Subject: [PATCH 06/10] details/common (get-this-title) --- .../pages/details/common/get-this-title.js | 51 ------------- .../pages/details/common/get-this-title.tsx | 71 +++++++++++++++++++ .../get-this-title/get-this-title.test.js | 60 ---------------- .../get-this-title/get-this-title.test.tsx | 58 +++++++++++++++ 4 files changed, 129 insertions(+), 111 deletions(-) delete mode 100644 src/app/pages/details/common/get-this-title.js create mode 100644 src/app/pages/details/common/get-this-title.tsx delete mode 100644 test/src/pages/details/get-this-title/get-this-title.test.js create mode 100644 test/src/pages/details/get-this-title/get-this-title.test.tsx diff --git a/src/app/pages/details/common/get-this-title.js b/src/app/pages/details/common/get-this-title.js deleted file mode 100644 index 03db39687..000000000 --- a/src/app/pages/details/common/get-this-title.js +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import {useToggle} from '~/helpers/data'; -import { - TocOption, WebviewOption, PdfOption, BookshareOption, - IbooksOption, KindleOption, CheggOption, OptionExpander -} from './get-this-title-files/options'; -import OrderPrintCopy from './get-this-title-files/order-print-copy/order-print-copy'; -import './get-this-title-files/get-this-title.scss'; -import trackLink from './track-link'; - -function AdditionalOptions({model}) { - return ( - - - - - - ); -} - -export default function GetThisTitle({model}) { - const additionalOptions = [ - 'bookshareLink', 'ibookLink', 'kindleLink' - ].filter((key) => model[key]).length; - const [expanded, toggleExpanded] = useToggle(additionalOptions < 1); - const interceptLinkClicks = React.useCallback( - (event) => { - trackLink(event, model.id); - }, - [model.id] - ); - - return ( -
-
-
- - - - - { - expanded && - - } - -
-
- -
- ); -} diff --git a/src/app/pages/details/common/get-this-title.tsx b/src/app/pages/details/common/get-this-title.tsx new file mode 100644 index 000000000..35c938df2 --- /dev/null +++ b/src/app/pages/details/common/get-this-title.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import {useToggle} from '~/helpers/data'; +import { + TocOption, + WebviewOption, + PdfOption, + BookshareOption, + IbooksOption, + KindleOption, + CheggOption, + OptionExpander +} from './get-this-title-files/options'; +import OrderPrintCopy from './get-this-title-files/order-print-copy/order-print-copy'; +import './get-this-title-files/get-this-title.scss'; +import trackLink from './track-link'; + +type Model = { + id: string; + slug: string; + bookshareLink: string; + ibookLink: string; + kindleLink: string; +}; +type ModelKey = 'bookshareLink' | 'ibookLink' | 'kindleLink'; +type TrackedMouseEvent = Parameters[0]; + +function AdditionalOptions({model}: {model: Model}) { + return ( + + + + + + ); +} + +export default function GetThisTitle({model}: {model: Model}) { + const additionalOptions = ( + ['bookshareLink', 'ibookLink', 'kindleLink'] as ModelKey[] + ).filter((key) => model[key]).length; + const [expanded, toggleExpanded] = useToggle(additionalOptions < 1); + const interceptLinkClicks = React.useCallback( + (event: TrackedMouseEvent) => { + trackLink(event, model.id); + }, + [model.id] + ); + + return ( +
+
+
+ + + + + {expanded && } + +
+
+ +
+ ); +} diff --git a/test/src/pages/details/get-this-title/get-this-title.test.js b/test/src/pages/details/get-this-title/get-this-title.test.js deleted file mode 100644 index 01df6c07a..000000000 --- a/test/src/pages/details/get-this-title/get-this-title.test.js +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import {render, screen} from '@testing-library/preact'; -import userEvent from '@testing-library/user-event'; -import GetThisTitle from '~/pages/details/common/get-this-title'; -import {TOCContextProvider} from '~/pages/details/common/toc-slideout/context'; -import BookDetailsLoader from '../book-details-context'; -// College algebra book details -import details from '../../../data/details-college-algebra'; -import {transformData, camelCaseKeys} from '~/helpers/page-data-utils'; - -const model = camelCaseKeys(transformData(details)); - -function GTTinContext() { - return ( - - - - - - ); -} - -async function expectOptions(value) { - const options = await screen.findAllByRole('link'); - const expandLink = options[options.length - 1]; - const user = userEvent.setup(); - - expect(options).toHaveLength(value); - await user.click(expandLink); -} - -// ** There aren't enough options to have them get hidden since Print is separated out -// test('handles hiding and expanding non-preferred formats', async () => { -// render(); - -// await screen.findByText('View online'); -// let options = screen.queryAllByRole('link'); -// let toggleLink = options.pop(); - -// expect(options).toHaveLength(4); -// const user = userEvent.setup(); - -// await user.click(toggleLink); -// await screen.findByText('See 1 fewer option'); -// options = screen.queryAllByRole('link'); -// toggleLink = options.pop(); -// expect(options).toHaveLength(4); - -// await user.click(toggleLink); -// await screen.findByText('+ 1 more option...'); -// options = screen.queryAllByRole('link'); -// toggleLink = options.pop(); -// expect(options).toHaveLength(3); -// }); - -test('does the callout for Rex book', async () => { - render(); - - await screen.queryAllByText('Recommended'); -}); diff --git a/test/src/pages/details/get-this-title/get-this-title.test.tsx b/test/src/pages/details/get-this-title/get-this-title.test.tsx new file mode 100644 index 000000000..0118157a7 --- /dev/null +++ b/test/src/pages/details/get-this-title/get-this-title.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; +import GetThisTitle from '~/pages/details/common/get-this-title'; +import {TOCContextProvider} from '~/pages/details/common/toc-slideout/context'; +import BookDetailsLoader from '../book-details-context'; +// College algebra book details +import details from '../../../data/details-college-algebra'; +import {transformData, camelCaseKeys} from '~/helpers/page-data-utils'; + +const model = camelCaseKeys(transformData(details)); + +function GTTinContext() { + return ( + + + + + + ); +} + +const mockIsMobileDisplay = jest.fn().mockReturnValue(false); + +jest.mock('~/helpers/device', () => ({ + ...jest.requireActual('~/helpers/device'), + __esModule: true, + isMobileDisplay: () => mockIsMobileDisplay() +})); + +const user = userEvent.setup(); + +describe('get-this-title', () => { + it('renders with unexpanded options', async () => { + render(); + const expander = await screen.findByText('+ 1 more option...'); + + await user.click(expander); + await screen.findByText('See 1 fewer option'); + // Exercise link tracking + await user.click(screen.getByText('Download for Kindle')); + }); + it('opens give dialog for PDF', async () => { + render(); + const pdfLink = await screen.findByText('Download a PDF'); + + await user.click(pdfLink); + expect(screen.getAllByRole('dialog')).toHaveLength(2); + }); + it('no dialog on mobile display', async () => { + mockIsMobileDisplay.mockReturnValue(true); + render(); + const pdfLink = await screen.findByText('Download a PDF'); + + await user.click(pdfLink); + expect(screen.findByRole('dialog')).toBeTruthy(); + }); +}); From 695b71fe87d0cb9da914254128cbff473fb4ef6a Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Wed, 7 Aug 2024 12:02:42 -0500 Subject: [PATCH 07/10] details/get-this-title (give-before-*) --- .../give-before-pdf/content-warning.tsx | 2 +- .../give-before-pdf/give-before-pdf.tsx | 4 +- .../use-donation-popup-data.ts | 20 ++- .../get-this-title/content-warning.test.tsx | 57 +++++++++ .../get-this-title/give-before-other.test.tsx | 71 +++++++++++ .../get-this-title/give-before-pdf.test.tsx | 118 ++++++++++++++++++ 6 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 test/src/pages/details/get-this-title/content-warning.test.tsx create mode 100644 test/src/pages/details/get-this-title/give-before-other.test.tsx create mode 100644 test/src/pages/details/get-this-title/give-before-pdf.test.tsx diff --git a/src/app/pages/details/common/get-this-title-files/give-before-pdf/content-warning.tsx b/src/app/pages/details/common/get-this-title-files/give-before-pdf/content-warning.tsx index 4cfe60e8a..3a6825d2e 100644 --- a/src/app/pages/details/common/get-this-title-files/give-before-pdf/content-warning.tsx +++ b/src/app/pages/details/common/get-this-title-files/give-before-pdf/content-warning.tsx @@ -7,7 +7,7 @@ export default function ContentWarning({ link: string; track: string | undefined; close: () => void; - onDownload: (event: React.MouseEvent) => void; + onDownload?: (event: React.MouseEvent) => void; variant?: string; warning: string; id: string; diff --git a/src/app/pages/details/common/get-this-title-files/give-before-pdf/give-before-pdf.tsx b/src/app/pages/details/common/get-this-title-files/give-before-pdf/give-before-pdf.tsx index d6fc96b7a..e55d066d6 100644 --- a/src/app/pages/details/common/get-this-title-files/give-before-pdf/give-before-pdf.tsx +++ b/src/app/pages/details/common/get-this-title-files/give-before-pdf/give-before-pdf.tsx @@ -18,7 +18,7 @@ export default function GiveBeforePdf({ track?: string; close: () => void; data: DonationPopupData; - onDownload: () => void; + onDownload?: () => void; id?: string; }) { const [doneDownloading, setDoneDownloading] = React.useState(false); @@ -65,7 +65,7 @@ function GiveBeforePdfAfterConditionals({ track: string | undefined; data: DonationPopupData; close: () => void; - onDownload: (event: React.MouseEvent) => void; + onDownload?: (event: React.MouseEvent) => void; }) { const [controlLink, alternateLink] = useGiveLinks(); const variants = [ diff --git a/src/app/pages/details/common/get-this-title-files/give-before-pdf/use-donation-popup-data.ts b/src/app/pages/details/common/get-this-title-files/give-before-pdf/use-donation-popup-data.ts index 5f788d06f..0549aa3dd 100644 --- a/src/app/pages/details/common/get-this-title-files/give-before-pdf/use-donation-popup-data.ts +++ b/src/app/pages/details/common/get-this-title-files/give-before-pdf/use-donation-popup-data.ts @@ -1,14 +1,28 @@ import React from 'react'; import {useDataFromSlug} from '~/helpers/page-data-utils'; +export type DonationPopupData = { + download_image: string; + download_ready?: string; + header_image: string; + header_title: string; + header_subtitle: string; + give_link_text: string; + give_link: string; + thank_you_link_text: string; + thank_you_link: string; + giving_optional: string; + go_to_pdf_link_text: string; + hide_donation_popup: boolean; +}; + export default function useDonationPopupData() { + // When useDataFromSlug moves to TS, we can make it type-generic const data1 = useDataFromSlug('donations/donation-popup'); const data = React.useMemo( () => (data1?.length > 0 ? data1[0] : {}), [data1] ); - return data; + return data as DonationPopupData; } - -export type DonationPopupData = ReturnType; diff --git a/test/src/pages/details/get-this-title/content-warning.test.tsx b/test/src/pages/details/get-this-title/content-warning.test.tsx new file mode 100644 index 000000000..631edc0a8 --- /dev/null +++ b/test/src/pages/details/get-this-title/content-warning.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; +import ContentWarning from '~/pages/details/common/get-this-title-files/give-before-pdf/content-warning'; + +jest.useFakeTimers(); +const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime}); +const close = jest.fn(); +const onDownload = jest.fn(); + +describe('ContentWarning', () => { + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it('displays and closes', async () => { + render( + + ); + const goLink = (screen.getByRole('link')); + + expect(goLink.dataset.track).toBe('cw-track'); + expect(goLink.textContent).toBe('Go to your file'); + await user.click(goLink); + jest.runAllTimers(); + expect(close).toHaveBeenCalled(); + }); + it('runs onDownload whe present', async () => { + render( + + ); + const goLink = (screen.getByRole('link')); + + expect(goLink.textContent).toBe('Go to your book'); + await user.click(goLink); + jest.runAllTimers(); + expect(close).toHaveBeenCalled(); + expect(onDownload).toHaveBeenCalled(); + }); +}); + +// checkWarningCookie is exercised in other tests. diff --git a/test/src/pages/details/get-this-title/give-before-other.test.tsx b/test/src/pages/details/get-this-title/give-before-other.test.tsx new file mode 100644 index 000000000..7c83ab39f --- /dev/null +++ b/test/src/pages/details/get-this-title/give-before-other.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; +import GiveBeforeOther from '~/pages/details/common/get-this-title-files/give-before-pdf/give-before-other'; +import { MemoryRouter } from 'react-router-dom'; +import * as TY from '~/pages/details/common/get-this-title-files/give-before-pdf/thank-you-form'; + +type PopupData = Parameters[0]['data']; + +describe('give-before-other', () => { + const close = jest.fn(); + const data = {} as unknown as PopupData; // doesn't matter + const onDownload = jest.fn(); + const user = userEvent.setup(); + + it('renders (variant unspecified)', async () => { + render( + + + + ); + const goLink = screen.getByText('Go to your item'); + + await user.click(goLink); + expect(close).toHaveBeenCalled(); + expect(onDownload).toHaveBeenCalled(); + jest.resetAllMocks(); + }); + it('renders (variant book), closes without onDownload', async () => { + render( + + + + ); + const goLink = screen.getByText('Go to your book'); + + await user.click(goLink); + expect(close).toHaveBeenCalled(); + jest.resetAllMocks(); + }); + it('Shows ThankYou(variant resource)', async () => { + jest.spyOn(TY, 'useOnThankYouClick').mockReturnValue({ + showThankYou: true, + onThankYouClick: () => null + }); + render( + + + + ); + expect(screen.getAllByRole('textbox')).toHaveLength(5); + screen.getByText('Send us a thank you note'); + }); +}); diff --git a/test/src/pages/details/get-this-title/give-before-pdf.test.tsx b/test/src/pages/details/get-this-title/give-before-pdf.test.tsx new file mode 100644 index 000000000..1dcbebec4 --- /dev/null +++ b/test/src/pages/details/get-this-title/give-before-pdf.test.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; +import {MemoryRouter} from 'react-router-dom'; +import GiveBeforePdf from '~/pages/details/common/get-this-title-files/give-before-pdf/give-before-pdf'; +// eslint-disable-next-line max-len +import type {DonationPopupData} from '~/pages/details/common/get-this-title-files/give-before-pdf/use-donation-popup-data'; +import * as TY from '~/pages/details/common/get-this-title-files/give-before-pdf/thank-you-form'; + +jest.useFakeTimers(); +const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime}); +const close = jest.fn(); +const onDownload = jest.fn(); +/* eslint-disable camelcase */ +const data: DonationPopupData = { + download_ready: 'your download is ready', + header_subtitle: 'header-subtitle', + download_image: 'image-url' +} as unknown as DonationPopupData; // doesn't matter +/* eslint-enable camelcase */ + +describe('give-before-pdf', () => { + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it('renders after download delay', async () => { + render( + + + + ); + screen.getByText('Downloading...'); + jest.runAllTimers(); + await screen.findByText('your download is ready'); + }); + it('shows Thank You', async () => { + jest.spyOn(TY, 'useOnThankYouClick').mockReturnValue({ + showThankYou: true, + onThankYouClick: () => null + }); + render( + + + + ); + jest.runAllTimers(); + await screen.findByText('Send us a thank you note'); + }); + it('Exercise data-track and datalayer effect', async () => { + (window as unknown as Window & {dataLayer: object[]}).dataLayer = []; + render( + + + + ); + screen.getByText('Downloading...'); + jest.runAllTimers(); + const goLink = await screen.findByText('Go to your file'); + + await user.click(goLink); + jest.runAllTimers(); + expect(onDownload).toHaveBeenCalled(); + }); + it('Handle close without onDownload', async () => { + expect(close).not.toHaveBeenCalled(); + render( + + + + ); + screen.getByText('Downloading...'); + jest.runAllTimers(); + await user.click(await screen.findByText('Go to your file')); + jest.runAllTimers(); + expect(close).toHaveBeenCalled(); + }); + it('handles Give link click', async () => { + render( + + + + ); + screen.getByText('Downloading...'); + jest.runAllTimers(); + await user.click(await screen.findByText('Go to your file')); + jest.runAllTimers(); + const links = screen.getAllByRole('link'); + + expect(links.length).toBe(2); + await user.click(links[0]); + }); +}); From 6b0144ce5d7c2bbbc52b7a892cec44b86edaf049 Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Wed, 7 Aug 2024 14:56:39 -0500 Subject: [PATCH 08/10] models --- test/src/models/models.test.ts | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 test/src/models/models.test.ts diff --git a/test/src/models/models.test.ts b/test/src/models/models.test.ts new file mode 100644 index 000000000..1b499d58b --- /dev/null +++ b/test/src/models/models.test.ts @@ -0,0 +1,71 @@ +import * as CF from '~/helpers/cms-fetch'; +import * as TC from '~/models/table-of-contents-html'; +import * as PDU from '~/helpers/page-data-utils'; +import bookToc from '~/models/book-toc'; +import getFields from '~/models/errata-fields'; + +const mockCmsFetch = jest.spyOn(CF, 'default'); +const mockCnxFetch = jest.spyOn(TC, 'cnxFetch'); + +describe('mapbox', () => { + it('calls cmsFetch', async () => { + mockCmsFetch.mockResolvedValue(['mapbox-value']); + const mapbox = await import('~/models/mapbox'); + + expect(await mapbox.default).toEqual('mapbox-value'); + }); +}); + +describe('give-today', () => { + function fromNow(offset: number) { + return new Date(Date.now() + offset).toLocaleString(); + } + it('passes test dates', async () => { + jest.spyOn(PDU, 'useDataFromPromise').mockReturnValue( + { + /* eslint-disable camelcase */ + menu_start: fromNow(-1000), + menu_expires: fromNow(1000), + start: fromNow(-2000), + expires: fromNow(2000) + /* eslint-enable camelcase */ + } + ); + const {default: useGiveToday} = await import('~/models/give-today'); + const output = await useGiveToday(); + + expect(output.showButton).toBe(true); + expect(output.showLink).toBe(true); + }); +}); + +describe('book-toc', () => { + it('calls cmsFetch and cnxFetch', async () => { + mockCmsFetch.mockResolvedValue({ + /* eslint-disable camelcase */ + webview_rex_link: 'webview-rex-link', + cnx_idd: 'cnx-id' + /* eslint-enable camelcase */ + }); + mockCnxFetch.mockResolvedValue({ + tree: { + contents: 'tree-contents' + } + }); + const contents = await bookToc('book-slug'); + + expect(contents).toBe('tree-contents'); + expect(mockCmsFetch).toHaveBeenCalled(); + expect(mockCnxFetch).toHaveBeenCalled(); + }); +}); + +describe('errata-fields', () => { + it('calls cmsFetch', async () => { + jest.resetAllMocks(); + expect(mockCmsFetch).not.toHaveBeenCalled(); + mockCmsFetch.mockResolvedValue('errata-fields-values'); + expect(await getFields('')).toBe('errata-fields-values'); + expect(mockCmsFetch).toHaveBeenCalled(); + }); +}); From e781749861502974cdc4f55e6821c792165fef2e Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Wed, 7 Aug 2024 15:34:26 -0500 Subject: [PATCH 09/10] explore-by-subject --- test/src/data/subject-categories.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/src/data/subject-categories.js b/test/src/data/subject-categories.js index 8bb719fbe..407f102dd 100644 --- a/test/src/data/subject-categories.js +++ b/test/src/data/subject-categories.js @@ -3,7 +3,8 @@ export default [ "id": 1, "name": "Math", "seo_title": "Math SEO Title", - "search_description": "This is math search meta" + "search_description": "This is math search meta", + "subject_icon": "/images/subject-icon" }, { "id": 2, From 86fa6f7a2aca41cd92643f1f122aa0f31d95bf68 Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Mon, 12 Aug 2024 17:40:02 -0500 Subject: [PATCH 10/10] Code review items --- src/app/pages/details/common/links-to-translations.tsx | 3 +-- test/src/pages/details/book-details-context.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/pages/details/common/links-to-translations.tsx b/src/app/pages/details/common/links-to-translations.tsx index 137dbee94..3912ba19b 100644 --- a/src/app/pages/details/common/links-to-translations.tsx +++ b/src/app/pages/details/common/links-to-translations.tsx @@ -48,8 +48,7 @@ function AnotherLanguage({ // translation is guaranteed to have a valid value, because the locale // is pulled from translations return ( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - + ); diff --git a/test/src/pages/details/book-details-context.js b/test/src/pages/details/book-details-context.js index a10ed10b9..608cfdcad 100644 --- a/test/src/pages/details/book-details-context.js +++ b/test/src/pages/details/book-details-context.js @@ -23,7 +23,7 @@ function BookDetailsWithContext({data, children}) { } export default function BookDetailsLoader({slug, children}) { - const absoluteSlug = slug.startsWith('/') ? slug : `/${slug}`; + const absoluteSlug = `/${slug}`; return (