Refactor Milkdrop

This commit is contained in:
Jordan Eldredge 2018-06-10 10:25:06 -07:00
parent f30ad0ebc9
commit c89529f442
9 changed files with 577 additions and 275 deletions

View file

@ -120,6 +120,7 @@ class App extends React.Component {
analyser={media.getAnalyser()}
width={width}
height={height}
playing={this.props.status === "PLAYING"}
/>
)}
</GenWindow>
@ -154,6 +155,7 @@ App.propTypes = {
};
const mapStateToProps = state => ({
status: state.media.status,
focused: state.windows.focused,
closed: state.display.closed,
genWindowsInfo: state.windows.genWindows

View file

@ -0,0 +1,26 @@
import React from "react";
const Background = props => {
const { innerRef, ...restProps } = props;
return (
<div
ref={innerRef}
className="draggable"
style={{
// This color will be used until Butterchurn is loaded
backgroundColor: "#000",
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
height: "100%",
width: "100%"
}}
tabIndex="0"
{...restProps}
/>
);
};
export default Background;

View file

@ -0,0 +1,202 @@
import React from "react";
import screenfull from "screenfull";
import PresetOverlay from "./PresetOverlay";
import Background from "./Background";
const USER_PRESET_TRANSITION_SECONDS = 5.7;
const PRESET_TRANSITION_SECONDS = 2.7;
const MILLISECONDS_BETWEEN_PRESET_TRANSITIONS = 15000;
export default class Milkdrop extends React.Component {
constructor(props) {
super(props);
this.state = {
isFullscreen: false,
presetOverlay: false
};
this._handleFocusedKeyboardInput = this._handleFocusedKeyboardInput.bind(
this
);
this._handleFullscreenChange = this._handleFullscreenChange.bind(this);
}
async componentDidMount() {
this.setState({ currentPreset: this.props.presets.getCurrentIndex() });
this.visualizer = this.props.butterchurn.createVisualizer(
this.props.analyser.context,
this._canvasNode,
{
width: this.props.width,
height: this.props.height,
pixelRatio: window.devicePixelRatio || 1
}
);
this._setRendererSize(this.props.width, this.props.height);
this.visualizer.connectAudio(this.props.analyser);
// Kick off the animation loop
const loop = () => {
if (this.props.playing) {
this.visualizer.render();
}
this._animationFrameRequest = window.requestAnimationFrame(loop);
};
loop();
this.presetCycle = true;
this._unsubscribeFocusedKeyDown = this.props.onFocusedKeyDown(
this._handleFocusedKeyboardInput
);
screenfull.onchange(this._handleFullscreenChange);
}
componentWillUnmount() {
this._pauseViz();
this._stopCycling();
if (this._unsubscribeFocusedKeyDown) {
this._unsubscribeFocusedKeyDown();
}
screenfull.off("change", this._handleFullscreenChange);
}
componentDidUpdate(prevProps) {
if (
this.props.width !== prevProps.width ||
this.props.height !== prevProps.height
) {
this._setRendererSize(this.props.width, this.props.height);
}
}
_pauseViz() {
if (this._animationFrameRequest) {
window.cancelAnimationFrame(this._animationFrameRequest);
this._animationFrameRequest = null;
}
}
_stopCycling() {
if (this.cycleInterval) {
clearInterval(this.cycleInterval);
this.cycleInterval = null;
}
}
_restartCycling() {
this._stopCycling();
if (this.presetCycle) {
this.cycleInterval = setInterval(() => {
this._nextPreset(PRESET_TRANSITION_SECONDS);
}, MILLISECONDS_BETWEEN_PRESET_TRANSITIONS);
}
}
_setRendererSize(width, height) {
this._canvasNode.width = width;
this._canvasNode.height = height;
this.visualizer.setRendererSize(width, height);
}
_handleFullscreenChange() {
if (screenfull.isFullscreen) {
this._setRendererSize(window.innerWidth, window.innerHeight);
} else {
this._setRendererSize(this.props.width, this.props.height);
}
this.setState({ isFullscreen: screenfull.isFullscreen });
}
_handleRequestFullsceen() {
if (screenfull.enabled) {
if (!screenfull.isFullscreen) {
screenfull.request(this._wrapperNode);
} else {
screenfull.exit();
}
}
}
_handleFocusedKeyboardInput(e) {
switch (e.keyCode) {
case 32: // spacebar
this._nextPreset(USER_PRESET_TRANSITION_SECONDS);
break;
case 8: // backspace
this._prevPreset(0);
break;
case 72: // H
this._nextPreset(0);
break;
case 82: // R
this.props.presets.toggleRandomize();
break;
case 76: // L
this.setState({ presetOverlay: !this.state.presetOverlay });
e.stopPropagation();
break;
case 145: // scroll lock
case 125: // F14 (scroll lock for OS X)
this.presetCycle = !this.presetCycle;
this._restartCycling();
break;
}
}
async _nextPreset(blendTime) {
this.selectPreset(await this.props.presets.next(), blendTime);
}
async _prevPreset(blendTime) {
this.selectPreset(await this.props.presets.previous(), blendTime);
}
selectPreset(preset, blendTime = 0) {
if (preset != null) {
this.visualizer.loadPreset(preset, blendTime);
this._restartCycling();
this.setState({ currentPreset: this.props.presets.getCurrentIndex() });
}
}
closePresetOverlay() {
this.setState({ presetOverlay: false });
}
render() {
const width = this.state.isFullscreen
? window.innerWidth
: this.props.width;
const height = this.state.isFullscreen
? window.innerHeight
: this.props.height;
return (
<Background
innerRef={node => (this._wrapperNode = node)}
onDoubleClick={() => this._handleRequestFullsceen()}
>
{this.state.presetOverlay && (
<PresetOverlay
width={width}
height={height}
presetKeys={this.props.presets.getKeys()}
currentPreset={this.state.currentPreset}
onFocusedKeyDown={listener => this.props.onFocusedKeyDown(listener)}
selectPreset={async idx => {
this.selectPreset(await this.props.presets.selectIndex(idx), 0);
}}
closeOverlay={() => this.closePresetOverlay()}
/>
)}
<canvas
style={{
height: "100%",
width: "100%"
}}
ref={node => (this._canvasNode = node)}
/>
</Background>
);
}
}

View file

@ -8,16 +8,19 @@ class PresetOverlay extends React.Component {
this
);
}
componentDidMount() {
this._unsubscribeFocusedKeyDown = this.props.onFocusedKeyDown(
this._handleFocusedKeyboardInput
);
}
componentWillUnmount() {
if (this._unsubscribeFocusedKeyDown) {
this._unsubscribeFocusedKeyDown();
}
}
_handleFocusedKeyboardInput(e) {
switch (e.keyCode) {
case 38: // up arrow
@ -43,6 +46,7 @@ class PresetOverlay extends React.Component {
break;
}
}
render() {
if (!this.props.presetKeys) {
return (

View file

@ -0,0 +1,102 @@
function getRandomIndex(arr) {
return Math.floor(Math.random() * arr.length);
}
function getRandomValue(arr) {
return arr[getRandomIndex(arr)];
}
function getLast(arr) {
return arr[arr.length - 1];
}
/**
* Track a collection of async loaded presets
*
* Presets can be changed via `next`, `previous` or `selectIndex`. In each case,
* a promise is returned. If the promise resolves to `null` it means the
* selection was canceled by a subsequent request before it could be fulfilled.
* If the request is successful, the promise resolves to the selected preset.
*
* We assume a model where some portion of the preset are supplied at initialization
* and the remainder can be loaded async via the function `getRest`.
*/
export default class Presets {
constructor({ keys, initialPresets, getRest, randomize = true }) {
this._keys = keys; // Alphabetical list of preset names
this._presets = initialPresets; // Presets indexed by name
this._getRest = getRest; // An async function to get the rest of the presets
this._history = []; // Indexes into _keys
this._randomize = randomize;
// Initialize with a key that we already have.
const avaliableKeys = Object.keys(initialPresets);
const currentKey = randomize
? getRandomValue(avaliableKeys)
: avaliableKeys[0];
this._currentIndex = this._keys.indexOf(currentKey);
this._history.push(this._currentIndex);
}
toggleRandomize() {
this._randomize = !this._randomize;
}
setRandomize(val) {
this._randomize = val;
}
async next() {
let idx;
if (this._randomize || this._history.length === 0) {
idx = getRandomIndex(this._keys);
} else {
idx = (getLast(this._history) + 1) % this._keys.length;
}
this._history.push(idx);
return this._selectIndex(idx);
}
async previous() {
if (this._history.length > 1) {
this._history.pop();
return this._selectIndex(getLast(this._history));
}
// We are at the very beginning. There is no "previous" preset.
return Promise.resolve();
}
async selectIndex(idx) {
// The public version of this method must add to the history
this._history.push(idx);
return this._selectIndex(idx);
}
async _selectIndex(idx) {
const preset = this._presets[this._keys[idx]];
if (!preset) {
const rest = await this._getRest();
this._presets = Object.assign(this._presets, rest);
if (getLast(this._history) !== idx) {
// This selection must be obsolete. Return null so that
// the caller knows this request got canceled.
return null;
}
}
this._currentIndex = idx;
return this.getCurrent();
}
getKeys() {
return this._keys;
}
getCurrentIndex() {
return this._currentIndex;
}
getCurrent() {
// #matryoshka
return this._presets[this._keys[this._currentIndex]];
}
}

View file

@ -0,0 +1,173 @@
import { mockRandom } from "jest-mock-random";
import Presets from "../Presets";
let presets;
beforeEach(() => {
mockRandom([0.0]);
presets = new Presets({
keys: ["a", "b"],
initialPresets: { a: "Preset A", b: "Preset B" },
randomize: true
});
});
describe("initialize", () => {
beforeEach(() => {});
test("picks a random value", () => {
expect(presets.getCurrent()).toBe("Preset A");
});
test("picks another random value", () => {
mockRandom([0.9]);
presets = new Presets({
keys: ["a", "b"],
initialPresets: { a: "Preset A", b: "Preset B" },
randomize: true
});
expect(presets.getCurrent()).toBe("Preset B");
});
test("picks its random value from the initial presets", () => {
presets = new Presets({
keys: ["a", "b", "c", "d", "e", "f", "g", "h"],
initialPresets: { a: "Preset A" },
randomize: true
});
expect(presets.getCurrent()).toBe("Preset A");
});
});
describe("next", () => {
test("picks a 'random' preset", async () => {
mockRandom([0.9]);
presets.next();
expect(presets.getCurrent()).toBe("Preset B");
mockRandom([0.0]);
presets.next();
expect(presets.getCurrent()).toBe("Preset A");
});
test("picks the next key", async () => {
presets.setRandomize(false);
presets.next();
expect(presets.getCurrent()).toBe("Preset B");
});
test("wraps around", async () => {
presets.setRandomize(false);
presets.next();
presets.next();
expect(presets.getCurrent()).toBe("Preset A");
});
});
describe("previous", () => {
test("picks the previous key", async () => {
presets.setRandomize(false);
presets.next();
presets.previous();
expect(presets.getCurrent()).toBe("Preset A");
});
test("does nothing when you are on the first item", async () => {
presets.previous();
expect(presets.getCurrent()).toBe("Preset A");
});
test("can do sequential previouses", async () => {
mockRandom([0.0]);
presets = new Presets({
keys: ["a", "b", "c", "d"],
initialPresets: {
a: "Preset A",
b: "Preset B",
c: "Preset C",
d: "Preset D"
},
randomize: false
});
presets.next(); // b
presets.next(); // c
presets.next(); // d
presets.previous(); // c
presets.previous(); // b
presets.previous(); // a
expect(presets.getCurrent()).toBe("Preset A");
});
test("will successfully resolve an unloaded preset", async () => {
mockRandom([0.0]);
presets = new Presets({
keys: ["a", "b", "c", "d"],
initialPresets: {
a: "Preset A"
},
randomize: false,
getRest: () =>
Promise.resolve({
b: "Preset B",
c: "Preset C"
})
});
presets.next(); // b
presets.next(); // c
const final = await presets.previous(); // b
expect(final).toBe("Preset B");
});
});
describe("getRest", () => {
beforeEach(() => {
mockRandom([0.0]);
presets = new Presets({
keys: ["a", "b"],
initialPresets: { a: "Preset A" },
getRest: () =>
Promise.resolve({
b: "Preset B"
})
});
});
test("will get the rest of the presets if needed", async () => {
mockRandom([0.9]);
const resolved = presets.next();
expect(presets.getCurrent()).toBe("Preset A");
await resolved;
expect(presets.getCurrent()).toBe("Preset B");
});
test("next (loading), previous brings us back to where we started", async () => {
presets.setRandomize(false);
presets.next();
expect(presets.getCurrent()).toBe("Preset A");
await presets.previous();
expect(presets.getCurrent()).toBe("Preset A");
});
});
describe("selectIndex", () => {
test("adds an entry to the history", async () => {
presets.selectIndex(1);
presets.previous();
expect(presets.getCurrent()).toBe("Preset A");
});
});
describe("getCurrentIndex", () => {
test("gets the active index while loading", async () => {
presets = new Presets({
keys: ["a", "b"],
initialPresets: { a: "Preset A" },
randomize: false,
getRest: () =>
Promise.resolve({
b: "Preset B"
})
});
const resolved = presets.next();
expect(presets.getCurrentIndex()).toBe(0);
await resolved;
expect(presets.getCurrentIndex()).toBe(1);
});
});

View file

@ -1,26 +1,47 @@
import React from "react";
import { connect } from "react-redux";
import screenfull from "screenfull";
import PresetOverlay from "./PresetOverlay";
import Presets from "./Presets";
import Milkdrop from "./Milkdrop";
import Background from "./Background";
const USER_PRESET_TRANSITION_SECONDS = 5.7;
const PRESET_TRANSITION_SECONDS = 2.7;
const MILLISECONDS_BETWEEN_PRESET_TRANSITIONS = 15000;
class MilkdropWindow extends React.Component {
constructor(props) {
super(props);
this.state = {
isFullscreen: false,
presetOverlay: false,
currentPreset: -1
};
this._handleFocusedKeyboardInput = this._handleFocusedKeyboardInput.bind(
this
);
this._handleFullscreenChange = this._handleFullscreenChange.bind(this);
// This component is just responsible for loading dependencies.
// This simplifies the inner <Milkdrop /> component, by allowing
// it to alwasy assume that it has it's dependencies.
export default class PresetsLoader extends React.Component {
constructor() {
super();
this.state = { presets: null, butterchurn: null };
}
componentDidMount() {
async componentDidMount() {
const {
butterchurn,
presetKeys,
minimalPresets
} = await loadInitialDependencies();
this.setState({
butterchurn,
presets: new Presets({
keys: presetKeys,
initialPresets: minimalPresets,
getRest: loadNonMinimalPresets
})
});
}
render() {
const { butterchurn, presets } = this.state;
const loaded = butterchurn != null && presets != null;
return loaded ? (
<Milkdrop {...this.props} presets={presets} butterchurn={butterchurn} />
) : (
<Background />
);
}
}
async function loadInitialDependencies() {
return new Promise((resolve, reject) => {
require.ensure(
[
"butterchurn",
@ -28,265 +49,32 @@ class MilkdropWindow extends React.Component {
"butterchurn-presets/lib/butterchurnPresetPackMeta.min"
],
require => {
const analyserNode = this.props.analyser;
const butterchurn = require("butterchurn");
const butterchurnMinimalPresets = require("butterchurn-presets/lib/butterchurnPresetsMinimal.min");
const presetPackMeta = require("butterchurn-presets/lib/butterchurnPresetPackMeta.min");
this.presets = butterchurnMinimalPresets.getPresets();
this.minmalPresetKeys = Object.keys(this.presets);
this.presetKeys = presetPackMeta.getMainPresetMeta().presets;
const presetKey = this.minmalPresetKeys[
Math.floor(Math.random() * this.minmalPresetKeys.length)
];
this.visualizer = butterchurn.createVisualizer(
analyserNode.context,
this._canvasNode,
{
width: this.props.width,
height: this.props.height,
pixelRatio: window.devicePixelRatio || 1
}
);
this._canvasNode.width = this.props.width;
this._canvasNode.height = this.props.height;
this.visualizer.connectAudio(analyserNode);
this.visualizer.loadPreset(this.presets[presetKey], 0);
// Kick off the animation loop
const loop = () => {
if (this.props.status === "PLAYING") {
this.visualizer.render();
}
window.requestAnimationFrame(loop);
};
loop();
const presetIdx = this.presetKeys.indexOf(presetKey);
this.presetHistory = [presetIdx];
this.presetRandomize = true;
this.presetCycle = true;
this._restartCycling();
this._unsubscribeFocusedKeyDown = this.props.onFocusedKeyDown(
this._handleFocusedKeyboardInput
);
this.setState({ currentPreset: presetIdx });
},
e => {
console.error("Error loading Butterchurn", e);
resolve({
butterchurn,
minimalPresets: butterchurnMinimalPresets.getPresets(),
presetKeys: presetPackMeta.getMainPresetMeta().presets
});
},
reject,
"butterchurn"
);
}
componentWillUnmount() {
this._pauseViz();
this._stopCycling();
if (this._unsubscribeFocusedKeyDown) {
this._unsubscribeFocusedKeyDown();
}
screenfull.off("change", this._handleFullscreenChange);
}
componentDidUpdate(prevProps) {
if (
this.props.width !== prevProps.width ||
this.props.height !== prevProps.height
) {
this._setRendererSize(this.props.width, this.props.height);
}
}
_pauseViz() {
if (this.animationFrameRequest) {
window.cancelAnimationFrame(this.animationFrameRequest);
this.animationFrameRequest = null;
}
}
_stopCycling() {
if (this.cycleInterval) {
clearInterval(this.cycleInterval);
this.cycleInterval = null;
}
}
_restartCycling() {
this._stopCycling();
if (this.presetCycle) {
this.cycleInterval = setInterval(() => {
this._nextPreset(PRESET_TRANSITION_SECONDS);
}, MILLISECONDS_BETWEEN_PRESET_TRANSITIONS);
}
}
_setRendererSize(width, height) {
this._canvasNode.width = width;
this._canvasNode.height = height;
// It's possible that the visualizer has not been intialized yet.
if (this.visualizer != null) {
this.visualizer.setRendererSize(width, height);
}
}
_handleFullscreenChange() {
if (screenfull.isFullscreen) {
this._setRendererSize(window.innerWidth, window.innerHeight);
} else {
this._setRendererSize(this.props.width, this.props.height);
}
this.setState({ isFullscreen: screenfull.isFullscreen });
}
_handleRequestFullsceen() {
if (screenfull.enabled) {
if (!screenfull.isFullscreen) {
screenfull.request(this._wrapperNode);
} else {
screenfull.exit();
}
}
}
_handleFocusedKeyboardInput(e) {
switch (e.keyCode) {
case 32: // spacebar
this._nextPreset(USER_PRESET_TRANSITION_SECONDS);
break;
case 8: // backspace
this._prevPreset(0);
break;
case 72: // H
this._nextPreset(0);
break;
case 82: // R
this.presetRandomize = !this.presetRandomize;
break;
case 76: // L
this.setState({ presetOverlay: !this.state.presetOverlay });
e.stopPropagation();
break;
case 145: // scroll lock
case 125: // F14 (scroll lock for OS X)
this.presetCycle = !this.presetCycle;
this._restartCycling();
break;
}
}
async _loadMainPresetPack() {
this.loadingPresets = true;
return require.ensure(
["butterchurn-presets/lib/butterchurnPresetsNonMinimal.min"],
require => {
const butterchurnNonMinimalPresets = require("butterchurn-presets/lib/butterchurnPresetsNonMinimal.min");
Object.assign(this.presets, butterchurnNonMinimalPresets.getPresets());
this.loadingPresets = false;
},
e => {
console.error("Error loading Butterchurn Presets", e);
},
"butterchurn-presets"
);
}
async _nextPreset(blendTime) {
// The visualizer may not have initialized yet.
if (this.visualizer != null) {
let presetIdx;
if (this.presetRandomize || this.presetHistory.length === 0) {
presetIdx = Math.floor(this.presetKeys.length * Math.random());
} else {
const prevPresetIdx = this.presetHistory[this.presetHistory.length - 1];
presetIdx = (prevPresetIdx + 1) % this.presetKeys.length;
}
this.selectPreset(presetIdx, blendTime);
}
}
_prevPreset(blendTime) {
if (this.loadingPresets && this.presetQueue.length > 1) {
this.presetQueue.pop();
return;
}
if (this.presetHistory.length > 1 && this.visualizer != null) {
this.presetHistory.pop();
const prevPreset = this.presetHistory[this.presetHistory.length - 1];
this.visualizer.loadPreset(
this.presets[this.presetKeys[prevPreset]],
blendTime
);
this._restartCycling();
this.setState({ currentPreset: prevPreset });
}
}
async selectPreset(presetIdx, blendTime) {
if (this.loadingPresets) {
this.presetQueue.push(presetIdx);
return;
}
let preset = this.presets[this.presetKeys[presetIdx]];
let selectedIdx;
if (!preset) {
this.presetQueue = [presetIdx];
await this._loadMainPresetPack();
if (this.presetQueue.length === 0) {
return;
}
selectedIdx = this.presetQueue[this.presetQueue.length - 1];
preset = this.presets[this.presetKeys[selectedIdx]];
this.presetHistory = this.presetHistory.concat(this.presetQueue);
} else {
selectedIdx = presetIdx;
this.presetHistory.push(selectedIdx);
}
this.visualizer.loadPreset(preset, blendTime);
this._restartCycling();
this.setState({ currentPreset: selectedIdx });
}
closePresetOverlay() {
this.setState({ presetOverlay: false });
}
render() {
const width = this.state.isFullscreen
? window.innerWidth
: this.props.width;
const height = this.state.isFullscreen
? window.innerHeight
: this.props.height;
return (
<div
className="draggable"
style={{
// This color will be used until Butterchurn is loaded
backgroundColor: "#000",
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
height: "100%",
width: "100%"
}}
tabIndex="0"
ref={node => (this._wrapperNode = node)}
onDoubleClick={() => this._handleRequestFullsceen()}
>
{this.state.presetOverlay && (
<PresetOverlay
width={width}
height={height}
presetKeys={this.presetKeys}
currentPreset={this.state.currentPreset}
onFocusedKeyDown={listener => this.props.onFocusedKeyDown(listener)}
selectPreset={idx => this.selectPreset(idx, 0)}
closeOverlay={() => this.closePresetOverlay()}
/>
)}
<canvas
style={{
height: "100%",
width: "100%"
}}
ref={node => (this._canvasNode = node)}
/>
</div>
);
}
});
}
const mapStateToProps = state => ({
status: state.media.status
});
export default connect(mapStateToProps)(MilkdropWindow);
async function loadNonMinimalPresets() {
return new Promise((resolve, reject) => {
require.ensure(
["butterchurn-presets/lib/butterchurnPresetsNonMinimal.min"],
require => {
resolve(
require("butterchurn-presets/lib/butterchurnPresetsNonMinimal.min").getPresets()
);
},
reject,
"butterchurn-presets"
);
});
}

View file

@ -17,7 +17,7 @@
"weight": "npm run build-library > /dev/null && gzip-size built/webamp.bundle.min.js",
"test": "jest --projects config/jest.unit.js config/jest.eslint.js",
"travis-tests": "npm run test && npm run build && npm run build-library",
"tdd": "jest --watch",
"tdd": "jest --projects config/jest.unit.js --watch",
"format": "prettier --write experiments/**/*.js js/**/*.js css/**/*.css !css/**/*.min.css",
"build-skin": "rm skins/base-2.91.wsz && cd skins/base-2.91 && zip -x .* -x 'Skining Updates.txt' -r ../base-2.91.wsz .",
"build-skin-png": "rm skins/base-2.91-png.wsz && cd skins/base-2.91-png && zip -x .* -x 'Skining Updates.txt' -r ../base-2.91-png.wsz .",
@ -74,6 +74,7 @@
"http-server": "^0.11.1",
"invariant": "^2.2.3",
"jest": "^23.0.0",
"jest-mock-random": "^1.0.2",
"jest-runner-eslint": "^0.4.0",
"jsmediatags": "^3.8.1",
"jszip": "^3.1.3",

View file

@ -4341,6 +4341,10 @@ jest-message-util@^23.0.0:
slash "^1.0.0"
stack-utils "^1.0.1"
jest-mock-random@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/jest-mock-random/-/jest-mock-random-1.0.2.tgz#81a1aa641fdb3a049bf64e2a7a0411fd8fc3fb20"
jest-mock@^23.0.0:
version "23.0.0"
resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-23.0.0.tgz#d9d897a1b74dc05c66a737213931496215897dd8"