Merge branch 'main' into patch-inline-fonts

This commit is contained in:
bjoluc 2022-03-12 11:26:41 +01:00
commit 62b131efae
26 changed files with 2753 additions and 7167 deletions

View File

@ -0,0 +1,5 @@
---
"jspsych": minor
---
Added `filterColumns()` to the DataCollection class. This function lets users select a subset of the columns in the DataCollection. It is the opposite of the `ignore()` method.

View File

@ -0,0 +1,5 @@
---
"jspsych": minor
---
Added `setSeed()` to `jsPsych.randomization` to allow for seeding the random number generator and generating predictable sequences of random numbers.

View File

@ -0,0 +1,5 @@
---
"@jspsych/config": minor
---
Implement an `updateUnpkgLinks` Gulp task to update each unpkg link with a precise version number to the corresponding package's current version as defined in the package's `package.json`.

View File

@ -0,0 +1,5 @@
---
"@jspsych/config": patch
---
Fix css path rewriting in `createCoreDistArchive` Gulp task when `link` tags do not end in `/>`

View File

@ -25,7 +25,7 @@ jobs:
cache: npm
- name: Install dependencies
run: npm install
run: npm ci
- name: Download Turborepo cache
uses: actions/cache@v2
@ -35,9 +35,6 @@ jobs:
restore-keys: |
${{ runner.os }}-node-16-turbo-
- name: Build packages
run: npm run build
- name: Run tests
run: npm run test -- --ci --maxWorkers=2
env:
@ -47,7 +44,8 @@ jobs:
id: changesets
uses: changesets/action@v1
with:
publish: npm run release
version: npm run changeset:version
publish: npm run changeset:publish
commit: "chore(release): version packages"
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@ -15,7 +15,7 @@
const trial = {
type: jsPsychBrowserCheck,
inclusion_function: (data) => {
return ['chrome', 'firefox'].contains(data.browser);
return ['chrome', 'firefox'].includes(data.browser);
},
exclusion_message: `<p>You must use Chrome or Firefox to complete this experiment.</p>`
};

View File

@ -175,7 +175,7 @@ If you have tips based on your own experience please consider sharing them on ou
<script src="https://unpkg.com/@jspsych/plugin-webgazer-init-camera@1.0.0"></script>
<script src="https://unpkg.com/@jspsych/plugin-webgazer-calibrate@1.0.0"></script>
<script src="https://unpkg.com/@jspsych/plugin-webgazer-validate@1.0.0"></script>
<script src="https://cdn.jsdelivr.net/gh/jspsych/jspsych@7.1.2/examples/js/webgazer/webgazer.js"></script>
<script src="https://cdn.jsdelivr.net/gh/jspsych/jsPsych@jspsych@7.1.2/examples/js/webgazer/webgazer.js"></script>
<script src="https://unpkg.com/@jspsych/extension-webgazer@1.0.0"></script>
<link
rel="stylesheet"

View File

