Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ######################################
Expand Down
57 changes: 41 additions & 16 deletions src/settings/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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( {
Expand All @@ -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 || {} );
},
};

Expand Down
125 changes: 125 additions & 0 deletions src/settings/store/index.test.js
Original file line number Diff line number Diff line change
@@ -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();
} );
} );
} );
Loading