diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bef21e6a..1e5e9bb7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -239,6 +239,25 @@ jobs: - name: Lint JS run: npm run lint:js + # ###################################### + # Jest unit tests + # ###################################### + jest-tests: + name: Jest + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: npm + + - run: npm ci + + - name: Run Jest unit tests + run: npm run test:unit + # ###################################### # Plugin Check # ###################################### diff --git a/src/settings/store/index.js b/src/settings/store/index.js index ec6eb305..83bad5e5 100644 --- a/src/settings/store/index.js +++ b/src/settings/store/index.js @@ -4,34 +4,59 @@ import apiFetch from '@wordpress/api-fetch'; import { createReduxStore } from '@wordpress/data'; -// Shared editor state. Adding a key here gives you a `getKey` selector for -// free; write a resolver below that returns `set( 'key', value )`. +// Shared editor state, fetched lazily by the resolvers below; each key has a +// matching selector. Most keys hold a single session-wide value. `voices`, +// `videoSizes` and `project` are maps keyed by their resolver's argument: +// @wordpress/data marks resolution finished per selector-args, so each +// argument's result needs its own slot — with one shared slot, a later fetch +// (say, another language's voices) would overwrite it while the resolution +// cache keeps reporting the overwritten entry as fresh. export const DEFAULT_STATE = { settings: {}, languages: [], - voices: [], + voices: {}, // languageCode → voice list scriptTemplates: [], videoTemplates: [], - videoSizes: [], - project: {}, + videoSizes: {}, // projectId → size list + project: {}, // projectId → project record }; const reducer = ( state = DEFAULT_STATE, action ) => { + // Replace a session-wide key. if ( action.type === 'SET' && action.key in state ) { const empty = Array.isArray( DEFAULT_STATE[ action.key ] ) ? [] : {}; return { ...state, [ action.key ]: action.value || empty }; } + // Merge one argument's result into a per-argument key, leaving the other + // arguments' entries untouched. + if ( action.type === 'SET_BY' && action.key in state ) { + return { + ...state, + [ action.key ]: { + ...state[ action.key ], + [ action.arg ]: action.value, + }, + }; + } return state; }; -const selectors = Object.fromEntries( - Object.keys( DEFAULT_STATE ).map( ( key ) => [ - `get${ key[ 0 ].toUpperCase() }${ key.slice( 1 ) }`, - ( state ) => state[ key ], - ] ) -); +// The per-argument selectors (voices, video sizes, project) read the entry for +// the requested argument only, with an empty value while it is unfetched. +const selectors = { + getSettings: ( state ) => state.settings, + getLanguages: ( state ) => state.languages, + getVoices: ( state, languageCode ) => state.voices[ languageCode ] ?? [], + getScriptTemplates: ( state ) => state.scriptTemplates, + getVideoTemplates: ( state ) => state.videoTemplates, + getVideoSizes: ( state, projectId ) => state.videoSizes[ projectId ] ?? [], + getProject: ( state, projectId ) => state.project[ projectId ] ?? {}, +}; +// Both action shapes are private to this store — it registers no `actions`, +// so the resolvers below are the only dispatchers. const set = ( key, value ) => ( { type: 'SET', key, value } ); +const setBy = ( key, arg, value ) => ( { type: 'SET_BY', key, arg, value } ); const resolvers = { async getSettings() { @@ -46,7 +71,7 @@ const resolvers = { const value = await apiFetch( { path: `/beyondwords/v1/languages/${ languageCode }/voices`, } ); - return set( 'voices', value ); + return setBy( 'voices', languageCode, value || [] ); }, async getScriptTemplates() { const value = await apiFetch( { @@ -62,23 +87,23 @@ const resolvers = { }, async getVideoSizes( projectId ) { if ( ! projectId ) { - return set( 'videoSizes', [] ); + return setBy( 'videoSizes', projectId, [] ); } const r = await apiFetch( { path: `/beyondwords/v1/projects/${ projectId }/video-settings`, } ); - return set( 'videoSizes', r?.sizes ?? [] ); + return setBy( 'videoSizes', projectId, r?.sizes ?? [] ); }, // The project carries the default `language` used to pre-select the Language // dropdown when Customize is enabled. Fetched on demand (behind the toggle). async getProject( projectId ) { if ( ! projectId ) { - return set( 'project', {} ); + return setBy( 'project', projectId, {} ); } const value = await apiFetch( { path: `/beyondwords/v1/projects/${ projectId }`, } ); - return set( 'project', value ); + return setBy( 'project', projectId, value || {} ); }, }; diff --git a/src/settings/store/index.test.js b/src/settings/store/index.test.js new file mode 100644 index 00000000..d60a74bb --- /dev/null +++ b/src/settings/store/index.test.js @@ -0,0 +1,125 @@ +/* global jest, describe, it, expect, beforeEach */ + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { createRegistry } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import store from './index'; + +jest.mock( '@wordpress/api-fetch' ); + +const STORE = 'beyondwords/settings'; + +// Distinct data per resolver argument, so a value leaking from one argument's +// fetch into another's slot is observable in the assertions below. +const VOICES = { + en_US: [ { id: 1, name: 'Amy' } ], + fr_FR: [ { id: 2, name: 'Céline' } ], +}; + +const PROJECT = { + 11111: { id: 11111, language: 'en_US' }, + 22222: { id: 22222, language: 'fr_FR' }, +}; + +const VIDEO_SIZES = { + 11111: [ { name: 'square', enabled: true } ], + 22222: [ { name: 'portrait', enabled: true } ], +}; + +describe( 'beyondwords/settings store', () => { + let registry; + + beforeEach( () => { + apiFetch.mockReset(); + apiFetch.mockImplementation( ( { path } ) => { + let m = path.match( /languages\/([^/]+)\/voices$/ ); + if ( m ) { + return Promise.resolve( VOICES[ m[ 1 ] ] ?? [] ); + } + m = path.match( /projects\/([^/]+)\/video-settings$/ ); + if ( m ) { + return Promise.resolve( { + sizes: VIDEO_SIZES[ m[ 1 ] ] ?? [], + } ); + } + m = path.match( /projects\/([^/]+)$/ ); + if ( m ) { + return Promise.resolve( PROJECT[ m[ 1 ] ] ?? {} ); + } + return Promise.resolve( [] ); + } ); + + registry = createRegistry(); + registry.register( store ); + } ); + + describe( 'getVoices', () => { + it( 'keeps each language in its own slot after another is fetched', async () => { + const resolve = registry.resolveSelect( STORE ); + + // Fetch A, then B. With the old single shared `voices` slot, B's + // response overwrote the value A's already-finished resolution read. + expect( await resolve.getVoices( 'en_US' ) ).toEqual( + VOICES.en_US + ); + expect( await resolve.getVoices( 'fr_FR' ) ).toEqual( + VOICES.fr_FR + ); + + // Re-reading A must still return A's voices, not B's — no refetch is + // dispatched because resolution for [ 'en_US' ] is already finished. + const select = registry.select( STORE ); + expect( select.getVoices( 'en_US' ) ).toEqual( VOICES.en_US ); + expect( select.getVoices( 'fr_FR' ) ).toEqual( VOICES.fr_FR ); + } ); + + it( 'resolves each language once and reuses the cached result', async () => { + const resolve = registry.resolveSelect( STORE ); + await resolve.getVoices( 'en_US' ); + await resolve.getVoices( 'en_US' ); + await resolve.getVoices( 'fr_FR' ); + + // en_US fetched once (second call is a cache hit), fr_FR once. + expect( apiFetch ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'returns an empty list for a language that has not been fetched', () => { + expect( registry.select( STORE ).getVoices( 'de_DE' ) ).toEqual( + [] + ); + } ); + } ); + + describe( 'getProject / getVideoSizes', () => { + it( 'keeps each project id in its own slot', async () => { + const resolve = registry.resolveSelect( STORE ); + await resolve.getProject( '11111' ); + await resolve.getProject( '22222' ); + await resolve.getVideoSizes( '11111' ); + await resolve.getVideoSizes( '22222' ); + + const select = registry.select( STORE ); + expect( select.getProject( '11111' ) ).toEqual( PROJECT[ 11111 ] ); + expect( select.getProject( '22222' ) ).toEqual( PROJECT[ 22222 ] ); + expect( select.getVideoSizes( '11111' ) ).toEqual( + VIDEO_SIZES[ 11111 ] + ); + expect( select.getVideoSizes( '22222' ) ).toEqual( + VIDEO_SIZES[ 22222 ] + ); + } ); + + it( 'defaults to {} / [] and skips the fetch for a falsy id', async () => { + const resolve = registry.resolveSelect( STORE ); + expect( await resolve.getProject( '' ) ).toEqual( {} ); + expect( await resolve.getVideoSizes( '' ) ).toEqual( [] ); + expect( apiFetch ).not.toHaveBeenCalled(); + } ); + } ); +} );