@ -1,4 +1,4 @@
# Intergrating with Prolific
# Integrating with Prolific
[Prolific](https://www.prolific.co/?ref=5JCXZPVU) is a participant recruitment service aimed at research. Integrating a jsPsych experiment with Prolific requires capturing the participant's ID and sending the participant to a completion URL at the end of the experiment.

View File

@ -96,7 +96,7 @@ As with all simulated plugins, you can override the default (actual) data with f
var trial = {
type: jsPsychBrowserCheck,
inclusion_function: (data) => {
return ['chrome', 'firefox'].contains(data.browser);
return ['chrome', 'firefox'].includes(data.browser);
},
exclusion_message: `<p>You must use Chrome or Firefox to complete this experiment.</p>`
};

View File

@ -1,6 +1,6 @@
# webgazer-calibrate
This plugin can be used to calibrate the [WebGazer extension](../extensions/webgazer). For a narrative description of eye tracking with jsPsych, see the [eye tracking overview](../overview/eye-tracking).
This plugin can be used to calibrate the [WebGazer extension](../extensions/webgazer.md). For a narrative description of eye tracking with jsPsych, see the [eye tracking overview](../overview/eye-tracking.md).
## Parameters
@ -23,7 +23,7 @@ In addition to the [default data collected by all plugins](../overview/plugins.m
Name | Type | Value
-----|------|------
No data currently added by this plugin. Use the [webgazer-validate](jspsych-webgazer-validate) plugin to measure the precision and accuracy of calibration.
No data currently added by this plugin. Use the [webgazer-validate](../webgazer-validate) plugin to measure the precision and accuracy of calibration.
## Simulation Mode

View File

@ -1,6 +1,6 @@
# webgazer-init-camera
This plugin initializes the camera and helps the participant center their face in the camera view for using the the [WebGazer extension](../extensions/webgazer). For a narrative description of eye tracking with jsPsych, see the [eye tracking overview](../overview/eye-tracking).
This plugin initializes the camera and helps the participant center their face in the camera view for using the the [WebGazer extension](../extensions/webgazer.md). For a narrative description of eye tracking with jsPsych, see the [eye tracking overview](../overview/eye-tracking.md).
## Parameters

View File

@ -1,6 +1,6 @@
# webgazer-validate
This plugin can be used to measure the accuracy and precision of gaze predictions made by the [WebGazer extension](../extensions/webgazer). For a narrative description of eye tracking with jsPsych, see the [eye tracking overview](../overview/eye-tracking).
This plugin can be used to measure the accuracy and precision of gaze predictions made by the [WebGazer extension](../extensions/webgazer.md). For a narrative description of eye tracking with jsPsych, see the [eye tracking overview](../overview/eye-tracking.md).
## Parameters

View File

@ -364,6 +364,15 @@ The filter method returns a DataCollection object, so methods can be chained ont
var block_1_correct = jsPsych.data.get().filter({block:1, correct:true}).count();
```
#### .filterColumns()
Selects the set of columns listed in the array. This is the opposite of the `.ignore()` method.
```javascript
// Get only the subject, rt, and condition entries for each trial.
const subset_of_data = jsPsych.data.get().filterColumns(['subject', 'rt', 'condition'])
```
#### .filterCustom()
This method is similar to the `.filter()` method, except that it accepts a function as the filter. The function is passed a single argument, containing the data for a trial. If the function returns `true` the trial is included in the returned DataCollection.

View File

@ -467,6 +467,51 @@ var sample = jsPsych.randomization.sampleWithoutReplacement(myArray, 2);
---
## jsPsych.randomization.setSeed
```javascript
jsPsych.randomization.setSeed(seed)
```
### Parameters
| Parameter | Type | Description |
| --------- | ----- | ------------------------------ |
| seed | string | A seed for the random number generator |
### Return value
Returns the seed value.
### Description
This function will override the behavior of `Math.random()` to produce a seedable pseudo random number generator.
It uses the [seedrandom package](https://www.npmjs.com/package/seedrandom).
Note that calling `setSeed()` will change how `Math.random()` behaves for the entire document.
If you have non-jsPsych components on the page that use `Math.random()` they will be affected.
Using `setSeed()` without passing in a seed will generate a random 32-bit seed.
The seed value will be returned from the function call, allowing you to save it in the data for the experiment if needed.
### Examples
#### Use a random 32-bit seed and save to data
```javascript
const seed = jsPsych.setSeed();
jsPsych.data.addProperties({
rng_seed: seed
});
```
#### Use your own seed
```javascript
jsPsych.setSeed("jspsych");
```
---
## jsPsych.randomization.shuffle
```javascript

View File

@ -1 +1 @@
export { createCoreDistArchive } from "@jspsych/config/gulp";
export { createCoreDistArchive, updateUnpkgLinks } from "@jspsych/config/gulp";

9515
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,24 +9,27 @@
"test:watch": "npm test -- --watch",
"build": "turbo run build",
"build:archive": "gulp createCoreDistArchive",
"update-unpkg-links": "gulp updateUnpkgLinks",
"prepare": "node -e 'process.exit(!process.env.CI)' || (husky install && npm run build)",
"tsc": "turbo tsc",
"changeset": "changeset",
"release": "changeset publish"
"changeset:version": "changeset version && npm install && npm run update-unpkg-links",
"changeset:publish": "npm run build && changeset publish"
},
"engines": {
"node": ">=14.0.0",
"npm": ">=7.0.0"
},
"packageManager": "npm@8.3.1",
"devDependencies": {
"@changesets/changelog-github": "^0.4.1",
"@changesets/cli": "^2.17.0",
"husky": "^7.0.1",
"@changesets/changelog-github": "^0.4.3",
"@changesets/cli": "^2.21.1",
"husky": "^7.0.4",
"import-sort-style-module": "^6.0.0",
"lint-staged": "^11.1.2",
"prettier": "^2.3.2",
"lint-staged": "^12.3.5",
"prettier": "^2.5.1",
"prettier-plugin-import-sort": "^0.0.7",
"turbo": "^1.0.6"
"turbo": "^1.1.6"
},
"prettier": {
"printWidth": 100
@ -44,22 +47,5 @@
"projects": [
"<rootDir>/packages/*/jest.config.cjs"
]
},
"turbo": {
"baseBranch": "origin/main",
"npmClient": "npm",
"pipeline": {
"build": {
"dependsOn": [
"^build"
],
"outputs": [
"dist/**"
]
},
"tsc": {
"outputs": []
}
}
}
}

View File

@ -13,6 +13,20 @@ const { dest, src } = gulp;
const readJsonFile = (filename) => JSON.parse(readFileSync(filename, "utf8"));
const getAllPackages = () =>
glob
// Get an array of all package.json filenames
.sync("packages/*/package.json")
// Map file names to package details
.map((filename) => {
const packageJson = readJsonFile(filename);
return {
name: packageJson.name,
version: packageJson.version,
};
});
const getVersionFileContents = () =>
[
"Included in this release:\n",
@ -25,19 +39,7 @@ const getVersionFileContents = () =>
"/";
return (
glob
// Get an array of all package.json filenames
.sync("packages/*/package.json")
// Map file names to package details
.map((filename) => {
const packageJson = readJsonFile(filename);
return {
name: packageJson.name,
version: packageJson.version,
};
})
getAllPackages()
// Filter packages that should not be listed
.filter(({ name }) => !["@jspsych/config", "@jspsych/test-utils"].includes(name))
@ -85,15 +87,15 @@ export const createCoreDistArchive = () =>
// Rewrite script source paths
.pipe(
replace(
/<script src="(.*)\/packages\/(.*)\/dist\/index\.browser\.js"><\/script>/g,
'<script src="$1/dist/$2.js"></script>'
/<script src="(.*)\/packages\/(.*)\/dist\/index\.browser\.js"/g,
'<script src="$1/dist/$2.js"'
)
)
// Rewrite jspsych css source paths
.pipe(
replace(
/<link rel="stylesheet" href="(.*)\/packages\/jspsych\/css\/(.*)" \/>/g,
'<link rel="stylesheet" href="$1/dist/$2" />'
/<link rel="stylesheet" href="(.*)\/packages\/jspsych\/css\/(.*)"/g,
'<link rel="stylesheet" href="$1/dist/$2"'
)
),
@ -105,3 +107,23 @@ export const createCoreDistArchive = () =>
)
.pipe(zip("dist.zip"))
.pipe(dest("."));
/**
* Updates each unpkg link with a precise version number to the corresponding package's current
* version as defined in the package's `package.json`. Only considers `.md` and `.html` files.
*/
export const updateUnpkgLinks = () => {
const packageVersions = new Map(getAllPackages().map(({ name, version }) => [name, version]));
return src(["./**/*.{md,html}"])
.pipe(
replace(
/"https:\/\/unpkg\.com\/(@?.*)@(\d+.\d+.\d+)(\/[^"]*)?"/g,
(url, packageName, currentVersion, path) => {
const latestVersion = packageVersions.get(packageName) ?? currentVersion;
return `"https://unpkg.com/${packageName}@${latestVersion}${path ?? ""}"`;
}
)
)
.pipe(dest("./"));
};

View File

@ -42,12 +42,14 @@
"homepage": "https://www.jspsych.org",
"dependencies": {
"auto-bind": "^4.0.0",
"random-words": "^1.1.1"
"random-words": "^1.1.1",
"seedrandom": "^3.0.5"
},
"devDependencies": {
"@fontsource/open-sans": "4.5.3",
"@jspsych/config": "^1.1.0",
"@types/dom-mediacapture-record": "^1.0.11",
"@types/seedrandom": "^3.0.1",
"base64-inline-loader": "^2.0.1",
"css-loader": "^6.6.0",
"mini-css-extract-plugin": "^2.5.3",

View File

@ -134,6 +134,14 @@ export class DataCollection {
return new DataCollection(this.trials.filter(fn));
}
filterColumns(columns: Array<string>) {
return new DataCollection(
this.trials.map((trial) =>
Object.fromEntries(columns.filter((key) => key in trial).map((key) => [key, trial[key]]))
)
);
}
select(column) {
const values = [];
for (const trial of this.trials) {

View File

@ -1,4 +1,20 @@
import rw from "random-words";
import seedrandom from "seedrandom";
/**
* Uses the `seedrandom` package to replace Math.random() with a seedable PRNG.
*
* @param seed An optional seed. If none is given, a random seed will be generated.
* @returns The seed value.
*/
export function setSeed(seed?: string) {
if (!seed) {
const prng = seedrandom();
seed = prng.int32().toString();
}
seedrandom(seed, { global: true });
return seed;
}
export function repeat(array, repetitions, unpack = false) {
const arr_isArray = Array.isArray(array);

View File

@ -15,14 +15,14 @@ describe("DataCollection", () => {
dataCollection = new DataCollection(data);
});
test("#filter", () => {
test("filter", () => {
expect(dataCollection.filter({ filter: true }).count()).toBe(2);
});
test("#filter OR", () => {
test("filter OR", () => {
expect(dataCollection.filter([{ filter: true }, { rt: 300 }]).count()).toBe(2);
expect(dataCollection.filter([{ filter: true }, { rt: 200 }]).count()).toBe(3);
});
test("#filterCustom", () => {
test("filterCustom", () => {
expect(
dataCollection
.filterCustom((x) => {
@ -31,43 +31,59 @@ describe("DataCollection", () => {
.count()
).toBe(2);
});
test("#ignore", () => {
test("filterColumns", () => {
data = [
{ foo: "bar", rt: 100, filter: true },
{ foo: "bar", rt: 200, filter: false },
];
dataCollection = new DataCollection(data);
const filtered_data = dataCollection.filterColumns(["rt", "foo"]);
expect(filtered_data.values()).toEqual([
{ foo: "bar", rt: 100 },
{ foo: "bar", rt: 200 },
]);
});
test("ignore", () => {
expect(dataCollection.ignore("rt").select("rt").count()).toBe(0);
});
test("#select", () => {
test("select", () => {
expect(JSON.stringify(dataCollection.select("rt").values)).toBe(
JSON.stringify([100, 200, 300, 400, 500])
);
});
test("#addToAll", () => {
test("addToAll", () => {
expect(dataCollection.readOnly().addToAll({ added: 5 }).select("added").count()).toBe(5);
});
test("#addToLast", () => {
test("addToLast", () => {
dataCollection.addToLast({ lastonly: true });
expect(dataCollection.values()[4].lastonly).toBe(true);
});
test("#readOnly", () => {
test("readOnly", () => {
const d = dataCollection.readOnly().values();
d[0].rt = 0;
expect(dataCollection.values()[0].rt).toBe(100);
});
test("not #readOnly", () => {
test("not readOnly", () => {
const d = dataCollection.values();
d[0].rt = 0;
expect(dataCollection.values()[0].rt).toBe(0);
});
test("#count", () => {
test("count", () => {
expect(dataCollection.count()).toBe(5);
});
test("#push", () => {
test("push", () => {
dataCollection.push({ rt: 600, filter: true });
expect(dataCollection.count()).toBe(6);
});
test("#values", () => {
test("values", () => {
expect(JSON.stringify(dataCollection.values())).toBe(JSON.stringify(data));
expect(dataCollection.values()).toBe(data);
});
test("#first", () => {
test("first", () => {
expect(dataCollection.first(3).count()).toBe(3);
expect(dataCollection.first(2).values()[1].rt).toBe(200);
expect(dataCollection.first().count()).toBe(1);
@ -82,7 +98,7 @@ describe("DataCollection", () => {
const too_many = n + 1;
expect(dataCollection.first(too_many).count()).toBe(n);
});
test("#last", () => {
test("last", () => {
expect(dataCollection.last(2).count()).toBe(2);
expect(dataCollection.last(2).values()[0].rt).toBe(400);
expect(dataCollection.last().count()).toBe(1);
@ -97,14 +113,14 @@ describe("DataCollection", () => {
const too_many = n + 1;
expect(dataCollection.last(too_many).count()).toBe(n);
});
test("#join", () => {
test("join", () => {
const dc1 = dataCollection.filter({ filter: true });
const dc2 = dataCollection.filter({ rt: 500 });
const data = dc1.join(dc2);
expect(data.count()).toBe(3);
expect(data.values()[2].rt).toBe(500);
});
test("#unqiueNames", () => {
test("unqiueNames", () => {
expect(
new DataCollection([
{ rt: 100, filter: true },

View File

@ -3,6 +3,7 @@ import {
randomID,
randomInt,
repeat,
setSeed,
shuffle,
shuffleAlternateGroups,
shuffleNoRepeats,
@ -12,7 +13,7 @@ afterEach(() => {
jest.restoreAllMocks();
});
describe("#shuffle", () => {
describe("shuffle", () => {
test("should produce fixed order with mock RNG", () => {
jest.spyOn(Math, "random").mockReturnValue(0.5);
const arr = [1, 2, 3, 4, 5, 6];
@ -31,7 +32,7 @@ describe("shuffleAlternateGroups", () => {
});
});
describe("#randomID", () => {
describe("randomID", () => {
test("should produce ID based on mock RNG", () => {
jest
.spyOn(Math, "random")
@ -165,3 +166,20 @@ describe("randomInt", () => {
}).toThrowError();
});
});
describe("setSeed", () => {
test("Replaces Math.random() with seedable RNG", () => {
setSeed("jspsych");
const r1_1 = Math.random();
const r1_2 = Math.random();
setSeed("jspsych");
const r2_1 = Math.random();
const r2_2 = Math.random();
expect(r1_1).toEqual(r2_1);
expect(r1_2).toEqual(r2_2);
});
});

View File

@ -0,0 +1,93 @@
// this file is to test that jsPsych.randomization.setSeed works in context
import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response";
import { pressKey, startTimeline } from "@jspsych/test-utils";
import { initJsPsych } from "../../src";
describe("setSeed generates predictable randomization", () => {
test("timeline variable randomization is preserved", async () => {
const jsPsych = initJsPsych();
const seed = jsPsych.randomization.setSeed();
const { getData } = await startTimeline(
[
{
timeline: [
{
type: htmlKeyboardResponse,
stimulus: "this is html",
data: {
i: jsPsych.timelineVariable("i"),
},
},
],
timeline_variables: [
{ i: 0 },
{ i: 1 },
{ i: 2 },
{ i: 3 },
{ i: 4 },
{ i: 5 },
{ i: 6 },
{ i: 7 },
{ i: 8 },
],
randomize_order: true,
},
],
jsPsych
);
for (let i = 0; i < 9; i++) {
pressKey(" ");
}
const data_run_1 = getData().readOnly();
const jsPsych_run2 = initJsPsych();
jsPsych_run2.randomization.setSeed(seed);
const { getData: getData2 } = await startTimeline(
[
{
timeline: [
{
type: htmlKeyboardResponse,
stimulus: "this is html",
data: {
i: jsPsych_run2.timelineVariable("i"),
},
},
],
timeline_variables: [
{ i: 0 },
{ i: 1 },
{ i: 2 },
{ i: 3 },
{ i: 4 },
{ i: 5 },
{ i: 6 },
{ i: 7 },
{ i: 8 },
],
randomize_order: true,
},
],
jsPsych_run2
);
for (let i = 0; i < 9; i++) {
pressKey(" ");
}
const data_run_2 = getData2().readOnly();
console.log(data_run_1.values());
console.log(data_run_2.values());
expect(data_run_1.select("i").values).toEqual(data_run_2.select("i").values);
});
});

View File

@ -33,6 +33,7 @@
"jspsych": ">=7.0.0"
},
"devDependencies": {
"@jspsych/config": "^1.1.0"
"@jspsych/config": "^1.1.0",
"jspsych": "^7.0.0"
}
}

17
turbo.json Normal file
View File

@ -0,0 +1,17 @@
{
"baseBranch": "origin/main",
"npmClient": "npm",
"pipeline": {
"build": {
"dependsOn": [
"^build"
],
"outputs": [
"dist/**"
]
},
"tsc": {
"outputs": []
}
}
}