mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-26 07:48:12 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
044720e13f | ||
|
|
2d7aab3a9a | ||
|
|
f749733d54 | ||
|
|
ffcf6eb5d8 | ||
|
|
c209a9bca7 | ||
|
|
85319d45a4 | ||
|
|
514f1627ea | ||
|
|
127de34a20 | ||
|
|
54ab9d79b6 | ||
|
|
f48d06fb90 | ||
|
|
84563291a0 | ||
|
|
ddb601c103 | ||
|
|
450261f109 | ||
|
|
c0f4ad6dfa | ||
|
|
ff5da77372 | ||
|
|
a9044b01df | ||
|
|
11b948369f | ||
|
|
a2dc0c25b1 | ||
|
|
a9ef01a6f9 | ||
|
|
03d9e8e6aa | ||
|
|
30e19e47c6 | ||
|
|
d2b20129f7 | ||
|
|
6c86216f6b | ||
|
|
4df50a7677 | ||
|
|
69a6d52240 | ||
|
|
06da05eb9f | ||
|
|
d753441acc | ||
|
|
8b4c1e418e | ||
|
|
f2bd6ebce1 | ||
|
|
de44739f8b | ||
|
|
002c9a5c7f | ||
|
|
e6a0351079 | ||
|
|
38fdc649f5 | ||
|
|
69a5d5e837 | ||
|
|
b854c58eea | ||
|
|
2d650ba50d | ||
|
|
8bac98f25c | ||
|
|
ceb0cc6f27 | ||
|
|
853976b716 | ||
|
|
a344f292a9 | ||
|
|
15d2ce62b8 | ||
|
|
8e011314e9 | ||
|
|
c81731d32c | ||
|
|
3b091d888d | ||
|
|
79aeedc6c3 | ||
|
|
e6bacf043b | ||
|
|
be020deaa6 | ||
|
|
4034256d9c | ||
|
|
5163e35e1f | ||
|
|
f4986c9c7d | ||
|
|
a0011b0872 | ||
|
|
77fff0bd9c | ||
|
|
6a4884d97c | ||
|
|
15714e9924 | ||
|
|
5eb0ecc997 | ||
|
|
e2772d1da9 | ||
|
|
7f068cd576 | ||
|
|
e4961a579e | ||
|
|
c0263f6ce5 | ||
|
|
3ebbb0ccbf | ||
|
|
97caa5bcaa | ||
|
|
f49c3d8fe7 | ||
|
|
ef2ab07ca0 | ||
|
|
69e159916e | ||
|
|
a239d8636d | ||
|
|
0634160c0c | ||
|
|
01013bcf04 | ||
|
|
f9c744327a | ||
|
|
780852fd49 | ||
|
|
de03129061 | ||
|
|
2715dba703 | ||
|
|
154d099fef | ||
|
|
eaae6df5e3 | ||
|
|
295590a7c6 | ||
|
|
b814489328 | ||
|
|
08b03ace60 | ||
|
|
45cecad76a | ||
|
|
3fdf5cac9d | ||
|
|
3c3a794986 | ||
|
|
4b417cb5a5 | ||
|
|
925b785b1f | ||
|
|
660dff256c | ||
|
|
59c475b821 | ||
|
|
fc60b2acef | ||
|
|
26de334274 | ||
|
|
95ce39f2e9 | ||
|
|
03bb904f24 | ||
|
|
01d34580b9 | ||
|
|
5ab96ed360 | ||
|
|
97fb98b4eb | ||
|
|
0e7daea737 | ||
|
|
8136387127 | ||
|
|
27575b8d86 | ||
|
|
32f60a1547 | ||
|
|
1140676106 | ||
|
|
0b1ab02a60 | ||
|
|
1998c0ad63 | ||
|
|
2b072e6271 | ||
|
|
a1c46170b9 | ||
|
|
934cc5c69b | ||
|
|
7328cbdbd5 | ||
|
|
1ea2b7fe80 | ||
|
|
1ebde2213d | ||
|
|
bbf1950624 | ||
|
|
3d7490e0a9 | ||
|
|
0667ad0274 | ||
|
|
e334fbe96e | ||
|
|
45a8984223 | ||
|
|
07816c2f93 | ||
|
|
90da04b6c7 | ||
|
|
873b3a2f1f | ||
|
|
5f976cc4fc | ||
|
|
f55c726e2a | ||
|
|
56fcbae672 | ||
|
|
face1b3033 | ||
|
|
b714c18e75 | ||
|
|
b8b1d0dd9e | ||
|
|
5007d307a2 | ||
|
|
82de4178d9 | ||
|
|
023ae10822 | ||
|
|
28b526b3fc | ||
|
|
7ed53c0f5b | ||
|
|
a4050f10e1 | ||
|
|
6f6634a4d9 | ||
|
|
265ccf3682 | ||
|
|
59ec3afbb9 | ||
|
|
4666f1f044 | ||
|
|
d04dba225f | ||
|
|
096d8ff1d2 | ||
|
|
25b308c532 | ||
|
|
f2e9aecebe | ||
|
|
ebeb1c8818 | ||
|
|
b60d20eb2f | ||
|
|
fd2a7a53ab | ||
|
|
860547ced1 | ||
|
|
876ceb4d8b | ||
|
|
99a795a688 | ||
|
|
e2ce9a90ca | ||
|
|
63cf82c60e | ||
|
|
c23b25d4e8 | ||
|
|
f36d889455 | ||
|
|
4510c8c8a4 | ||
|
|
6ecdfa4940 | ||
|
|
b446bf7144 | ||
|
|
20bfa4cc97 | ||
|
|
2fa19c756c | ||
|
|
ae30b66123 | ||
|
|
bfb9e1edcc | ||
|
|
ae23c9e5b9 | ||
|
|
b494dd31f4 | ||
|
|
78c9d24ed4 | ||
|
|
e6e358ddc8 | ||
|
|
5335c39184 | ||
|
|
a89362c1ad | ||
|
|
8d932fb47d | ||
|
|
920d71e6b7 | ||
|
|
31e0326ded | ||
|
|
1d3d9237e7 | ||
|
|
c037f4eaa6 | ||
|
|
b00496198d | ||
|
|
b3aadf6632 | ||
|
|
f3a3820ffa | ||
|
|
09466a5c32 | ||
|
|
de571ce0b3 | ||
|
|
fb61bd6e9c | ||
|
|
036b5f8c0f | ||
|
|
e6281baa39 | ||
|
|
3aef21f76c | ||
|
|
d821f0eac9 | ||
|
|
3c0b1d45db | ||
|
|
90a4ff7526 | ||
|
|
d5ad823895 | ||
|
|
0e48b335be | ||
|
|
e264724597 | ||
|
|
c7eb319817 | ||
|
|
db559467a2 | ||
|
|
96362b0b98 | ||
|
|
7599af252d | ||
|
|
f07e174a06 | ||
|
|
46d02b1987 | ||
|
|
0b91371ea8 | ||
|
|
c428242878 | ||
|
|
9be086b1e8 | ||
|
|
9a34264f92 | ||
|
|
66e55ba59a | ||
|
|
f700fb7757 | ||
|
|
ae310097dd | ||
|
|
ea70a35a01 | ||
|
|
16718f392f | ||
|
|
ad6b1928e4 | ||
|
|
2cc02a84cb | ||
|
|
f77038ad58 | ||
|
|
2cf55ab819 | ||
|
|
095db02439 | ||
|
|
85fc000418 | ||
|
|
34742b9a94 | ||
|
|
06b88cda20 | ||
|
|
23714bc9f8 | ||
|
|
e892e64685 | ||
|
|
39a1124c35 | ||
|
|
b5856ff9bc | ||
|
|
d87551ce5e | ||
|
|
a5c9ae2432 | ||
|
|
78176f9711 | ||
|
|
b9e132a00c | ||
|
|
3b6c46ba86 | ||
|
|
6a1e5f71be | ||
|
|
727af83152 | ||
|
|
924b518492 | ||
|
|
946a910ef4 | ||
|
|
7e9353c5f1 | ||
|
|
c106ebc525 | ||
|
|
2fa206f2ad | ||
|
|
f41a023418 | ||
|
|
9793398737 | ||
|
|
377e4bccaa | ||
|
|
6a741956e1 | ||
|
|
c9f9549118 | ||
|
|
242546371b | ||
|
|
798976e4c1 | ||
|
|
5325495123 | ||
|
|
beb46bf3df | ||
|
|
86ff48ae36 | ||
|
|
c01a2c72b6 | ||
|
|
6677d0de56 | ||
|
|
c4c48b9a57 | ||
|
|
b5ec43c4b1 | ||
|
|
6c1756f852 | ||
|
|
1c4c501c43 | ||
|
|
cf77a8da9e | ||
|
|
eb108feb37 | ||
|
|
b386ca3425 | ||
|
|
b048fd8d9a | ||
|
|
3718e894ed | ||
|
|
1f9bba6f00 | ||
|
|
8bd4cbd1e5 | ||
|
|
e6c996ca93 | ||
|
|
99152dd5aa | ||
|
|
04a3c3c6b6 | ||
|
|
998fa4762d | ||
|
|
2ffe5ffcae | ||
|
|
4aabb8e092 | ||
|
|
7bee09cd16 | ||
|
|
af3c6e1d8e | ||
|
|
1145f61c78 | ||
|
|
863ca8be03 | ||
|
|
21dfdb91a9 | ||
|
|
374bb11279 | ||
|
|
3e2d7174fd | ||
|
|
a817a94eec | ||
|
|
210042814f | ||
|
|
14fbe0aa77 | ||
|
|
25c272ae8e | ||
|
|
a63bf9d5a3 | ||
|
|
3e9cb0265e | ||
|
|
4f339e89b1 | ||
|
|
9fac6849a3 | ||
|
|
342a9d774c | ||
|
|
6a7ae8bc8e | ||
|
|
4b240920e2 | ||
|
|
a7a4580944 | ||
|
|
3f20f4a1f4 | ||
|
|
5583e91392 | ||
|
|
89b68723cc | ||
|
|
5781879da5 | ||
|
|
0a27b2923a | ||
|
|
437fd5feda | ||
|
|
54b42607ae | ||
|
|
ed865e9a57 | ||
|
|
f8cdb12892 | ||
|
|
ab1b9368a0 | ||
|
|
0a10b3ec2a | ||
|
|
eb05d1cb5c | ||
|
|
9a40b28172 | ||
|
|
65ea1b673f | ||
|
|
5dfe0043c3 | ||
|
|
bc150a8469 | ||
|
|
a5c9ffa880 | ||
|
|
fae9ac9382 | ||
|
|
924aaf9aef | ||
|
|
950be4426d | ||
|
|
053f39d49e | ||
|
|
994c20c094 | ||
|
|
1acb8e4a6f | ||
|
|
298924fef5 | ||
|
|
4ad9f5875a | ||
|
|
508889f74b | ||
|
|
b034055c35 | ||
|
|
263e893b30 | ||
|
|
6a82030558 | ||
|
|
018f653ec6 | ||
|
|
905308e09f | ||
|
|
3c04529523 | ||
|
|
2689d8d2c0 | ||
|
|
1c4bb84f35 | ||
|
|
6848f7a8b2 | ||
|
|
dda530a67c | ||
|
|
a23aaa131d | ||
|
|
53a268fb71 | ||
|
|
518768e8f8 | ||
|
|
44c738e2f4 | ||
|
|
f221856ae1 | ||
|
|
80f6137528 | ||
|
|
49b89ba7af | ||
|
|
018daacdbb | ||
|
|
4291f6005d | ||
|
|
c913935d90 | ||
|
|
8d96477fae | ||
|
|
b18f96da77 | ||
|
|
b5e5ee0168 | ||
|
|
0d94c9e092 | ||
|
|
cdae4f9f10 | ||
|
|
550847f763 | ||
|
|
6bda27f554 | ||
|
|
f632a8db91 | ||
|
|
8bbc183647 | ||
|
|
ce13a9c7ca | ||
|
|
2afa48cf11 | ||
|
|
bb2326525e | ||
|
|
f2fbeff511 | ||
|
|
055511fe60 | ||
|
|
d748b9f691 | ||
|
|
75cb513dd9 | ||
|
|
bd147abb3f | ||
|
|
5633d7c761 | ||
|
|
af3a76c354 | ||
|
|
59f989d26f | ||
|
|
5285556415 | ||
|
|
bbef27b8c9 | ||
|
|
38ce1f90fb | ||
|
|
98c5cae9f4 | ||
|
|
14cd1b9f0b | ||
|
|
0a626537d5 | ||
|
|
c25dd33d28 | ||
|
|
ad1bda07e9 | ||
|
|
27f75641cc | ||
|
|
0344cfacce | ||
|
|
83d572cde2 | ||
|
|
03bad04436 | ||
|
|
a44da11f3f | ||
|
|
009fa2ff02 | ||
|
|
2715157793 | ||
|
|
410566f5e1 | ||
|
|
b6412b5b8a | ||
|
|
7b99d1a1b8 | ||
|
|
2cb3ae7046 | ||
|
|
18d0fd78cf | ||
|
|
f223d7504e | ||
|
|
ed23d5dd9d | ||
|
|
2f700b377f | ||
|
|
3c6d90a4db | ||
|
|
c592ff35f6 | ||
|
|
2265bfc921 | ||
|
|
17c1145ee4 | ||
|
|
68cc0ae16c | ||
|
|
1269e188be | ||
|
|
f69d141336 | ||
|
|
13246cb6d1 | ||
|
|
dcbee43b88 | ||
|
|
45cac75665 | ||
|
|
75be3a83f9 | ||
|
|
f3d5081d38 | ||
|
|
e5670d1732 | ||
|
|
2b0c4692ba | ||
|
|
5015c5ca3f | ||
|
|
8898a9817c | ||
|
|
b79f09c550 | ||
|
|
52a3f8b85a | ||
|
|
152c07a28e | ||
|
|
0fd3d4b8f4 | ||
|
|
83376627e8 | ||
|
|
e22e671f10 | ||
|
|
9e7ecfa279 | ||
|
|
0e28df0bd6 | ||
|
|
d6a96e9c29 | ||
|
|
23619010c3 | ||
|
|
77ffa0712e | ||
|
|
d6c606b72e | ||
|
|
bc7a712048 | ||
|
|
f063708b33 | ||
|
|
4e96847032 | ||
|
|
99d004d1f0 | ||
|
|
df6b42f5d6 | ||
|
|
101f446f8d | ||
|
|
2ba4944f5c | ||
|
|
76523d4f32 | ||
|
|
d0577b94af | ||
|
|
f7585eef1e | ||
|
|
030eea1898 | ||
|
|
51758a0be9 | ||
|
|
271e7a1e76 | ||
|
|
4d100d4507 | ||
|
|
889b582538 | ||
|
|
18da8911a0 | ||
|
|
8d762e59ca | ||
|
|
efdae22fbe | ||
|
|
b06b08ce33 | ||
|
|
bc5b01da0f | ||
|
|
ce819383af | ||
|
|
2992d17c1d | ||
|
|
c34a68304e | ||
|
|
5fc76edf2d | ||
|
|
e21997799c | ||
|
|
730410c5e1 | ||
|
|
e55595cc55 | ||
|
|
c1bf491a96 | ||
|
|
9026e0ce41 | ||
|
|
7ec9bb92f7 | ||
|
|
888f730526 | ||
|
|
a7d641ea6b | ||
|
|
f49507f18a | ||
|
|
5947306ca0 | ||
|
|
67d7b8160f | ||
|
|
630b59d4a7 | ||
|
|
55a7967001 | ||
|
|
61ca75e846 | ||
|
|
1ee76826c7 | ||
|
|
f42f5151a6 | ||
|
|
6cccc41c0f | ||
|
|
b255e32548 | ||
|
|
a17fff83cf | ||
|
|
40fbc42b13 | ||
|
|
cd821a0344 | ||
|
|
e495c571c3 | ||
|
|
05fcdaf93c | ||
|
|
666e6d9849 | ||
|
|
546e272a60 | ||
|
|
3c191c3f93 | ||
|
|
565a3f148d | ||
|
|
d31b7dc797 | ||
|
|
735eb11eb7 | ||
|
|
a2047b90ae | ||
|
|
9b12b729c9 | ||
|
|
8742f9094e | ||
|
|
8b59755157 | ||
|
|
cbcd3f4f8f | ||
|
|
5dc40158c4 | ||
|
|
55ef1448e8 | ||
|
|
d22300e3db | ||
|
|
03a75ec566 | ||
|
|
58e4c2489b | ||
|
|
7e7ed74b92 | ||
|
|
95ade476e8 | ||
|
|
ea434fc46f | ||
|
|
7d4d360e52 | ||
|
|
e5dfd6e8fa | ||
|
|
64bd908c25 | ||
|
|
a3df1a9f4a | ||
|
|
5c0b8c6296 | ||
|
|
136093d583 | ||
|
|
98c2a1359b | ||
|
|
5dcfcb5467 | ||
|
|
b3c26e7bc7 | ||
|
|
bff5745139 | ||
|
|
cfacdb15a8 | ||
|
|
2d2a7c2108 | ||
|
|
61b330b9b0 | ||
|
|
14021db239 | ||
|
|
0398249a20 | ||
|
|
e2a12ba9bb | ||
|
|
d457988810 | ||
|
|
1f94c71c22 | ||
|
|
b466d952f9 | ||
|
|
bcacb75296 | ||
|
|
3b341a7191 | ||
|
|
6193ae4997 | ||
|
|
4efabf5231 | ||
|
|
ffa95b5a55 | ||
|
|
0d9f24115c | ||
|
|
de8033e551 | ||
|
|
4117050081 | ||
|
|
7c56041b11 | ||
|
|
308ee64f30 | ||
|
|
fa0164fdf6 | ||
|
|
c2a24ae0c6 | ||
|
|
33ce95ba09 | ||
|
|
708a5af039 | ||
|
|
033727632c | ||
|
|
d4ec976aaa | ||
|
|
03d7a107bf | ||
|
|
c8f98917c5 | ||
|
|
8fac40b6ae | ||
|
|
54a75ffd7e | ||
|
|
8c0480b0bc | ||
|
|
a398f85ccc | ||
|
|
57938b487b | ||
|
|
40c2e4c164 | ||
|
|
adf7010f50 | ||
|
|
91e17e64a5 | ||
|
|
26605049a4 | ||
|
|
7b53209ae3 | ||
|
|
674f14f9bd | ||
|
|
e07ebf85e8 | ||
|
|
5ec66c5b0e | ||
|
|
bea1836386 | ||
|
|
752b9e4ab3 | ||
|
|
d196cbc8f6 | ||
|
|
96b092f0eb | ||
|
|
785bf5b5bc | ||
|
|
b249df9036 | ||
|
|
d99b84ba09 | ||
|
|
db7a485796 | ||
|
|
8180be83b8 | ||
|
|
1351c8f724 | ||
|
|
c4731d52e2 | ||
|
|
de006f8f91 | ||
|
|
040dbd3a95 | ||
|
|
8f09a4b1bc | ||
|
|
e4bf65c54a | ||
|
|
38c4ecac8c | ||
|
|
8a733270d7 | ||
|
|
86cbe64ae8 | ||
|
|
1e8d89f8f2 | ||
|
|
9c2f57df39 | ||
|
|
9bc83fdd02 | ||
|
|
bbfd2e5ac5 | ||
|
|
f28bd52d79 | ||
|
|
63f1f38f7c | ||
|
|
3ba4cf4483 | ||
|
|
a862f2b2a4 | ||
|
|
3bf72829c2 | ||
|
|
7cd9a0032a | ||
|
|
9089fc98f2 | ||
|
|
8beeb57c96 | ||
|
|
234f25b782 | ||
|
|
28ddb60b2f | ||
|
|
1a93068810 | ||
|
|
d8231a9a3b | ||
|
|
ffdcc24a40 | ||
|
|
023a73cab8 | ||
|
|
144b19c449 | ||
|
|
b3e9719602 | ||
|
|
bdd69160e2 | ||
|
|
bddf66d62a | ||
|
|
7b77bf4bdd | ||
|
|
cc890fe391 | ||
|
|
62e944796b | ||
|
|
1547a864c2 | ||
|
|
05323ae643 | ||
|
|
87efcc0e99 | ||
|
|
bf7d7342d5 | ||
|
|
1e4381ab7f | ||
|
|
8b0adcdfcc | ||
|
|
0043308b52 | ||
|
|
8a62c9f027 | ||
|
|
ded35c7252 | ||
|
|
46876e2fb1 | ||
|
|
7bc58bba2b | ||
|
|
5c70cc5bcd | ||
|
|
281154f591 | ||
|
|
c7f2032dd7 | ||
|
|
fb1f0c6469 | ||
|
|
d2bae0c4f6 | ||
|
|
907cc634f5 | ||
|
|
349c413d8b | ||
|
|
c630be33df | ||
|
|
d30087ca63 | ||
|
|
ac13b31e13 | ||
|
|
5340a5f951 | ||
|
|
e770db1e2c | ||
|
|
1c9b9f6a08 | ||
|
|
3afc03b95b | ||
|
|
a927d733c2 | ||
|
|
3dd50e5676 | ||
|
|
588ddc103a | ||
|
|
9bdcf44564 | ||
|
|
8b8a800453 | ||
|
|
d48647a655 | ||
|
|
8d725b1ad9 | ||
|
|
69c639b14d | ||
|
|
431c8bdabc | ||
|
|
2832393f40 | ||
|
|
8b329630f8 | ||
|
|
30f3ce49e4 | ||
|
|
ddbfc458d7 | ||
|
|
124254d5ed | ||
|
|
677433bbf5 | ||
|
|
e1ff800980 | ||
|
|
2d39cb3b30 | ||
|
|
18177b108f | ||
|
|
be54d583f1 | ||
|
|
4c1ecf9e52 | ||
|
|
b172ff7f15 | ||
|
|
327d005a1f | ||
|
|
0d2cb14796 | ||
|
|
97738e5c2d | ||
|
|
dfaf0d0603 | ||
|
|
0b1bdca022 | ||
|
|
6370025caf | ||
|
|
53a6d01fa3 | ||
|
|
29947e25c3 | ||
|
|
f3015cb9df | ||
|
|
ec79995be8 | ||
|
|
517e0f3826 | ||
|
|
5812e00e01 | ||
|
|
1ed68261d4 | ||
|
|
9c09ee6329 | ||
|
|
c9b6044c4f | ||
|
|
fa89a5a375 | ||
|
|
f8cef71eed | ||
|
|
bb3032ea2f | ||
|
|
5e471349e7 | ||
|
|
0aee4b89e0 | ||
|
|
d2171b9a2f | ||
|
|
a593e95ab3 | ||
|
|
575f71aaee | ||
|
|
04aa6f8fe9 | ||
|
|
298ded6e2d | ||
|
|
9bcafeb7c1 | ||
|
|
85c1b6957e | ||
|
|
2f99b317a2 | ||
|
|
8535664e9c | ||
|
|
5ed9f5126b | ||
|
|
5713aa8792 | ||
|
|
9aaaad6709 | ||
|
|
621b88bff4 | ||
|
|
ecedf666c7 | ||
|
|
afff2ceec7 | ||
|
|
be9a415a4a | ||
|
|
0295041972 | ||
|
|
8fe7ee75e8 | ||
|
|
a3d96aae0a | ||
|
|
d5d56d89d1 | ||
|
|
a6a2841961 | ||
|
|
7bf76fa557 | ||
|
|
c2ededfc62 | ||
|
|
ee62dd355f | ||
|
|
0415c30f0b | ||
|
|
79e534e397 | ||
|
|
f4919340e9 | ||
|
|
976f9d9ff8 | ||
|
|
9a540a4f0a | ||
|
|
5968da18b4 | ||
|
|
bd5f816d58 | ||
|
|
cf8f1a710d | ||
|
|
2d0b646956 | ||
|
|
b284e6f820 | ||
|
|
5ea9ac8a9e | ||
|
|
89d748b007 | ||
|
|
68ad4b1228 | ||
|
|
a52e86a69b | ||
|
|
a5d343aaa4 | ||
|
|
04c83993d2 | ||
|
|
54d144bb75 | ||
|
|
bf3bf58d6e | ||
|
|
611c8b98b1 | ||
|
|
d08506ae12 | ||
|
|
238b7420f7 | ||
|
|
cadf710bf8 | ||
|
|
9c8c28e1cb | ||
|
|
84900bad0e | ||
|
|
4c3d783fc8 | ||
|
|
461084eca1 | ||
|
|
341d3015f0 | ||
|
|
6a95d956a9 | ||
|
|
7599af451f | ||
|
|
823d819eee | ||
|
|
a16c2e2747 | ||
|
|
e50fd8b881 | ||
|
|
3e61bb807d | ||
|
|
345e2c344b | ||
|
|
3f0567c59f | ||
|
|
934f61f2a1 | ||
|
|
60a23b681c | ||
|
|
e581dec8f5 | ||
|
|
711f3b7048 | ||
|
|
1467357db5 | ||
|
|
de6721b682 | ||
|
|
296352b620 | ||
|
|
3f2811d9da | ||
|
|
700c871e6d | ||
|
|
f865187a5d | ||
|
|
ad290b3630 | ||
|
|
21218f1c59 | ||
|
|
f41f47cf1b | ||
|
|
d94508d534 | ||
|
|
44cb276cdf | ||
|
|
e64f4f06fb | ||
|
|
32feb36172 | ||
|
|
c983e878e9 | ||
|
|
077541c762 | ||
|
|
3d15db7c5e | ||
|
|
109c9e70ec | ||
|
|
197ea9214a | ||
|
|
81e57afe29 | ||
|
|
3660a6cc27 | ||
|
|
ab2c423006 | ||
|
|
922c375333 | ||
|
|
ec4da63408 | ||
|
|
20317fd08f | ||
|
|
0f520f2fd4 | ||
|
|
84c815cc04 | ||
|
|
77de22d1ed | ||
|
|
c35f94fe6d | ||
|
|
78a43402cb | ||
|
|
b0562a11fd | ||
|
|
b309367bb0 | ||
|
|
b359a610da | ||
|
|
0f36b49840 | ||
|
|
28c4d28308 | ||
|
|
b8c05763fe | ||
|
|
613f1a911f | ||
|
|
bbb521537b | ||
|
|
79a503e436 | ||
|
|
69e69f4dae | ||
|
|
fcaa30d7cc | ||
|
|
03baa1ac0b | ||
|
|
906d65c06f | ||
|
|
57b1c3cbb4 | ||
|
|
40c533a45b | ||
|
|
9eb8185e82 | ||
|
|
6a86883c2d | ||
|
|
d16a69e158 | ||
|
|
3d6bb88556 | ||
|
|
e199b14259 | ||
|
|
9c05aa6766 | ||
|
|
3833bada54 | ||
|
|
3d98797d8c | ||
|
|
777214cc14 | ||
|
|
9f0f60b5eb | ||
|
|
ca20add577 | ||
|
|
98b07b1284 | ||
|
|
941c297363 | ||
|
|
722a91f1d2 | ||
|
|
acdc810f82 | ||
|
|
8afb22a017 | ||
|
|
5effe4bbbb | ||
|
|
df1109e920 | ||
|
|
2df93d6d94 | ||
|
|
d5deb1579f | ||
|
|
334b8bb8da | ||
|
|
6aeefb0fe6 | ||
|
|
eac514e04d | ||
|
|
afeddad343 | ||
|
|
0f17b84dc1 | ||
|
|
3edb87d125 | ||
|
|
9f9393a486 | ||
|
|
1d6bb032a3 | ||
|
|
3152de4b7f | ||
|
|
ca9a6796d8 | ||
|
|
a6142bdf93 | ||
|
|
ffd7f0db49 | ||
|
|
c6252825f3 | ||
|
|
4b96b3a635 | ||
|
|
862d8feb5e | ||
|
|
1ef77d1596 | ||
|
|
3dc45d6330 | ||
|
|
a8c2e408d1 | ||
|
|
e941c1a496 | ||
|
|
a657eb3152 | ||
|
|
ef7c26e799 | ||
|
|
49af278de7 | ||
|
|
2357d0c1b8 | ||
|
|
63af79d3f8 | ||
|
|
fcc05f6a00 | ||
|
|
de3407d875 | ||
|
|
2d4e827bea | ||
|
|
1fa4838863 | ||
|
|
ab4c74a321 | ||
|
|
82d65addec | ||
|
|
e2ad28a510 | ||
|
|
efa1709158 | ||
|
|
d6040f687a | ||
|
|
b9a1e30652 | ||
|
|
7adac5d004 | ||
|
|
2354731083 | ||
|
|
d522cc8f91 | ||
|
|
86eb01eb2c | ||
|
|
8a94b1e086 | ||
|
|
846b8d15bd | ||
|
|
c12cb2424a | ||
|
|
1471d6b214 | ||
|
|
98b63fa9ea | ||
|
|
0aa0f641ab | ||
|
|
77a4495b28 | ||
|
|
b521a17ffd | ||
|
|
86bf64e793 | ||
|
|
8a72010768 | ||
|
|
a08e19bbd0 | ||
|
|
9f10d8095a | ||
|
|
ea187f48e0 | ||
|
|
9c6273eb94 | ||
|
|
1f735abe2b | ||
|
|
b878b0f93c | ||
|
|
ad0a3c939c | ||
|
|
d581a0d460 | ||
|
|
9044b8d85d | ||
|
|
73852dd6f4 | ||
|
|
5e02408732 | ||
|
|
34e5a91dc6 | ||
|
|
7275d9d4dc | ||
|
|
c9a8e3ea42 | ||
|
|
71505abe4c | ||
|
|
c9a56fa73a | ||
|
|
3167e38993 | ||
|
|
ce8326ddea | ||
|
|
5d4b11d287 | ||
|
|
096d477893 | ||
|
|
153f240c43 | ||
|
|
df46db965a | ||
|
|
7ef5203564 | ||
|
|
9420abfacc | ||
|
|
16f93397ae | ||
|
|
016a4a422a | ||
|
|
996b06cecc | ||
|
|
571b7387e7 | ||
|
|
b6d468376a | ||
|
|
d8fc7e40a1 | ||
|
|
e875ed739b | ||
|
|
aa5ce4a2f1 | ||
|
|
80969012c9 | ||
|
|
caaee7e9bf | ||
|
|
f96fd36ecb | ||
|
|
495dc55144 | ||
|
|
8795f0f32c | ||
|
|
a0f54b4871 | ||
|
|
68dfbcc2eb | ||
|
|
82493d1019 | ||
|
|
3d6df0c044 | ||
|
|
2583016693 | ||
|
|
d8e3721846 | ||
|
|
a876740a3c | ||
|
|
e7ad6f5d1c | ||
|
|
2f1facaafb | ||
|
|
ffd6273419 | ||
|
|
2bae696986 | ||
|
|
6b33315cbe | ||
|
|
57c4e9ff33 | ||
|
|
4e67c645f8 | ||
|
|
8b28d51dfa | ||
|
|
40ad283107 | ||
|
|
0f8d7a55de | ||
|
|
b6f946ec8a | ||
|
|
dbb3a309a9 | ||
|
|
2e9525741f | ||
|
|
585a5808b9 | ||
|
|
6f464f4f92 | ||
|
|
08ea7d5137 | ||
|
|
ad9be3cdd7 | ||
|
|
f737cc4ee4 | ||
|
|
1103272b95 | ||
|
|
2df7161cd8 | ||
|
|
d31c05c970 | ||
|
|
07db2eb3ee | ||
|
|
a6d2a2e119 | ||
|
|
f87e4bfbd3 | ||
|
|
ecaa32cfb2 | ||
|
|
10693b08cc | ||
|
|
b02361a6bf | ||
|
|
3fa888c6d4 | ||
|
|
a42c9aca1c | ||
|
|
394da40bea | ||
|
|
5d60505407 | ||
|
|
f876a0091a | ||
|
|
528eb5d172 | ||
|
|
b90447c387 | ||
|
|
42e25a1a1f | ||
|
|
eceab377f7 | ||
|
|
b0daeb3a9b | ||
|
|
7499fca251 | ||
|
|
05a5a945bb | ||
|
|
d3c96087b2 | ||
|
|
cfcf16aa69 | ||
|
|
bc59074300 | ||
|
|
56f9bc543e | ||
|
|
f2eb2cad82 | ||
|
|
bd523280a4 | ||
|
|
fc45fb3899 | ||
|
|
f0f7200f0b | ||
|
|
6af7708d62 | ||
|
|
a297ea30e2 | ||
|
|
0ef6c36f4c | ||
|
|
aaa60bc303 | ||
|
|
7d40d89fd8 | ||
|
|
46ad346df1 | ||
|
|
83154577e4 | ||
|
|
a847951658 | ||
|
|
24af6efbcb | ||
|
|
126cac833a | ||
|
|
fc272dc42e | ||
|
|
d3a0d66139 | ||
|
|
9604231a2a | ||
|
|
6ea3b7f3cf | ||
|
|
0d0a32f272 | ||
|
|
48e0ea542b | ||
|
|
95fd20b405 | ||
|
|
5c6cc597c8 | ||
|
|
3bfeadc2b9 | ||
|
|
296ed752fa | ||
|
|
1e66ec2b89 | ||
|
|
4a50f0ed46 | ||
|
|
af0d1f6fb2 | ||
|
|
a83ec60308 | ||
|
|
76c0cbf16b | ||
|
|
455d987398 | ||
|
|
00b9a8fa72 | ||
|
|
28f5263c88 | ||
|
|
933fac5fff | ||
|
|
0c26f227fe | ||
|
|
2b90c747f5 | ||
|
|
43a4e087ef | ||
|
|
f2612707bb | ||
|
|
138d30909a | ||
|
|
2b5094665f | ||
|
|
a81258bc0d | ||
|
|
141f802824 | ||
|
|
3475f3f12e | ||
|
|
dff2526077 | ||
|
|
2b4aa92f67 | ||
|
|
3151306d7e | ||
|
|
ef80c8df2a | ||
|
|
1a5f49239a | ||
|
|
dfc04d8de5 |
+15
-5
@@ -1,15 +1,25 @@
|
||||
[bumpversion]
|
||||
current_version = 0.0.3
|
||||
current_version = 1.1.2
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<prerel>rc)\.(?P<prerelversion>\d+))?
|
||||
serialize =
|
||||
{major}.{minor}.{patch}-{prerel}.{prerelversion}
|
||||
{major}.{minor}.{patch}
|
||||
|
||||
[bumpversion:part:prerel]
|
||||
optional_value = release
|
||||
values =
|
||||
rc
|
||||
release
|
||||
|
||||
[bumpversion:file:setup.py]
|
||||
search = version="{current_version}"
|
||||
replace = version="{new_version}"
|
||||
|
||||
[bumpversion:file:server/cli/cli.py]
|
||||
search = version="{current_version}"
|
||||
replace = version="{new_version}"
|
||||
|
||||
[bumpversion:file:client/package.json]
|
||||
search = "version": "{current_version}"
|
||||
replace = "version": "{new_version}"
|
||||
|
||||
[bumpversion:file:server/__init__.py]
|
||||
search = __version__ = "{current_version}"
|
||||
replace = __version__ = "{new_version}"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
informational: true
|
||||
@@ -0,0 +1,5 @@
|
||||
bin
|
||||
client
|
||||
dist
|
||||
docs
|
||||
server
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: "\U0001F41E Bug report"
|
||||
about: Create a report to identify a bug in cellxgene
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Version (please complete the following information):**
|
||||
- Desktop or hosted?:
|
||||
- Browser (if hosted) [e.g. chrome, safari]:
|
||||
- Version [e.g. 0.13.0]:
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "\U0001F4A1 Feature request"
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE REQUEST]"
|
||||
labels: user request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: "\U0001F9D0 Question/Clarification"
|
||||
about: Ask a question or for a clarification
|
||||
title: "[QUESTION]"
|
||||
labels: question
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Tech Issue
|
||||
about: Engineering-specific technical work that is not product-specific. Engineering team "owns" these issues.
|
||||
title: ""
|
||||
labels: tech
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Motivation
|
||||
|
||||
Why is this work important to engineers?
|
||||
|
||||
## Definition of Done
|
||||
|
||||
What should the end result look like? What will have been changed?
|
||||
|
||||
## Tasks
|
||||
|
||||
Detail the specific tasks that can be used to accomplish the desired changes.
|
||||
If detailed steps cannot be provided at this time, please file a [Tech Proposal](https://docs.google.com/document/d/1o2vuvl-kXwRJN1nBoPzJS_MAQgDGYnjmPZWa4qRDi-I/edit#heading=h.7dvzhm7gqc3v) instead.
|
||||
|
||||
- [ ]
|
||||
- [ ]
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Close inactive pull requests
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
days-before-issue-stale: -1 # Do not mark any issues as stale
|
||||
days-before-pr-stale: 14
|
||||
days-before-pr-close: 3
|
||||
stale-pr-message: "This PR has not seen any activity in the past 2 weeks; if no one comments or reviews it in the next 3 days, this PR will be closed."
|
||||
close-pr-message: "This PR was closed because it has been inactive for 17 days, 3 days since being marked as stale. Please re-open if you still need this to be addressed."
|
||||
stale-pr-label: "stale"
|
||||
close-pr-label: "autoclosed"
|
||||
exempt-draft-pr: true
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,67 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL Scan"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript', 'python' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Compatibility Tests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 7 * 2'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
JEST_ENV: prod
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Build docker image
|
||||
run: docker build .
|
||||
|
||||
matrix-compatibility-test:
|
||||
name: cxg:${{ matrix.cellxgene_build }} os:${{ matrix.os }} py:${{ matrix.python-version }} anndata:${{ matrix.anndata_version || 'latest' }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# note: The `macos-latest` is latest Catalina version, and not Big Sur. So we explicitly ask for Big Sur (`macos-11`)
|
||||
os: [ubuntu-latest, macos-latest, macos-11]
|
||||
python-version: [3.6, 3.7, 3.8, 3.9]
|
||||
cellxgene_build: [main, latest]
|
||||
exclude:
|
||||
# 3.6 no longer avail on Big Sur (`macos-11`)
|
||||
- os: macos-11
|
||||
python-version: 3.6
|
||||
# no pypi build exists for macos+py3.9 and source install fails to
|
||||
# install `tables` py pkg (a `scanpy` dependency), so we test py3.9
|
||||
# only on ubuntu
|
||||
- os: macos-11
|
||||
python-version: 3.9
|
||||
- os: macos-latest
|
||||
python-version: 3.9
|
||||
# add anndata pinned version test for subset of matrix configurations,
|
||||
# in order to reduce matrix cross-product explosion
|
||||
include:
|
||||
- python-version: 3.8
|
||||
cellxgene_build: latest
|
||||
# TODO: dynamically use the literal version in requirements.txt,
|
||||
# to avoid having to update this in manually in the future
|
||||
# TODO: Do not bother running this if anndata latest version
|
||||
# matches this pinned version, to avoid a redundant test
|
||||
anndata_version: '==0.7.6'
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Cache env vars
|
||||
run: echo "PIP_CACHE=`python -m pip cache dir`" >> $GITHUB_ENV
|
||||
- name: Cache env vars (MacOS)
|
||||
if: startsWith(matrix.os, 'macos')
|
||||
run: echo "BREW_CACHE=`brew --cache`" >> $GITHUB_ENV
|
||||
# FIXME: Only working for Linux
|
||||
- name: Python cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ env.PIP_CACHE }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Node cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
- name: Brew cache (MacOS)
|
||||
if: startsWith(matrix.os, 'macos')
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ env.BREW_CACHE }}
|
||||
key: ${{ runner.os }}-brew-
|
||||
- name: Install dependencies (Ubuntu Linux)
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libhdf5-serial-dev
|
||||
- name: Install dependencies (MacOS)
|
||||
if: startsWith(matrix.os, 'macos')
|
||||
run: brew install hdf5
|
||||
- name: Install cellxgene from `main` branch
|
||||
if: matrix.cellxgene_build == 'main'
|
||||
run: |
|
||||
pip install -r server/requirements-dev.txt
|
||||
make pydist install-dist
|
||||
- name: Install cellxgene from latest release (pypi.org)
|
||||
if: matrix.cellxgene_build == 'latest'
|
||||
run: |
|
||||
pip install --upgrade cellxgene
|
||||
# install the additional dev requirements on top of what is in the
|
||||
# cellxgene pip package, which are needed for testing, but otherwise
|
||||
# keep same pip pkg versions as in the cxg release
|
||||
sed -i'' -e 's/-r requirements.txt//' server/requirements-dev.txt
|
||||
pip install -r server/requirements-dev.txt
|
||||
- name: Install anndata version per matrix variable
|
||||
run: pip install anndata${{ matrix.anndata_version }}
|
||||
- name: Install node
|
||||
run: make dev-env-client
|
||||
# Run different types of test separately, to facilitate troubleshooting
|
||||
- name: Unit Tests - client
|
||||
run: make unit-test-client
|
||||
- name: Unit Tests - server
|
||||
run: make unit-test-server
|
||||
- name: Smoke Tests
|
||||
run: make smoke-test
|
||||
# FIXME: Fails intermittently. See https://app.zenhub.com/workspaces/single-cell-5e2a191dad828d52cc78b028/issues/chanzuckerberg/cellxgene/2415
|
||||
# - name: Smoke Tests with Annotations
|
||||
# run: make smoke-test-annotations
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
name: "Lint PR commit message"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v3.4.1
|
||||
with:
|
||||
validateSingleCommit: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,136 @@
|
||||
name: Push Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: main
|
||||
pull_request:
|
||||
branches: "*"
|
||||
|
||||
env:
|
||||
JEST_ENV: prod
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: |
|
||||
git fetch --depth=1 origin +${{github.base_ref}}
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Node cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install flake8
|
||||
pip install black
|
||||
cd client
|
||||
npm install
|
||||
- name: Format with black and lint with flake8
|
||||
run: |
|
||||
make lint-server
|
||||
- name: Lint src with eslint
|
||||
working-directory: ./client
|
||||
run: |
|
||||
npx eslint src __tests__
|
||||
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.7 (pyenv) # pyenv needed for mlflow in cli annotate tests
|
||||
uses: gabrielfalcao/pyenv-action@v9
|
||||
with:
|
||||
default: 3.7
|
||||
command: pip install -U pip # upgrade pip after installing python
|
||||
- run: pip install virtualenv # virtualenv needed for mlflow in cli annotate tests
|
||||
- name: Python cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Node cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
- name: Install dependencies
|
||||
run: make pydist install-dist dev-env-server
|
||||
- name: Unit tests
|
||||
run: |
|
||||
make unit-test-server unit-test-client
|
||||
bash <(curl -s https://codecov.io/bash) -y .codecov.yml -k server -cF server,python,unitTest
|
||||
cd client && ./node_modules/codecov/bin/codecov --yml=../.codecov.yml --root=../ --gcov-root=../ -C -F frontend,javascript,unitTest
|
||||
|
||||
smoke-tests:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Python cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Node cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
- name: Install dependencies
|
||||
run: make pydist install-dist
|
||||
- name: Smoke tests (without annotations feature)
|
||||
run: |
|
||||
cd client && make smoke-test
|
||||
./node_modules/codecov/bin/codecov --yml=../.codecov.yml --root=../ --gcov-root=../ -C -F frontend,javascript,smokeTest
|
||||
|
||||
# TODO: reinstate: https://github.com/chanzuckerberg/cellxgene/issues/2544
|
||||
# smoke-tests-annotations:
|
||||
# runs-on: ubuntu-latest
|
||||
# timeout-minutes: 20
|
||||
# steps:
|
||||
# - uses: actions/checkout@v2
|
||||
# - name: Set up Python 3.7
|
||||
# uses: actions/setup-python@v4
|
||||
# with:
|
||||
# python-version: 3.7
|
||||
# - name: Python cache
|
||||
# uses: actions/cache@v1
|
||||
# with:
|
||||
# path: ~/.cache/pip
|
||||
# key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-pip-
|
||||
# - name: Node cache
|
||||
# uses: actions/cache@v1
|
||||
# with:
|
||||
# path: ~/.npm
|
||||
# key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-node-
|
||||
# - name: Install dependencies
|
||||
# run: make pydist install-dist
|
||||
# - name: Smoke tests (with annotations feature)
|
||||
# run: |
|
||||
# cd client && make smoke-test-annotations
|
||||
# ./node_modules/codecov/bin/codecov --yml=../.codecov.yml --root=../ --gcov-root=../ -C -F frontend,javascript,smokeTestAnnotations
|
||||
+28
-6
@@ -3,6 +3,8 @@ node_modules/
|
||||
|
||||
# coverage
|
||||
coverage
|
||||
coverage.xml
|
||||
.coverage
|
||||
.nyc_output
|
||||
|
||||
.pytest_cache/
|
||||
@@ -13,20 +15,26 @@ dist/
|
||||
*.egg-info
|
||||
|
||||
# Environments
|
||||
venv/
|
||||
venv*/
|
||||
cellxgene/
|
||||
|
||||
# client build
|
||||
server/app/web/static/css/
|
||||
server/app/web/static/img/
|
||||
server/app/web/static/media/
|
||||
server/app/web/static/js/
|
||||
server/app/web/templates/index\.html
|
||||
server/common/web/static/*
|
||||
server/common/web/templates/
|
||||
server/common/web/csp-hashes.json
|
||||
|
||||
# eb build
|
||||
artifact.dir
|
||||
artifact.zip
|
||||
customize
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
*.ipynb
|
||||
|
||||
# cefpython
|
||||
error.log
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
npm-debug.log
|
||||
@@ -35,3 +43,17 @@ npm-debug.log
|
||||
__pycache__
|
||||
*.DS_Store*
|
||||
data
|
||||
tags
|
||||
myconfig.yaml
|
||||
|
||||
# Jekyll
|
||||
docs/_site/
|
||||
docs/Gemfile.lock
|
||||
|
||||
client/.eslintcache
|
||||
|
||||
# E2E Testing
|
||||
ignoreE2E*
|
||||
|
||||
# annotate subcmd
|
||||
.models_cache
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
language: python
|
||||
python:
|
||||
- "3.6"
|
||||
node_js:
|
||||
- "8"
|
||||
cache:
|
||||
pip: true
|
||||
install:
|
||||
- set -eo pipefail
|
||||
- pip install flake8
|
||||
- ./bin/build-client
|
||||
- pip install -e .
|
||||
- pip install -r server/requirements-dev.txt
|
||||
script:
|
||||
- set -eo pipefail
|
||||
- flake8 server/app/
|
||||
- flake8 server/cli/
|
||||
- npm run --prefix client/ build
|
||||
- npm run --prefix client/ test
|
||||
- pytest -s server/test
|
||||
@@ -0,0 +1,3 @@
|
||||
We warmly welcome contributions from the community!
|
||||
|
||||
Whether you want to contribute ideas, requests, documentation, or code, you can get started by visiting our [contribution guide](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/contribute.md).
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
FROM ubuntu:bionic
|
||||
|
||||
ENV LC_ALL=C.UTF-8
|
||||
ENV LANG=C.UTF-8
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y build-essential libxml2-dev python3-dev python3-pip zlib1g-dev python3-requests python3-aiohttp && \
|
||||
python3 -m pip install --upgrade pip && \
|
||||
pip3 install cellxgene
|
||||
|
||||
ENTRYPOINT ["cellxgene"]
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013
|
||||
Copyright (c) 2017-2023 Chan Zuckerberg Initiative
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
@@ -17,4 +17,4 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
+7
-2
@@ -1,3 +1,8 @@
|
||||
recursive-include server/app/web/templates *
|
||||
recursive-include server/app/web/static *
|
||||
recursive-include server/common/web/templates *
|
||||
recursive-include server/common/web/static *
|
||||
|
||||
include server/requirements.txt
|
||||
include server/requirements-prepare.txt
|
||||
include server/requirements-annotate.txt
|
||||
include server/converters/schema/hgnc_complete_set.txt.gz
|
||||
include server/converters/schema/schema_definitions/*
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
include common.mk
|
||||
|
||||
BUILDDIR := build
|
||||
CLIENTBUILD := $(BUILDDIR)/client
|
||||
SERVERBUILD := $(BUILDDIR)/server
|
||||
CLEANFILES := $(BUILDDIR)/ client/build build dist cellxgene.egg-info
|
||||
|
||||
PART ?= patch
|
||||
|
||||
# CLEANING
|
||||
.PHONY: clean
|
||||
clean: clean-lite clean-server clean-client
|
||||
|
||||
# cleaning the client's node_modules is the longest one, so we avoid that if possible
|
||||
.PHONY: clean-lite
|
||||
clean-lite:
|
||||
rm -rf $(CLEANFILES)
|
||||
|
||||
.PHONY: clean-client
|
||||
clean-client:
|
||||
cd client && $(MAKE) clean
|
||||
|
||||
.PHONY: clean-server
|
||||
clean-server:
|
||||
cd server && $(MAKE) clean
|
||||
|
||||
|
||||
# BUILDING PACKAGE
|
||||
|
||||
.PHONY: build-client
|
||||
build-client:
|
||||
cd client && $(MAKE) ci build
|
||||
|
||||
.PHONY: build
|
||||
build: clean build-client
|
||||
git ls-files server/ | cpio -pdm $(BUILDDIR)
|
||||
cp -r client/build/ $(CLIENTBUILD)
|
||||
$(call copy_client_assets,$(CLIENTBUILD),$(SERVERBUILD))
|
||||
cp MANIFEST.in README.md setup.cfg setup.py $(BUILDDIR)
|
||||
|
||||
# If you are actively developing in the server folder use this, dirties the source tree
|
||||
.PHONY: build-for-server-dev
|
||||
build-for-server-dev: clean-server build-client copy-client-assets
|
||||
|
||||
.PHONY: copy-client-assets
|
||||
copy-client-assets:
|
||||
$(call copy_client_assets,client/build,server)
|
||||
|
||||
|
||||
# TESTING
|
||||
.PHONY: test
|
||||
test: unit-test smoke-test
|
||||
|
||||
.PHONY: unit-test
|
||||
unit-test: unit-test-server unit-test-client
|
||||
|
||||
.PHONY: test-server
|
||||
test-server: unit-test-server smoke-test
|
||||
|
||||
.PHONY: unit-test-client
|
||||
unit-test-client:
|
||||
cd client && $(MAKE) unit-test
|
||||
|
||||
.PHONY: unit-test-server
|
||||
unit-test-server:
|
||||
PYTHONWARNINGS=ignore:ResourceWarning coverage run \
|
||||
--source=server \
|
||||
--omit=.coverage,venv \
|
||||
-m unittest discover \
|
||||
--start-directory test/unit \
|
||||
--verbose; test_result=$$?; \
|
||||
exit $$test_result \
|
||||
|
||||
.PHONY: smoke-test
|
||||
smoke-test:
|
||||
cd client && $(MAKE) smoke-test
|
||||
|
||||
.PHONY: smoke-test-annotations
|
||||
smoke-test-annotations:
|
||||
cd client && $(MAKE) smoke-test-annotations
|
||||
|
||||
# FORMATTING CODE
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: fmt-client fmt-py
|
||||
|
||||
.PHONY: fmt-client
|
||||
fmt-client:
|
||||
cd client && $(MAKE) fmt
|
||||
|
||||
.PHONY: fmt
|
||||
fmt-py:
|
||||
black .
|
||||
|
||||
.PHONY: lint
|
||||
lint: lint-server lint-client
|
||||
|
||||
|
||||
.PHONY: lint-server
|
||||
lint-server: fmt-py
|
||||
flake8 server --per-file-ignores='test/fixtures/dataset_config_outline.py:F821 test/fixtures/server_config_outline.py:F821 test/performance/scale_test_annotations.py:E501'
|
||||
|
||||
.PHONY: lint-client
|
||||
lint-client:
|
||||
cd client && $(MAKE) lint
|
||||
|
||||
# CREATING DISTRIBUTION RELEASE
|
||||
|
||||
.PHONY: pydist
|
||||
pydist: build
|
||||
cd $(BUILDDIR); python setup.py sdist -d ../dist
|
||||
@echo "done"
|
||||
|
||||
# RELEASE HELPERS
|
||||
|
||||
# Set PART=[major, minor, patch] as param to make bump.
|
||||
# This will create a release candidate. (i.e. 0.16.1 -> 0.16.2-rc.0 for a patch bump)
|
||||
.PHONY: bump-version
|
||||
bump-version:
|
||||
bumpversion --config-file .bumpversion.cfg $(PART)
|
||||
|
||||
# Create new version to commit to main
|
||||
.PHONY: create-release-candidate
|
||||
create-release-candidate: bump-version clean-lite gen-package-lock
|
||||
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
|
||||
|
||||
# Bump the release candidate version if needed (i.e. the previous release candidate had errors).
|
||||
.PHONY: recreate-release-candidate
|
||||
recreate-release-candidate: bump-release-candidate clean-lite gen-package-lock
|
||||
@echo "Version bumped part:$(PART) and client built. Ready to commit and push"
|
||||
|
||||
# Build dist and release to Test PyPI
|
||||
.PHONY: release-candidate-to-test-pypi
|
||||
release-candidate-to-test-pypi: pydist twine
|
||||
@echo "Dist built and uploaded to test.pypi.org"
|
||||
@echo "Test the install:"
|
||||
@echo " make install-release-test"
|
||||
|
||||
# Build final dist (gets rid of the rc tag) and release final candidate to TestPyPI
|
||||
.PHONY: release-final-to-test-pypi
|
||||
release-final-to-test-pypi: bump-release clean-lite gen-package-lock pydist twine
|
||||
@echo "Final release dist built and uploaded to test.pypi.org"
|
||||
@echo "Test the install:"
|
||||
@echo " make install-release-test"
|
||||
|
||||
.PHONY: release-final
|
||||
release-final: twine-prod
|
||||
@echo "Release uploaded to pypi.org"
|
||||
|
||||
# DANGER: releases directly to prod
|
||||
# use this if you accidentally burned a test release version number,
|
||||
.PHONY: release-directly-to-prod
|
||||
release-directly-to-prod: pydist twine-prod
|
||||
@echo "Dist built and uploaded to pypi.org"
|
||||
@echo "Test the install:"
|
||||
@echo " make install-release"
|
||||
|
||||
.PHONY: dev-env
|
||||
dev-env: dev-env-client dev-env-server
|
||||
|
||||
.PHONY: dev-env-client
|
||||
dev-env-client:
|
||||
cd client && $(MAKE) ci
|
||||
|
||||
.PHONY: dev-env-server
|
||||
dev-env-server:
|
||||
pip install -r server/requirements-dev.txt
|
||||
|
||||
# Increments the release candidate version (i.e. 0.16.2-rc.1 -> 0.16.2-rc.2)
|
||||
.PHONY: bump-release-candidate
|
||||
bump-release-candidate:
|
||||
bumpversion --config-file .bumpversion.cfg prerelversion --allow-dirty
|
||||
|
||||
# Finalizes the release candidate by removing the release candidate tag (i.e. 0.16.2-rc.2 -> 0.16.2).
|
||||
.PHONY: bump-release
|
||||
bump-release:
|
||||
bumpversion --config-file .bumpversion.cfg prerel --allow-dirty
|
||||
|
||||
.PHONY: twine
|
||||
twine:
|
||||
twine upload --repository-url https://test.pypi.org/legacy/ dist/*
|
||||
|
||||
.PHONY: twine-prod
|
||||
twine-prod:
|
||||
twine upload dist/*
|
||||
|
||||
# quicker than re-building client
|
||||
.PHONY: gen-package-lock
|
||||
gen-package-lock:
|
||||
cd client && $(MAKE) install
|
||||
|
||||
|
||||
# INSTALL
|
||||
|
||||
# install from source tree for development
|
||||
.PHONY: install-dev
|
||||
install-dev: uninstall
|
||||
pip install -e .
|
||||
|
||||
# install from test.pypi to test your release
|
||||
.PHONY: install-release-test
|
||||
install-release-test: uninstall
|
||||
pip install --no-cache-dir --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple cellxgene==$(VERSION)
|
||||
@echo "Installed cellxgene from test.pypi.org, now run and smoke test"
|
||||
|
||||
# install from pypi to test your release
|
||||
.PHONY: install-release
|
||||
install-release: uninstall
|
||||
pip install --no-cache-dir cellxgene
|
||||
@echo "Installed cellxgene from pypi.org"
|
||||
|
||||
# install from dist
|
||||
.PHONY: install-dist
|
||||
install-dist: uninstall
|
||||
pip install dist/cellxgene*.tar.gz
|
||||
|
||||
.PHONY: uninstall
|
||||
uninstall:
|
||||
pip uninstall -y cellxgene || :
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#### Reviewers
|
||||
**Functional:**
|
||||
|
||||
**Readability:**
|
||||
|
||||
---
|
||||
|
||||
## Changes
|
||||
- add
|
||||
- remove
|
||||
- modify
|
||||
@@ -1,111 +1,98 @@
|
||||
# cellxgene
|
||||
<img src="./docs/cellxgene-logo.png" width="300">
|
||||
|
||||
### An interactive, performant explorer for single cell transcriptomics data.
|
||||
_an interactive explorer for single-cell transcriptomics data_
|
||||
|
||||
<img align="right" width="350" height="218" src="./example-dataset/cellxgene-demo.gif" pad="50px">
|
||||
cellxgene is an open-source experiment in how to bring powerful tools from modern web development to visualize and explore large single-cell transcriptomics datasets.
|
||||
Started in the context of the Human Cell Atlas Consortium, cellxgene hopes to both enable scientists to explore their data and to equip developers with scalable, reusable patterns and frameworks for visualizing large scientific datasets.
|
||||
[](https://zenodo.org/badge/latestdoi/105615409) [](https://pypi.org/project/cellxgene/) [](https://pypistats.org/packages/cellxgene) [](https://github.com/chanzuckerberg/cellxgene/pulse)
|
||||
[](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Push+Tests%22)
|
||||
[](https://github.com/chanzuckerberg/cellxgene/actions?query=workflow%3A%22Compatibility+Tests%22)
|
||||

|
||||
|
||||
## Features
|
||||
CZ CELLxGENE Annotate (pronounced "cell-by-gene") is an interactive data explorer for single-cell datasets, such as those coming from the [Human Cell Atlas](https://humancellatlas.org). Leveraging modern web development techniques to enable fast visualizations of at least 1 million cells, we hope to enable biologists and computational researchers to explore their data.
|
||||
|
||||
- **Visualization at scale:** built with [WebGL](https://www.khronos.org/webgl/), [React](https://reactjs.org/) & [Redux](https://redux.js.org/) to handle visualization of at least 1 million cells.
|
||||
Whether you need to visualize one thousand cells or one million, CELLxGENE Annotate helps you gain insight into your single-cell data.
|
||||
|
||||
- **Interactive exploration:** select, cross-filter, and compare subsets of your data with performant indexing and data handling.
|
||||
<img src="https://github.com/chanzuckerberg/cellxgene/raw/main/docs/images/crossfilter.gif" width="350" height="200" hspace="30"><img src="https://github.com/chanzuckerberg/cellxgene/raw/main/docs/images/category-breakdown.gif" width="350" height="200" hspace="30">
|
||||
|
||||
- **Flexible API:** the cellxgene client-server model is designed to support a range of existing analysis packages for backend computational tasks (eg scanpy), integrated with client-side visualization via a [REST API](https://restfulapi.net/).
|
||||
# Getting started
|
||||
|
||||
## Getting Started
|
||||
### The comprehensive guide to CZ CELLxGENE Annotate
|
||||
|
||||
**Requirements**
|
||||
[The CZ CELLxGENE Annotate documentation is your one-stop-shop for information about CELLxGENE Annotate](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/README.md)! You may be particularly interested in:
|
||||
|
||||
- OS: OSX, Windows, Linux -- the developers are currently testing on OSX and Windows (via WSL using Ubuntu). It should work on other platforms but if you are using something different and need help, please let us know.
|
||||
- python 3.6
|
||||
- python3 tkinter
|
||||
- npm
|
||||
- Google Chrome
|
||||
- Seeing [what Annotate can do](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/explore-data/explorer-tutorials.md)
|
||||
- Learning more about Annotate [installation](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/desktop/install.md) and [usage](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/desktop/quick-start.md#quick-start-1)
|
||||
- [Preparing your own data](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/desktop/data-reqs.md) for use in Annotate
|
||||
- Checking out [our roadmap](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/roadmap.md) for future development
|
||||
- [Contributing](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/contribute.md) to Annotate
|
||||
|
||||
**Clone project**
|
||||
### Quick start
|
||||
|
||||
git clone https://github.com/chanzuckerberg/cellxgene.git
|
||||
To install CELLxGENE Annotate you need Python 3.6+. We recommend [installing Annotate into a conda or virtual environment.](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/desktop/install.md)
|
||||
|
||||
**Install client**
|
||||
Install the package.
|
||||
|
||||
cd cellxgene
|
||||
./bin/build-client
|
||||
```bash
|
||||
pip install cellxgene
|
||||
```
|
||||
|
||||
**To use with virtual env for python**
|
||||
(optional, but recommended)
|
||||
Launch Annotate with an example [anndata](https://anndata.readthedocs.io/en/latest/) file
|
||||
|
||||
ENV_NAME=cellxgene
|
||||
python3 -m venv ${ENV_NAME}
|
||||
source ${ENV_NAME}/bin/activate
|
||||
```bash
|
||||
cellxgene launch https://cellxgene-example-data.czi.technology/pbmc3k.h5ad
|
||||
```
|
||||
|
||||
**Install server**
|
||||
To explore more datasets already formatted for Annotate, check out the [Demo data](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/desktop/quick-start.md#example-datasets) or
|
||||
see [Preparing your data](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/desktop/data-reqs.md) to learn more about formatting your own
|
||||
data for CELLxGENE Annotate.
|
||||
|
||||
pip install -e .
|
||||
### Supported browsers
|
||||
|
||||
**Run (with demo data)**
|
||||
CELLxGENE Annotate currently supports the following browsers:
|
||||
|
||||
cellxgene launch --title PBMC3K example-dataset/pbmc3k.h5ad
|
||||
- Google Chrome 61+
|
||||
- Edge 15+
|
||||
- Firefox 60+
|
||||
|
||||
**Help**
|
||||
Please [file an issue](https://github.com/chanzuckerberg/cellxgene/issues/new/choose) if you would like us to add support for an unsupported browser.
|
||||
|
||||
cellxgene --help
|
||||
### Finding help
|
||||
|
||||
_For help with the scanpy engine_
|
||||
We'd love to hear from you!
|
||||
For questions, suggestions, or accolades, [join the `#cellxgene-users` channel on the CZI Science Slack](https://join-cellxgene-users.herokuapp.com/) and say "hi!".
|
||||
|
||||
cellxgene scanpy --help
|
||||
For any errors, [report bugs on Github](https://github.com/chanzuckerberg/cellxgene/issues).
|
||||
|
||||
## Using your own data
|
||||
# Developing with CZ CELLxGENE Annotate
|
||||
|
||||
### Scanpy
|
||||
### Contributing
|
||||
|
||||
To prepare your data you will need to format your data into AnnData format using scanpy and calculate PCA and nearest neighbors and save in h5ad format.
|
||||
We warmly welcome contributions from the community! Please see our [contributing guide](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/contribute.md) and don't hesitate to open an issue or send a pull request to improve CELLxGENE Annotate. Please see the [dev_docs](https://github.com/chanzuckerberg/cellxgene/tree/main/dev_docs) for pull request suggestions, unit test details, local documentation preview, and other development specifics.
|
||||
|
||||
1. [Load data into scanpy](https://scanpy.readthedocs.io/en/latest/api/index.html#reading)
|
||||
This project adheres to the Contributor Covenant [code of conduct](https://github.com/chanzuckerberg/.github/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to opensource@chanzuckerberg.com.
|
||||
|
||||
- Ensure that `obs`'s index is the cell names: `print(data.obs_names)` should show your cell indices. If it shows gene names, you may need to just call `data.transpose()`.
|
||||
### Reuse
|
||||
|
||||
2. Calculate PCA
|
||||
This project was started with the sole goal of empowering the scientific community to explore and understand their data.
|
||||
As such, we encourage other scientific tool builders in academia or industry to adopt the patterns, tools, and code from
|
||||
this project. All code is freely available for reuse under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
sc.pp.pca(data) ## sc is scanpy.api
|
||||
|
||||
3. Calculate nearest neighbors (depending on layout algorithm)
|
||||
Before extending CELLxGENE Annotate, we encourage you to reach out to us with ideas or questions. It might be possible that an
|
||||
extension could be directly contributed, which would make it available for a wider audience, or that it's on our
|
||||
[roadmap](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/roadmap.md) and under active development.
|
||||
|
||||
```
|
||||
# For umap layout algorithm, you need to use the "umap" method for neighbors
|
||||
sc.pp.neighbors(data, method="umap", metric="euclidean", use_rep="X_pca")
|
||||
See the [CELLxGENE extensions](https://github.com/chanzuckerberg/cellxgene-documentation/blob/main/community-extensions.md) section of our documentation for examples of community use and CELLxGENE extensions.
|
||||
|
||||
# For tsne layout algorithm, you can use either "umap" or "gauss"; we recommend "gauss"
|
||||
sc.pp.neighbors(data, method="gauss", metric="euclidean", use_rep="X_pca")
|
||||
```
|
||||
### Security
|
||||
|
||||
4. Save file
|
||||
If you believe you have found a security issue, we would appreciate notification. Please send email to <security@chanzuckerberg.com>.
|
||||
|
||||
```
|
||||
# cellxgene requires file to be named data.h5ad
|
||||
data.write("data.h5ad")
|
||||
```
|
||||
# Inspiration
|
||||
|
||||
## Contributing
|
||||
We've been heavily inspired by several other related single-cell visualization projects, including the [UCSC Cell Browser](http://cells.ucsc.edu/), [Cytoscape](http://www.cytoscape.org/), [Xena](https://xena.ucsc.edu/), [ASAP](https://asap.epfl.ch/), [GenePattern](http://genepattern-notebook.org/), and many others. We hope to explore collaborations where useful as this community works together on improving interactive visualization for single-cell data.
|
||||
|
||||
We warmly welcome contributions from the community. Please submit any bug reports and feature requests through github issues. Please submit any direct contributions via a branch + pull request.
|
||||
We were inspired by Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for the design of our filtering implementation.
|
||||
|
||||
## Inspiration and collaboration
|
||||
We have been working closely with the [scanpy](https://github.com/theislab/scanpy) team to integrate with their awesome analysis tools. Special thanks to Alex Wolf, Fabian Theis, and the rest of the team for their help during development and for providing an example dataset.
|
||||
|
||||
We’ve been inspired by several other related efforts in this space, including the [UCSC Cell Browswer](http://cells.ucsc.edu/), [Cytoscape](http://www.cytoscape.org/), [Xena](https://xena.ucsc.edu/), [ASAP](https://asap.epfl.ch/), [Gene Pattern](http://genepattern-notebook.org/), & many others; we hope to explore collaborations where useful.
|
||||
|
||||
## Help/Contact
|
||||
|
||||
Have questions, suggestions, or comments? You can contact us by joining [CZI Science Slack](https://cziscience.slack.com/messages/CCTA8DF1T) and posting in the #cellxgene channel. Please submit any feature requests or bugs as an issue in github. We'd love to hear from you!
|
||||
|
||||
## Reuse
|
||||
|
||||
This project was started with the sole goal of empowering the scientific community to explore and understand their data. As such, we whole-heartedly encourage other scientific tool builders to adopt the patterns, tools, and code from this project, and reach out to us with ideas or questions using Github Issues or Pull Requests. All code is freely available for reuse under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
cellxgene is inspired by many innovative projects. We would like to specifically thank:
|
||||
|
||||
- Alex Wolf for the demo dataset.
|
||||
- Mike Bostock and the [crossfilter](https://github.com/crossfilter) team for API inspiration.
|
||||
We are eager to explore integrations with other computational backends such as [Seurat](https://github.com/satijalab/seurat) or [Bioconductor](https://github.com/Bioconductor)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
|
||||
CELLXGENE_DIR=$(dirname $DIR)
|
||||
|
||||
cd $CELLXGENE_DIR
|
||||
|
||||
npm install --prefix client/ client
|
||||
npm run --prefix client build
|
||||
rm -rf server/app/web/static
|
||||
mkdir -p server/app/web/static/img
|
||||
cp client/build/index.html server/app/web/templates/
|
||||
cp -r client/build/static server/app/web/
|
||||
|
||||
cp client/build/favicon.png server/app/web/static/img
|
||||
cp client/build/service-worker.js server/app/web/static/js/
|
||||
@@ -1,16 +0,0 @@
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
|
||||
CELLXGENE_DIR=$(dirname $DIR)
|
||||
|
||||
echo "Uninstalling cellxgene"
|
||||
yes | pip uninstall cellxgene
|
||||
echo "removing node_modules"
|
||||
rm -rf $CELLXGENE_DIR/client/node_modules
|
||||
echo "removing client_build"
|
||||
rm -rf $CELLXGENE_DIR/client/build
|
||||
echo "removing egg-info"
|
||||
rm -rf $CELLXGENE_DIR/cellxgene.egg-info
|
||||
echo "removing static files"
|
||||
rm -f $CELLXGENE_DIR/server/app/web/templates/index.html
|
||||
rm -rf $CELLXGENE_DIR/server/app/web/static
|
||||
echo "cellxgene cleanup complete"
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
cd client
|
||||
npx --no-install lint-staged --config "./configuration/lint-staged/lint-staged.config.js"
|
||||
@@ -0,0 +1,62 @@
|
||||
include ../common.mk
|
||||
|
||||
ANNOTATIONS := $(if $(ANNOTATIONS),$(ANNOTATIONS),../test/fixtures/pbmc3k-annotations.csv)
|
||||
GENE_SETS := $(if $(GENE_SETS),$(GENE_SETS),../test/fixtures/pbmc3k-genesets.csv)
|
||||
ANNOTATIONS_FILENAME := $(shell basename $(ANNOTATIONS))
|
||||
GENE_SETS_FILENAME := $(shell basename $(GENE_SETS))
|
||||
|
||||
CXG_CONFIG := $(if $(CXG_CONFIG),$(CXG_CONFIG),./__tests__/e2e/test_config.yaml)
|
||||
|
||||
|
||||
|
||||
# Packaging
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf node_modules
|
||||
rm -f __tests__/screenshots/*.png
|
||||
|
||||
.PHONY: ci
|
||||
ci:
|
||||
npm ci
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
npm install
|
||||
|
||||
.PHONY: build
|
||||
WEBPACK_CONFIG ?= configuration/webpack/webpack.config.prod.js
|
||||
build:
|
||||
npm run build $(WEBPACK_CONFIG)
|
||||
|
||||
# Development convenience methods
|
||||
.PHONY: start-frontend
|
||||
start-frontend:
|
||||
node server/development.js
|
||||
|
||||
# start an instance of cellxgene and run the end-to-end tests
|
||||
.PHONY: smoke-test
|
||||
smoke-test:
|
||||
start_server_and_test \
|
||||
'CXG_OPTIONS="--config-file $(CXG_CONFIG)" $(MAKE) start-server' \
|
||||
$(CXG_SERVER_PORT) \
|
||||
'CXG_URL_BASE="http://localhost:$(CXG_SERVER_PORT)" npm run e2e -- --verbose false'
|
||||
|
||||
# start an instance of cellxgene and run the end-to-end annotations tests
|
||||
.PHONY: smoke-test-annotations
|
||||
smoke-test-annotations:
|
||||
$(eval TMP_DIR := $(shell mktemp -d /tmp/cellxgene_XXXXXX))
|
||||
cp $(ANNOTATIONS) $(TMP_DIR)/ && \
|
||||
cp $(GENE_SETS) $(TMP_DIR)/ && \
|
||||
start_server_and_test \
|
||||
'CXG_OPTIONS="--annotations-file $(TMP_DIR)/$(ANNOTATIONS_FILENAME) --gene-sets-file $(TMP_DIR)/$(GENE_SETS_FILENAME)" $(MAKE) start-server' \
|
||||
$(CXG_SERVER_PORT) \
|
||||
'CXG_URL_BASE="http://localhost:$(CXG_SERVER_PORT)" npm run e2e-annotations -- --verbose false'
|
||||
rm -rf $(TMP_DIR)
|
||||
|
||||
.PHONY: unit-test
|
||||
unit-test:
|
||||
node node_modules/jest/bin/jest.js --testPathIgnorePatterns e2e
|
||||
|
||||
# pass remaining commands through to npm run
|
||||
%:
|
||||
npm run $(*)
|
||||
@@ -0,0 +1,5 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`did launch page launched 1`] = `"<span style=\\"max-width: 155px; display: flex; overflow: hidden; justify-content: flex-start; width: 100%; padding: 0px;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">pbm</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">c3k</span><span style=\\"position: absolute; right: 0px; color: inherit;\\">c3k</span></span></span>"`;
|
||||
|
||||
exports[`metadata loads categories and values from dataset appear 1`] = `"<div style=\\"display: flex; justify-content: space-between; align-items: baseline;\\"><div style=\\"display: flex; justify-content: flex-start; align-items: flex-start;\\"><label class=\\"bp3-control bp3-checkbox\\" for=\\"category-select-louvain\\"><input id=\\"category-select-louvain\\" data-testclass=\\"category-select\\" data-testid=\\"louvain:category-select\\" type=\\"checkbox\\" checked=\\"\\"><span class=\\"bp3-control-indicator\\"></span></label><span role=\\"menuitem\\" tabindex=\\"0\\" data-testclass=\\"category-expand\\" data-testid=\\"louvain:category-expand\\" style=\\"cursor: pointer;\\"><span aria-haspopup=\\"true\\" class=\\"bp3-popover2-target\\"><span data-testid=\\"louvain:category-label\\" tabindex=\\"-1\\" aria-label=\\"louvain\\" class=\\"\\" style=\\"max-width: 265px;\\"><span style=\\"max-width: 265px; display: flex; overflow: hidden; justify-content: flex-start; width: 100%; padding: 0px;\\"><span style=\\"overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 1; min-width: 5px;\\">lou</span><span style=\\"position: relative; overflow: hidden; white-space: nowrap;\\"><span style=\\"color: transparent;\\">vain</span><span style=\\"position: absolute; right: 0px; color: inherit;\\">vain</span></span></span></span></span><svg stroke=\\"currentColor\\" fill=\\"currentColor\\" stroke-width=\\"0\\" viewBox=\\"0 0 320 512\\" data-testclass=\\"category-expand-is-not-expanded\\" height=\\"1em\\" width=\\"1em\\" xmlns=\\"http://www.w3.org/2000/svg\\" style=\\"font-size: 10px; margin-left: 5px;\\"><path d=\\"M285.476 272.971L91.132 467.314c-9.373 9.373-24.569 9.373-33.941 0l-22.667-22.667c-9.357-9.357-9.375-24.522-.04-33.901L188.505 256 34.484 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L285.475 239.03c9.373 9.372 9.373 24.568.001 33.941z\\"></path></svg></span></div><div><span class=\\"bp3-popover-wrapper\\"><span aria-haspopup=\\"true\\" class=\\"bp3-popover-target\\"><a role=\\"button\\" data-testclass=\\"colorby\\" data-testid=\\"colorby-louvain\\" class=\\"bp3-button\\" tabindex=\\"0\\"><span icon=\\"tint\\" aria-hidden=\\"true\\" tabindex=\\"0\\" class=\\"bp3-icon bp3-icon-tint\\"><svg data-icon=\\"tint\\" width=\\"16\\" height=\\"16\\" viewBox=\\"0 0 16 16\\"><path d=\\"M7.88 1s-4.9 6.28-4.9 8.9c.01 2.82 2.34 5.1 4.99 5.1 2.65-.01 5.03-2.3 5.03-5.13C12.99 7.17 7.88 1 7.88 1z\\" fill-rule=\\"evenodd\\"></path></svg></span></a></span></span></div></div><div style=\\"margin-left: 26px;\\"></div>"`;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,445 @@
|
||||
/* eslint-disable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
|
||||
import { strict as assert } from "assert";
|
||||
import {
|
||||
clearInputAndTypeInto,
|
||||
clickOn,
|
||||
getAllByClass,
|
||||
getOneElementInnerText,
|
||||
typeInto,
|
||||
waitByID,
|
||||
waitByClass,
|
||||
waitForAllByIds,
|
||||
clickOnUntil,
|
||||
getTestClass,
|
||||
getTestId,
|
||||
isElementPresent,
|
||||
} from "./puppeteerUtils";
|
||||
|
||||
export async function drag(testId, start, end, lasso = false) {
|
||||
const layout = await waitByID(testId);
|
||||
const elBox = await layout.boxModel();
|
||||
const x1 = elBox.content[0].x + start.x;
|
||||
const x2 = elBox.content[0].x + end.x;
|
||||
const y1 = elBox.content[0].y + start.y;
|
||||
const y2 = elBox.content[0].y + end.y;
|
||||
await page.mouse.move(x1, y1);
|
||||
await page.mouse.down();
|
||||
if (lasso) {
|
||||
await page.mouse.move(x2, y1);
|
||||
await page.mouse.move(x2, y2);
|
||||
await page.mouse.move(x1, y2);
|
||||
await page.mouse.move(x1, y1);
|
||||
} else {
|
||||
await page.mouse.move(x2, y2);
|
||||
}
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function clickOnCoordinate(testId, coord) {
|
||||
const layout = await expect(page).toMatchElement(getTestId(testId));
|
||||
const elBox = await layout.boxModel();
|
||||
|
||||
if (!elBox) {
|
||||
throw Error("Layout's boxModel is not available!");
|
||||
}
|
||||
|
||||
const x = elBox.content[0].x + coord.x;
|
||||
const y = elBox.content[0].y + coord.y;
|
||||
await page.mouse.click(x, y);
|
||||
}
|
||||
|
||||
export async function getAllHistograms(testclass, testIds) {
|
||||
const histTestIds = testIds.map((tid) => `histogram-${tid}`);
|
||||
|
||||
// these load asynchronously, so we need to wait for each histogram individually,
|
||||
// and they may be quite slow in some cases.
|
||||
await waitForAllByIds(histTestIds, { timeout: 4 * 60 * 1000 });
|
||||
|
||||
const allHistograms = await getAllByClass(testclass);
|
||||
|
||||
const testIDs = await Promise.all(
|
||||
allHistograms.map((hist) =>
|
||||
page.evaluate((elem) => elem.dataset.testid, hist)
|
||||
)
|
||||
);
|
||||
|
||||
return testIDs.map((id) => id.replace(/^histogram-/, ""));
|
||||
}
|
||||
|
||||
export async function getAllCategoriesAndCounts(category) {
|
||||
// these load asynchronously, so we have to wait for the specific category.
|
||||
await waitByID(`category-${category}`);
|
||||
|
||||
return page.$$eval(
|
||||
`[data-testid="category-${category}"] [data-testclass='categorical-row']`,
|
||||
(rows) =>
|
||||
Object.fromEntries(
|
||||
rows.map((row) => {
|
||||
const cat = row
|
||||
.querySelector("[data-testclass='categorical-value']")
|
||||
.getAttribute("aria-label");
|
||||
|
||||
const count = row.querySelector(
|
||||
"[data-testclass='categorical-value-count']"
|
||||
).innerText;
|
||||
|
||||
return [cat, count];
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCellSetCount(num) {
|
||||
await clickOn(`cellset-button-${num}`);
|
||||
return getOneElementInnerText(`[data-testid='cellset-count-${num}']`);
|
||||
}
|
||||
|
||||
export async function resetCategory(category) {
|
||||
const checkboxId = `${category}:category-select`;
|
||||
await waitByID(checkboxId);
|
||||
const checkedPseudoclass = await page.$eval(
|
||||
`[data-testid='${checkboxId}']`,
|
||||
(el) => el.matches(":checked")
|
||||
);
|
||||
if (!checkedPseudoclass) await clickOn(checkboxId);
|
||||
|
||||
const categoryRow = await waitByID(`${category}:category-expand`);
|
||||
|
||||
const isExpanded = await categoryRow.$(
|
||||
"[data-testclass='category-expand-is-expanded']"
|
||||
);
|
||||
|
||||
if (isExpanded) await clickOn(`${category}:category-expand`);
|
||||
}
|
||||
|
||||
export async function calcCoordinate(testId, xAsPercent, yAsPercent) {
|
||||
const el = await waitByID(testId);
|
||||
const size = await el.boxModel();
|
||||
return {
|
||||
x: Math.floor(size.width * xAsPercent),
|
||||
y: Math.floor(size.height * yAsPercent),
|
||||
};
|
||||
}
|
||||
|
||||
export async function calcDragCoordinates(testId, coordinateAsPercent) {
|
||||
return {
|
||||
start: await calcCoordinate(
|
||||
testId,
|
||||
coordinateAsPercent.x1,
|
||||
coordinateAsPercent.y1
|
||||
),
|
||||
end: await calcCoordinate(
|
||||
testId,
|
||||
coordinateAsPercent.x2,
|
||||
coordinateAsPercent.y2
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function selectCategory(category, values, reset = true) {
|
||||
if (reset) await resetCategory(category);
|
||||
|
||||
await clickOn(`${category}:category-expand`);
|
||||
await clickOn(`${category}:category-select`);
|
||||
|
||||
for (const value of values) {
|
||||
await clickOn(`categorical-value-select-${category}-${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expandCategory(category) {
|
||||
const expand = await waitByID(`${category}:category-expand`);
|
||||
const notExpanded = await expand.$(
|
||||
"[data-testclass='category-expand-is-not-expanded']"
|
||||
);
|
||||
if (notExpanded) await clickOn(`${category}:category-expand`);
|
||||
}
|
||||
|
||||
export async function clip(min = 0, max = 100) {
|
||||
await clickOn("visualization-settings");
|
||||
await clearInputAndTypeInto("clip-min-input", min);
|
||||
await clearInputAndTypeInto("clip-max-input", max);
|
||||
await clickOn("clip-commit");
|
||||
}
|
||||
|
||||
export async function createCategory(categoryName) {
|
||||
await clickOnUntil("open-annotation-dialog", async () => {
|
||||
await expect(page).toMatchElement(getTestId("new-category-name"));
|
||||
});
|
||||
|
||||
await typeInto("new-category-name", categoryName);
|
||||
await clickOn("submit-category");
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
GENESET
|
||||
|
||||
*/
|
||||
|
||||
export async function colorByGeneset(genesetName) {
|
||||
await clickOn(`${genesetName}:colorby-entire-geneset`);
|
||||
}
|
||||
|
||||
export async function colorByGene(gene) {
|
||||
await clickOn(`colorby-${gene}`);
|
||||
}
|
||||
|
||||
export async function assertColorLegendLabel(label) {
|
||||
const handle = await waitByID("continuous_legend_color_by_label");
|
||||
|
||||
const result = await handle.evaluate((node) =>
|
||||
node.getAttribute("aria-label")
|
||||
);
|
||||
|
||||
return expect(result).toBe(label);
|
||||
}
|
||||
|
||||
export async function expandGeneset(genesetName) {
|
||||
const expand = await waitByID(`${genesetName}:geneset-expand`);
|
||||
const notExpanded = await expand.$(
|
||||
"[data-testclass='geneset-expand-is-not-expanded']"
|
||||
);
|
||||
if (notExpanded) await clickOn(`${genesetName}:geneset-expand`);
|
||||
}
|
||||
|
||||
export async function createGeneset(genesetName) {
|
||||
await clickOnUntil("open-create-geneset-dialog", async () => {
|
||||
await expect(page).toMatchElement(getTestId("create-geneset-input"));
|
||||
});
|
||||
|
||||
await typeInto("create-geneset-input", genesetName);
|
||||
await clickOn("submit-geneset");
|
||||
await waitByClass("autosave-complete");
|
||||
}
|
||||
|
||||
export async function editGenesetName(genesetName, editText) {
|
||||
const editButton = `${genesetName}:edit-genesetName-mode`;
|
||||
const submitButton = `${genesetName}:submit-geneset`;
|
||||
await clickOnUntil(`${genesetName}:see-actions`, async () => {
|
||||
await expect(page).toMatchElement(getTestId(editButton));
|
||||
});
|
||||
await clickOn(editButton);
|
||||
await typeInto("rename-geneset-modal", editText);
|
||||
await clickOn(submitButton);
|
||||
}
|
||||
|
||||
export async function deleteGeneset(genesetName) {
|
||||
const targetId = `${genesetName}:delete-geneset`;
|
||||
|
||||
await clickOnUntil(`${genesetName}:see-actions`, async () => {
|
||||
await expect(page).toMatchElement(getTestId(targetId));
|
||||
});
|
||||
|
||||
await clickOn(targetId);
|
||||
|
||||
await assertGenesetDoesNotExist(genesetName);
|
||||
await waitByClass("autosave-complete");
|
||||
}
|
||||
|
||||
export async function assertGenesetDoesNotExist(genesetName) {
|
||||
const result = await isElementPresent(
|
||||
getTestId(`${genesetName}:geneset-name`)
|
||||
);
|
||||
await expect(result).toBe(false);
|
||||
}
|
||||
|
||||
export async function assertGenesetExists(genesetName) {
|
||||
const handle = await waitByID(`${genesetName}:geneset-name`);
|
||||
|
||||
const result = await handle.evaluate((node) =>
|
||||
node.getAttribute("aria-label")
|
||||
);
|
||||
|
||||
return expect(result).toBe(genesetName);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
GENE
|
||||
|
||||
*/
|
||||
|
||||
export async function addGeneToSet(genesetName, geneToAddToSet) {
|
||||
const submitButton = `${genesetName}:submit-gene`;
|
||||
|
||||
await clickOn(`${genesetName}:add-new-gene-to-geneset`);
|
||||
await typeInto("add-genes", geneToAddToSet);
|
||||
await clickOn(submitButton);
|
||||
}
|
||||
|
||||
export async function removeGene(geneSymbol) {
|
||||
const targetId = `delete-from-geneset:${geneSymbol}`;
|
||||
|
||||
await clickOn(targetId);
|
||||
|
||||
await waitByClass("autosave-complete");
|
||||
}
|
||||
|
||||
export async function assertGeneExistsInGeneset(geneSymbol) {
|
||||
const handle = await waitByID(`${geneSymbol}:gene-label`);
|
||||
|
||||
const result = await handle.evaluate((node) =>
|
||||
node.getAttribute("aria-label")
|
||||
);
|
||||
|
||||
return expect(result).toBe(geneSymbol);
|
||||
}
|
||||
|
||||
export async function assertGeneDoesNotExist(geneSymbol) {
|
||||
const result = await isElementPresent(getTestId(`${geneSymbol}:gene-label`));
|
||||
|
||||
await expect(result).toBe(false);
|
||||
}
|
||||
|
||||
export async function expandGene(geneSymbol) {
|
||||
await clickOn(`maximize-${geneSymbol}`);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
CATEGORY
|
||||
|
||||
*/
|
||||
|
||||
export async function duplicateCategory(categoryName) {
|
||||
await clickOn("open-annotation-dialog");
|
||||
|
||||
await typeInto("new-category-name", categoryName);
|
||||
|
||||
const dropdownOptionClass = "duplicate-category-dropdown-option";
|
||||
|
||||
await clickOnUntil("duplicate-category-dropdown", async () => {
|
||||
await expect(page).toMatchElement(getTestClass(dropdownOptionClass));
|
||||
});
|
||||
|
||||
const option = await expect(page).toMatchElement(
|
||||
getTestClass(dropdownOptionClass)
|
||||
);
|
||||
|
||||
await option.click();
|
||||
|
||||
await clickOnUntil("submit-category", async () => {
|
||||
await expect(page).toMatchElement(
|
||||
getTestId(`${categoryName}:category-expand`)
|
||||
);
|
||||
});
|
||||
|
||||
await waitByClass("autosave-complete");
|
||||
}
|
||||
|
||||
export async function renameCategory(oldCategoryName, newCategoryName) {
|
||||
await clickOn(`${oldCategoryName}:see-actions`);
|
||||
await clickOn(`${oldCategoryName}:edit-category-mode`);
|
||||
await clearInputAndTypeInto(
|
||||
`${oldCategoryName}:edit-category-name-text`,
|
||||
newCategoryName
|
||||
);
|
||||
await clickOn(`${oldCategoryName}:submit-category-edit`);
|
||||
}
|
||||
|
||||
export async function deleteCategory(categoryName) {
|
||||
const targetId = `${categoryName}:delete-category`;
|
||||
|
||||
await clickOnUntil(`${categoryName}:see-actions`, async () => {
|
||||
await expect(page).toMatchElement(getTestId(targetId));
|
||||
});
|
||||
|
||||
await clickOn(targetId);
|
||||
|
||||
await assertCategoryDoesNotExist();
|
||||
}
|
||||
|
||||
export async function createLabel(categoryName, labelName) {
|
||||
/**
|
||||
* (thuang): This explicit wait is needed, since currently showing
|
||||
* the modal again quickly after the previous action dismissing the
|
||||
* modal will persist the input value from the previous action.
|
||||
*
|
||||
* To reproduce:
|
||||
* 1. Click on the plus sign to show the modal to add a new label to the category
|
||||
* 2. Type `123` in the input box
|
||||
* 3. Hover over your mouse over the plus sign and double click to quickly dismiss and
|
||||
* invoke the modal again
|
||||
* 4. You will see `123` is persisted in the input box
|
||||
* 5. Expected behavior is to get an empty input box
|
||||
*/
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await clickOn(`${categoryName}:see-actions`);
|
||||
|
||||
await clickOn(`${categoryName}:add-new-label-to-category`);
|
||||
|
||||
await typeInto(`${categoryName}:new-label-name`, labelName);
|
||||
|
||||
await clickOn(`${categoryName}:submit-label`);
|
||||
}
|
||||
|
||||
export async function deleteLabel(categoryName, labelName) {
|
||||
await expandCategory(categoryName);
|
||||
await clickOn(`${categoryName}:${labelName}:see-actions`);
|
||||
await clickOn(`${categoryName}:${labelName}:delete-label`);
|
||||
}
|
||||
|
||||
export async function renameLabel(categoryName, oldLabelName, newLabelName) {
|
||||
await expandCategory(categoryName);
|
||||
await clickOn(`${categoryName}:${oldLabelName}:see-actions`);
|
||||
await clickOn(`${categoryName}:${oldLabelName}:edit-label`);
|
||||
await clearInputAndTypeInto(
|
||||
`${categoryName}:${oldLabelName}:edit-label-name`,
|
||||
newLabelName
|
||||
);
|
||||
await clickOn(`${categoryName}:${oldLabelName}:submit-label-edit`);
|
||||
}
|
||||
|
||||
export async function addGeneToSearch(geneName) {
|
||||
await typeInto("gene-search", geneName);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector(`[data-testid='histogram-${geneName}']`);
|
||||
}
|
||||
|
||||
export async function subset(coordinatesAsPercent) {
|
||||
// In order to deselect the selection after the subset, make sure we have some clear part
|
||||
// of the scatterplot we can click on
|
||||
assert(coordinatesAsPercent.x2 < 0.99 || coordinatesAsPercent.y2 < 0.99);
|
||||
const lassoSelection = await calcDragCoordinates(
|
||||
"layout-graph",
|
||||
coordinatesAsPercent
|
||||
);
|
||||
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
|
||||
await clickOn("subset-button");
|
||||
const clearCoordinate = await calcCoordinate("layout-graph", 0.5, 0.99);
|
||||
await clickOnCoordinate("layout-graph", clearCoordinate);
|
||||
}
|
||||
|
||||
export async function setSellSet(cellSet, cellSetNum) {
|
||||
const selections = cellSet.filter((sel) => sel.kind === "categorical");
|
||||
|
||||
for (const selection of selections) {
|
||||
await selectCategory(selection.metadata, selection.values, true);
|
||||
}
|
||||
|
||||
await getCellSetCount(cellSetNum);
|
||||
}
|
||||
|
||||
export async function runDiffExp(cellSet1, cellSet2) {
|
||||
await setSellSet(cellSet1, 1);
|
||||
await setSellSet(cellSet2, 2);
|
||||
await clickOn("diffexp-button");
|
||||
}
|
||||
|
||||
export async function bulkAddGenes(geneNames) {
|
||||
await clickOn("section-bulk-add");
|
||||
await typeInto("input-bulk-add", geneNames.join(","));
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
export async function assertCategoryDoesNotExist(categoryName) {
|
||||
const result = await isElementPresent(
|
||||
getTestId(`${categoryName}:category-label`)
|
||||
);
|
||||
|
||||
await expect(result).toBe(false);
|
||||
}
|
||||
|
||||
/* eslint-enable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as ENV_DEFAULT from "../../../environment.default.json";
|
||||
|
||||
export const jestEnv = process.env.JEST_ENV || ENV_DEFAULT.JEST_ENV;
|
||||
export const appUrlBase =
|
||||
process.env.CXG_URL_BASE || `http://localhost:${ENV_DEFAULT.CXG_CLIENT_PORT}`;
|
||||
export const DATASET = "pbmc3k";
|
||||
export const isDev = jestEnv === ENV_DEFAULT.DEV;
|
||||
export const isDebug = jestEnv === ENV_DEFAULT.DEBUG;
|
||||
export const TEST_EMAIL = "user@example.com";
|
||||
export const TEST_PASSWORD = process.env.TEST_ACCOUNT_PASS ?? "";
|
||||
@@ -0,0 +1,147 @@
|
||||
export const datasets = {
|
||||
pbmc3k: {
|
||||
title: "pbmc3k",
|
||||
dataframe: {
|
||||
nObs: "2638",
|
||||
nVar: "1838",
|
||||
type: "float32",
|
||||
},
|
||||
categorical: {
|
||||
louvain: {
|
||||
"B cells": "342",
|
||||
"CD14+ Monocytes": "480",
|
||||
"CD4 T cells": "1144",
|
||||
"CD8 T cells": "316",
|
||||
"Dendritic cells": "37",
|
||||
"FCGR3A+ Monocytes": "150",
|
||||
Megakaryocytes: "15",
|
||||
"NK cells": "154",
|
||||
},
|
||||
},
|
||||
continuous: {
|
||||
n_genes: "int32",
|
||||
percent_mito: "float32",
|
||||
n_counts: "float32",
|
||||
},
|
||||
cellsets: {
|
||||
lasso: [
|
||||
{
|
||||
"coordinates-as-percent": { x1: 0.1, y1: 0.25, x2: 0.7, y2: 0.75 },
|
||||
count: "1131",
|
||||
},
|
||||
],
|
||||
categorical: [
|
||||
{
|
||||
metadata: "louvain",
|
||||
values: ["B cells", "Megakaryocytes"],
|
||||
count: "357",
|
||||
},
|
||||
],
|
||||
continuous: [
|
||||
{
|
||||
metadata: "n_genes",
|
||||
"coordinates-as-percent": { x1: 0.25, y1: 0.5, x2: 0.55, y2: 0.5 },
|
||||
count: "1537",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
diffexp: {
|
||||
cellset1: [
|
||||
{ kind: "categorical", metadata: "louvain", values: ["B cells"] },
|
||||
],
|
||||
cellset2: [
|
||||
{
|
||||
kind: "categorical",
|
||||
metadata: "louvain",
|
||||
values: ["CD4 T cells", "NK cells"],
|
||||
},
|
||||
],
|
||||
"gene-results": [
|
||||
"HLA-DRB1",
|
||||
"HLA-DPB1",
|
||||
"CD79A",
|
||||
"HLA-DPA1",
|
||||
"HLA-DQA1",
|
||||
"CD79B",
|
||||
"HLA-DQB1",
|
||||
"MS4A1",
|
||||
"IL32",
|
||||
"CD37",
|
||||
],
|
||||
},
|
||||
|
||||
genes: {
|
||||
bulkadd: ["S100A8", "FCGR3A", "LGALS2", "GSTP1"],
|
||||
search: "ACD",
|
||||
},
|
||||
subset: {
|
||||
cellset1: [
|
||||
{
|
||||
kind: "categorical",
|
||||
metadata: "louvain",
|
||||
values: ["B cells", "Megakaryocytes"],
|
||||
},
|
||||
],
|
||||
count: "357",
|
||||
categorical: {
|
||||
louvain: {
|
||||
"B cells": "342",
|
||||
"CD14+ Monocytes": "0",
|
||||
"CD4 T cells": "0",
|
||||
"CD8 T cells": "0",
|
||||
"Dendritic cells": "0",
|
||||
"FCGR3A+ Monocytes": "0",
|
||||
Megakaryocytes: "15",
|
||||
"NK cells": "0",
|
||||
},
|
||||
},
|
||||
lasso: {
|
||||
"coordinates-as-percent": { x1: 0.25, y1: 0.05, x2: 0.75, y2: 0.55 },
|
||||
count: "331",
|
||||
},
|
||||
},
|
||||
scatter: {
|
||||
genes: { x: "S100A8", y: "FCGR3A" },
|
||||
},
|
||||
pan: {
|
||||
"coordinates-as-percent": { x1: 0.75, y1: 0.75, x2: 0.35, y2: 0.35 },
|
||||
},
|
||||
features: {
|
||||
panzoom: {
|
||||
lasso: {
|
||||
"coordinates-as-percent": { x1: 0.3, y1: 0.3, x2: 0.5, y2: 0.5 },
|
||||
count: "38",
|
||||
},
|
||||
},
|
||||
},
|
||||
categoryLabel: {
|
||||
lasso: {
|
||||
"coordinates-as-percent": { x1: 0.05, y1: 0.3, x2: 0.5, y2: 0.5 },
|
||||
},
|
||||
newCount: {
|
||||
bySubsetConfig: {
|
||||
false: "668",
|
||||
true: "659",
|
||||
},
|
||||
},
|
||||
},
|
||||
annotationsFromFile: {
|
||||
count: {
|
||||
bySubsetConfig: {
|
||||
false: "1161",
|
||||
true: "852",
|
||||
},
|
||||
},
|
||||
},
|
||||
clip: {
|
||||
min: "30",
|
||||
max: "70",
|
||||
metadata: "n_genes",
|
||||
gene: "S100A8",
|
||||
"coordinates-as-percent": { x1: 0.25, y1: 0.5, x2: 0.55, y2: 0.5 },
|
||||
count: "386",
|
||||
"gene-cell-count": "416",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
export const diffexpPop1Genes = [
|
||||
"CD79A",
|
||||
"HLA-DRB1",
|
||||
"HLA-DQA1",
|
||||
"HLA-DPB1",
|
||||
"HLA-DQB1",
|
||||
"HLA-DPA1",
|
||||
"MS4A1",
|
||||
"LTB",
|
||||
"CD79B",
|
||||
"CD37",
|
||||
"HLA-DMA",
|
||||
"TCL1A",
|
||||
"LINC00926",
|
||||
"HLA-DMB",
|
||||
"HVCN1",
|
||||
"EAF2",
|
||||
"FCRLA",
|
||||
"IRF8",
|
||||
"PKIG",
|
||||
"P2RX5",
|
||||
"HLA-DOB",
|
||||
"SPIB",
|
||||
"BLNK",
|
||||
"SWAP70",
|
||||
"PNOC",
|
||||
"CD19",
|
||||
"SMIM14",
|
||||
"CD72",
|
||||
"KIAA0125",
|
||||
"IGLL5",
|
||||
"ARHGAP24",
|
||||
"COTL1",
|
||||
"C16orf74",
|
||||
"BTK",
|
||||
"SNX29P2",
|
||||
"ADAM28",
|
||||
"FCGR2B",
|
||||
"PLD4",
|
||||
"PPP1R14A",
|
||||
"MZB1",
|
||||
"KIAA0040",
|
||||
"PHACTR1",
|
||||
"FCRL2",
|
||||
"RIC3",
|
||||
"P2RY10",
|
||||
"SCPEP1",
|
||||
"DRAM2",
|
||||
"RP5-887A10.1",
|
||||
"CD82",
|
||||
"GPX1",
|
||||
];
|
||||
|
||||
export const diffexpPop2Genes = [
|
||||
"NKG7",
|
||||
"GZMB",
|
||||
"CTSW",
|
||||
"PRF1",
|
||||
"GNLY",
|
||||
"GZMA",
|
||||
"CST7",
|
||||
"FGFBP2",
|
||||
"SRGN",
|
||||
"CD247",
|
||||
"FCGR3A",
|
||||
"TYROBP",
|
||||
"FCER1G",
|
||||
"ID2",
|
||||
"SPON2",
|
||||
"CCL4",
|
||||
"CCL5",
|
||||
"GZMH",
|
||||
"GIMAP7",
|
||||
"CLIC3",
|
||||
"HOPX",
|
||||
"XCL2",
|
||||
"LGALS1",
|
||||
"IGFBP7",
|
||||
"AKR1C3",
|
||||
"IL32",
|
||||
"EFHD2",
|
||||
"PRSS23",
|
||||
"TTC38",
|
||||
"ZAP70",
|
||||
"S1PR5",
|
||||
"SAMD3",
|
||||
"GIMAP4",
|
||||
"CCL3",
|
||||
"ABI3",
|
||||
"XCL1",
|
||||
"S100A6",
|
||||
"UBB",
|
||||
"GPR56",
|
||||
"PDIA3",
|
||||
"S100A11",
|
||||
"APOBEC3G",
|
||||
"HAVCR2",
|
||||
"PLEKHF1",
|
||||
"LITAF",
|
||||
"ARPC5L",
|
||||
"PTGDR",
|
||||
"PRMT2",
|
||||
"GSTP1",
|
||||
"FCRL6",
|
||||
];
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Smoke test suite that will be run in Travis CI
|
||||
* Tests included in this file are expected to be relatively stable and test core features
|
||||
*/
|
||||
|
||||
/* eslint-disable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
|
||||
import { appUrlBase, DATASET } from "./config";
|
||||
|
||||
import { datasets } from "./data";
|
||||
|
||||
import {
|
||||
clickOn,
|
||||
getAllByClass,
|
||||
getElementCoordinates,
|
||||
getOneElementInnerHTML,
|
||||
getTestId,
|
||||
goToPage,
|
||||
waitByID,
|
||||
} from "./puppeteerUtils";
|
||||
|
||||
import {
|
||||
calcDragCoordinates,
|
||||
clip,
|
||||
drag,
|
||||
getAllCategoriesAndCounts,
|
||||
getCellSetCount,
|
||||
selectCategory,
|
||||
} from "./cellxgeneActions";
|
||||
|
||||
const data = datasets[DATASET];
|
||||
|
||||
describe("did launch", () => {
|
||||
test("page launched", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
const element = await getOneElementInnerHTML(getTestId("header"));
|
||||
|
||||
expect(element).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata loads", () => {
|
||||
test("categories and values from dataset appear", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
for (const label of Object.keys(data.categorical)) {
|
||||
const element = await getOneElementInnerHTML(
|
||||
getTestId(`category-${label}`)
|
||||
);
|
||||
|
||||
expect(element).toMatchSnapshot();
|
||||
|
||||
await clickOn(`${label}:category-expand`);
|
||||
|
||||
const categories = await getAllCategoriesAndCounts(label);
|
||||
|
||||
expect(Object.keys(categories)).toMatchObject(
|
||||
Object.keys(data.categorical[label])
|
||||
);
|
||||
|
||||
expect(Object.values(categories)).toMatchObject(
|
||||
Object.values(data.categorical[label])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("continuous data appears", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
for (const label of Object.keys(data.continuous)) {
|
||||
await waitByID(`histogram-${label}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cell selection", () => {
|
||||
test("selects all cells cellset 1", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
const cellCount = await getCellSetCount(1);
|
||||
expect(cellCount).toBe(data.dataframe.nObs);
|
||||
});
|
||||
|
||||
test("selects all cells cellset 2", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
const cellCount = await getCellSetCount(2);
|
||||
expect(cellCount).toBe(data.dataframe.nObs);
|
||||
});
|
||||
|
||||
test("selects cells via lasso", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
for (const cellset of data.cellsets.lasso) {
|
||||
const cellset1 = await calcDragCoordinates(
|
||||
"layout-graph",
|
||||
cellset["coordinates-as-percent"]
|
||||
);
|
||||
|
||||
await drag("layout-graph", cellset1.start, cellset1.end, true);
|
||||
const cellCount = await getCellSetCount(1);
|
||||
expect(cellCount).toBe(cellset.count);
|
||||
}
|
||||
});
|
||||
|
||||
test("selects cells via categorical", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
for (const cellset of data.cellsets.categorical) {
|
||||
await clickOn(`${cellset.metadata}:category-expand`);
|
||||
await clickOn(`${cellset.metadata}:category-select`);
|
||||
|
||||
for (const value of cellset.values) {
|
||||
await clickOn(`categorical-value-select-${cellset.metadata}-${value}`);
|
||||
}
|
||||
|
||||
const cellCount = await getCellSetCount(1);
|
||||
|
||||
expect(cellCount).toBe(cellset.count);
|
||||
}
|
||||
});
|
||||
|
||||
test("selects cells via continuous", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
for (const cellset of data.cellsets.continuous) {
|
||||
const histBrushableAreaId = `histogram-${cellset.metadata}-plot-brushable-area`;
|
||||
|
||||
const coords = await calcDragCoordinates(
|
||||
histBrushableAreaId,
|
||||
cellset["coordinates-as-percent"]
|
||||
);
|
||||
|
||||
await drag(histBrushableAreaId, coords.start, coords.end);
|
||||
|
||||
const cellCount = await getCellSetCount(1);
|
||||
|
||||
expect(cellCount).toBe(cellset.count);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("subset", () => {
|
||||
test("subset - cell count matches", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
for (const select of data.subset.cellset1) {
|
||||
if (select.kind === "categorical") {
|
||||
await selectCategory(select.metadata, select.values, true);
|
||||
}
|
||||
}
|
||||
|
||||
await clickOn("subset-button");
|
||||
|
||||
for (const label of Object.keys(data.subset.categorical)) {
|
||||
const categories = await getAllCategoriesAndCounts(label);
|
||||
|
||||
expect(Object.keys(categories)).toMatchObject(
|
||||
Object.keys(data.subset.categorical[label])
|
||||
);
|
||||
|
||||
expect(Object.values(categories)).toMatchObject(
|
||||
Object.values(data.subset.categorical[label])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("lasso after subset", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
for (const select of data.subset.cellset1) {
|
||||
if (select.kind === "categorical") {
|
||||
await selectCategory(select.metadata, select.values, true);
|
||||
}
|
||||
}
|
||||
|
||||
await clickOn("subset-button");
|
||||
|
||||
const lassoSelection = await calcDragCoordinates(
|
||||
"layout-graph",
|
||||
data.subset.lasso["coordinates-as-percent"]
|
||||
);
|
||||
|
||||
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
|
||||
|
||||
const cellCount = await getCellSetCount(1);
|
||||
expect(cellCount).toBe(data.subset.lasso.count);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipping", () => {
|
||||
test("clip continuous", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
await clip(data.clip.min, data.clip.max);
|
||||
const histBrushableAreaId = `histogram-${data.clip.metadata}-plot-brushable-area`;
|
||||
const coords = await calcDragCoordinates(
|
||||
histBrushableAreaId,
|
||||
data.clip["coordinates-as-percent"]
|
||||
);
|
||||
await drag(histBrushableAreaId, coords.start, coords.end);
|
||||
const cellCount = await getCellSetCount(1);
|
||||
expect(cellCount).toBe(data.clip.count);
|
||||
});
|
||||
});
|
||||
|
||||
// interact with UI elements just that they do not break
|
||||
describe("ui elements don't error", () => {
|
||||
test("color by", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
const allLabels = [
|
||||
...Object.keys(data.categorical),
|
||||
...Object.keys(data.continuous),
|
||||
];
|
||||
|
||||
for (const label of allLabels) {
|
||||
await clickOn(`colorby-${label}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("pan and zoom", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
await clickOn("mode-pan-zoom");
|
||||
const panCoords = await calcDragCoordinates(
|
||||
"layout-graph",
|
||||
data.pan["coordinates-as-percent"]
|
||||
);
|
||||
|
||||
await drag("layout-graph", panCoords.start, panCoords.end, false);
|
||||
|
||||
await page.evaluate("window.scrollBy(0, 1000);");
|
||||
});
|
||||
});
|
||||
|
||||
describe("centroid labels", () => {
|
||||
test("labels are created", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
const labels = Object.keys(data.categorical);
|
||||
|
||||
await clickOn(`colorby-${labels[0]}`);
|
||||
await clickOn("centroid-label-toggle");
|
||||
|
||||
// Toggle colorby for each category and check to see if labels are generated
|
||||
for (let i = 0, { length } = labels; i < length; i += 1) {
|
||||
const label = labels[i];
|
||||
// first label is already enabled
|
||||
if (i !== 0) await clickOn(`colorby-${label}`);
|
||||
const generatedLabels = await getAllByClass("centroid-label");
|
||||
// Number of labels generated should be equal to size of the object
|
||||
expect(generatedLabels).toHaveLength(
|
||||
Object.keys(data.categorical[label]).length
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("graph overlay", () => {
|
||||
test("transform centroids correctly", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
const category = Object.keys(data.categorical)[0];
|
||||
|
||||
await clickOn(`colorby-${category}`);
|
||||
await clickOn("centroid-label-toggle");
|
||||
await clickOn("mode-pan-zoom");
|
||||
|
||||
const panCoords = await calcDragCoordinates(
|
||||
"layout-graph",
|
||||
data.pan["coordinates-as-percent"]
|
||||
);
|
||||
|
||||
const categoryValue = Object.keys(data.categorical[category])[0];
|
||||
const initialCoordinates = await getElementCoordinates(
|
||||
`${categoryValue}-centroid-label`
|
||||
);
|
||||
|
||||
await drag("layout-graph", panCoords.start, panCoords.end, false);
|
||||
const terminalCoordinates = await getElementCoordinates(
|
||||
`${categoryValue}-centroid-label`
|
||||
);
|
||||
|
||||
expect(terminalCoordinates[0] - initialCoordinates[0]).toBeCloseTo(
|
||||
panCoords.end.x - panCoords.start.x
|
||||
);
|
||||
expect(terminalCoordinates[1] - initialCoordinates[1]).toBeCloseTo(
|
||||
panCoords.end.y - panCoords.start.y
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("pan zoom mode resets lasso selection", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
const panzoomLasso = data.features.panzoom.lasso;
|
||||
|
||||
const lassoSelection = await calcDragCoordinates(
|
||||
"layout-graph",
|
||||
panzoomLasso["coordinates-as-percent"]
|
||||
);
|
||||
|
||||
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
|
||||
await waitByID("lasso-element", { visible: true });
|
||||
|
||||
const initialCount = await getCellSetCount(1);
|
||||
|
||||
expect(initialCount).toBe(panzoomLasso.count);
|
||||
|
||||
await clickOn("mode-pan-zoom");
|
||||
await clickOn("mode-lasso");
|
||||
|
||||
const modeSwitchCount = await getCellSetCount(1);
|
||||
|
||||
expect(modeSwitchCount).toBe(initialCount);
|
||||
});
|
||||
|
||||
test("lasso moves after pan", async () => {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
const panzoomLasso = data.features.panzoom.lasso;
|
||||
const coordinatesAsPercent = panzoomLasso["coordinates-as-percent"];
|
||||
|
||||
const lassoSelection = await calcDragCoordinates(
|
||||
"layout-graph",
|
||||
coordinatesAsPercent
|
||||
);
|
||||
|
||||
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
|
||||
await waitByID("lasso-element", { visible: true });
|
||||
|
||||
const initialCount = await getCellSetCount(1);
|
||||
|
||||
expect(initialCount).toBe(panzoomLasso.count);
|
||||
|
||||
await clickOn("mode-pan-zoom");
|
||||
|
||||
const panCoords = await calcDragCoordinates(
|
||||
"layout-graph",
|
||||
coordinatesAsPercent
|
||||
);
|
||||
|
||||
await drag("layout-graph", panCoords.start, panCoords.end, false);
|
||||
await clickOn("mode-lasso");
|
||||
|
||||
const panCount = await getCellSetCount(2);
|
||||
|
||||
expect(panCount).toBe(initialCount);
|
||||
});
|
||||
/* eslint-enable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
|
||||
@@ -0,0 +1,595 @@
|
||||
/*
|
||||
Tests included in this file are specific to annotation features
|
||||
*/
|
||||
import { appUrlBase, DATASET } from "./config";
|
||||
import { datasets } from "./data";
|
||||
|
||||
import {
|
||||
clickOn,
|
||||
goToPage,
|
||||
waitByClass,
|
||||
waitByID,
|
||||
getTestId,
|
||||
getTestClass,
|
||||
getAllByClass,
|
||||
clickOnUntil,
|
||||
getOneElementInnerHTML,
|
||||
} from "./puppeteerUtils";
|
||||
|
||||
import {
|
||||
assertCategoryDoesNotExist,
|
||||
calcDragCoordinates,
|
||||
createCategory,
|
||||
createLabel,
|
||||
deleteCategory,
|
||||
deleteLabel,
|
||||
drag,
|
||||
expandCategory,
|
||||
renameCategory,
|
||||
renameLabel,
|
||||
subset,
|
||||
duplicateCategory,
|
||||
createGeneset,
|
||||
deleteGeneset,
|
||||
assertGenesetExists,
|
||||
assertGenesetDoesNotExist,
|
||||
getCellSetCount,
|
||||
expandGeneset,
|
||||
editGenesetName,
|
||||
addGeneToSet,
|
||||
assertGeneExistsInGeneset,
|
||||
removeGene,
|
||||
assertGeneDoesNotExist,
|
||||
expandGene,
|
||||
colorByGeneset,
|
||||
assertColorLegendLabel,
|
||||
colorByGene,
|
||||
} from "./cellxgeneActions";
|
||||
|
||||
const data = datasets[DATASET];
|
||||
|
||||
const perTestCategoryName = "TEST-CATEGORY";
|
||||
const perTestLabelName = "TEST-LABEL";
|
||||
|
||||
// geneset CRUD
|
||||
const genesetToDeleteName = "geneset_to_delete";
|
||||
const preExistingGenesetName = "fifth_dataset";
|
||||
const meanExpressionBrushGenesetName = "second_gene_set";
|
||||
const meanExpressionBrushCellsSelected = "557";
|
||||
const subsetMeanExpressionBrushCellsSelected = "452";
|
||||
|
||||
// initial text, the text we type in, the result
|
||||
const editableGenesetName = "geneset_to_edit";
|
||||
const editText = "_111";
|
||||
const newGenesetName = "geneset_to_edit_111";
|
||||
|
||||
// add gene to set
|
||||
const geneToAddToSet = "RER1";
|
||||
const setToAddGeneTo = "fill_this_geneset";
|
||||
|
||||
// remove gene from set
|
||||
const geneToRemove = "SIK1";
|
||||
const setToRemoveFrom = "empty_this_geneset";
|
||||
|
||||
// brush a gene
|
||||
const geneToBrushAndColorBy = "SIK1";
|
||||
const brushThisGeneGeneset = "brush_this_gene";
|
||||
const geneBrushedCellCount = "109";
|
||||
const subsetGeneBrushedCellCount = "96";
|
||||
|
||||
const genesetDescriptionID =
|
||||
"geneset-description-tooltip-fourth_gene_set: fourth description";
|
||||
const genesetDescriptionString = "fourth_gene_set: fourth description";
|
||||
const genesetToCheckForDescription = "fourth_gene_set";
|
||||
|
||||
async function setup(config) {
|
||||
await goToPage(appUrlBase);
|
||||
|
||||
if (config.categoricalAnno) {
|
||||
// setup the test fixtures
|
||||
await createCategory(perTestCategoryName);
|
||||
await createLabel(perTestCategoryName, perTestLabelName);
|
||||
}
|
||||
|
||||
if (config.withSubset) {
|
||||
await subset({ x1: 0.1, y1: 0.1, x2: 0.8, y2: 0.8 });
|
||||
}
|
||||
|
||||
await waitByClass("autosave-complete");
|
||||
}
|
||||
|
||||
describe.each([
|
||||
{ withSubset: true, tag: "subset" },
|
||||
{ withSubset: false, tag: "whole" },
|
||||
])("geneSET crud operations and interactions", (config) => {
|
||||
test("genesets load from csv", async () => {
|
||||
await setup(config);
|
||||
|
||||
await assertGenesetExists(preExistingGenesetName);
|
||||
});
|
||||
test("brush on geneset mean", async () => {
|
||||
await setup(config);
|
||||
|
||||
await expandGeneset(meanExpressionBrushGenesetName);
|
||||
|
||||
const histBrushableAreaId = `histogram-${meanExpressionBrushGenesetName}-plot-brushable-area`;
|
||||
|
||||
const coords = await calcDragCoordinates(histBrushableAreaId, {
|
||||
x1: 0.25,
|
||||
y1: 0.5,
|
||||
x2: 0.55,
|
||||
y2: 0.5,
|
||||
});
|
||||
|
||||
await drag(histBrushableAreaId, coords.start, coords.end);
|
||||
|
||||
const cellCount = await getCellSetCount(1);
|
||||
if (config.withSubset) {
|
||||
expect(cellCount).toBe(subsetMeanExpressionBrushCellsSelected);
|
||||
} else {
|
||||
expect(cellCount).toBe(meanExpressionBrushCellsSelected);
|
||||
}
|
||||
});
|
||||
test("color by mean expression", async () => {
|
||||
await setup(config);
|
||||
|
||||
await colorByGeneset(meanExpressionBrushGenesetName);
|
||||
await assertColorLegendLabel(meanExpressionBrushGenesetName);
|
||||
});
|
||||
test("diffexp", async () => {
|
||||
if (config.withSubset) return;
|
||||
|
||||
await setup(config);
|
||||
|
||||
// set the two cell sets to b cells vs nk cells
|
||||
await expandCategory(`louvain`);
|
||||
await clickOn(`louvain:category-select`);
|
||||
await clickOn(`categorical-value-select-louvain-B cells`);
|
||||
await clickOn(`cellset-button-1`);
|
||||
await clickOn(`categorical-value-select-louvain-B cells`);
|
||||
await clickOn(`categorical-value-select-louvain-NK cells`);
|
||||
await clickOn(`cellset-button-2`);
|
||||
|
||||
// run diffexp
|
||||
await clickOn(`diffexp-button`);
|
||||
await waitByClass("pop-1-geneset-expand");
|
||||
await expect(page).toClick(getTestClass("pop-1-geneset-expand"));
|
||||
|
||||
await page.waitForFunction(
|
||||
(selector) => !document.querySelector(selector),
|
||||
{},
|
||||
getTestClass("gene-loading-spinner")
|
||||
);
|
||||
|
||||
let genesHTML = await getOneElementInnerHTML(
|
||||
getTestClass("gene-set-genes")
|
||||
);
|
||||
|
||||
expect(genesHTML).toMatchSnapshot();
|
||||
|
||||
await expect(page).toClick(getTestClass("pop-1-geneset-expand"));
|
||||
await expect(page).toClick(getTestClass("pop-2-geneset-expand"));
|
||||
|
||||
await page.waitForFunction(
|
||||
(selector) => !document.querySelector(selector),
|
||||
{},
|
||||
getTestClass("gene-loading-spinner")
|
||||
);
|
||||
|
||||
genesHTML = await getOneElementInnerHTML(getTestClass("gene-set-genes"));
|
||||
|
||||
expect(genesHTML).toMatchSnapshot();
|
||||
});
|
||||
test("create a new geneset and undo/redo", async () => {
|
||||
if (config.withSubset) return;
|
||||
|
||||
await setup(config);
|
||||
|
||||
const genesetName = `test-geneset-foo-123`;
|
||||
await assertGenesetDoesNotExist(genesetName);
|
||||
await createGeneset(genesetName);
|
||||
/* note: as of June 2021, the aria label is in the truncate component which clones the element */
|
||||
await assertGenesetExists(genesetName);
|
||||
await clickOn("undo");
|
||||
await assertGenesetDoesNotExist(genesetName);
|
||||
await clickOn("redo");
|
||||
await assertGenesetExists(genesetName);
|
||||
});
|
||||
test("edit geneset name and undo/redo", async () => {
|
||||
await setup(config);
|
||||
|
||||
await editGenesetName(editableGenesetName, editText);
|
||||
await assertGenesetExists(newGenesetName);
|
||||
await clickOn("undo");
|
||||
await assertGenesetExists(editableGenesetName);
|
||||
await clickOn("redo");
|
||||
await assertGenesetExists(newGenesetName);
|
||||
});
|
||||
test("delete a geneset and undo/redo", async () => {
|
||||
if (config.withSubset) return;
|
||||
|
||||
await setup(config);
|
||||
|
||||
await deleteGeneset(genesetToDeleteName);
|
||||
await clickOn("undo");
|
||||
await assertGenesetExists(genesetToDeleteName);
|
||||
await clickOn("redo");
|
||||
await assertGenesetDoesNotExist(genesetToDeleteName);
|
||||
});
|
||||
test("geneset description", async () => {
|
||||
if (config.withSubset) return;
|
||||
|
||||
await setup(config);
|
||||
|
||||
await clickOnUntil(
|
||||
`${genesetToCheckForDescription}:geneset-expand`,
|
||||
async () => {
|
||||
expect(page).toMatchElement(getTestId(genesetDescriptionID), {
|
||||
text: genesetDescriptionString,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ withSubset: true, tag: "subset" },
|
||||
{ withSubset: false, tag: "whole" },
|
||||
])("GENE crud operations and interactions", (config) => {
|
||||
test("add a gene to geneset and undo/redo", async () => {
|
||||
await setup(config);
|
||||
|
||||
await addGeneToSet(setToAddGeneTo, geneToAddToSet);
|
||||
await expandGeneset(setToAddGeneTo);
|
||||
await assertGeneExistsInGeneset(geneToAddToSet);
|
||||
await clickOn("undo");
|
||||
await assertGeneDoesNotExist(geneToAddToSet);
|
||||
await clickOn("redo");
|
||||
await assertGeneExistsInGeneset(geneToAddToSet);
|
||||
});
|
||||
test("expand gene and brush", async () => {
|
||||
await setup(config);
|
||||
|
||||
await expandGeneset(brushThisGeneGeneset);
|
||||
await expandGene(geneToBrushAndColorBy);
|
||||
const histBrushableAreaId = `histogram-${geneToBrushAndColorBy}-plot-brushable-area`;
|
||||
|
||||
const coords = await calcDragCoordinates(histBrushableAreaId, {
|
||||
x1: 0.25,
|
||||
y1: 0.5,
|
||||
x2: 0.55,
|
||||
y2: 0.5,
|
||||
});
|
||||
await drag(histBrushableAreaId, coords.start, coords.end);
|
||||
const cellCount = await getCellSetCount(1);
|
||||
if (config.withSubset) {
|
||||
expect(cellCount).toBe(subsetGeneBrushedCellCount);
|
||||
} else {
|
||||
expect(cellCount).toBe(geneBrushedCellCount);
|
||||
}
|
||||
});
|
||||
test("color by gene in geneset", async () => {
|
||||
await setup(config);
|
||||
|
||||
await expandGeneset(meanExpressionBrushGenesetName);
|
||||
|
||||
await colorByGene(geneToBrushAndColorBy);
|
||||
await assertColorLegendLabel(geneToBrushAndColorBy);
|
||||
});
|
||||
test("delete gene from geneset and undo/redo", async () => {
|
||||
// We've already deleted the gene
|
||||
if (config.withSubset) return;
|
||||
|
||||
await setup(config);
|
||||
|
||||
await expandGeneset(setToRemoveFrom);
|
||||
await removeGene(geneToRemove);
|
||||
await assertGeneDoesNotExist(geneToRemove);
|
||||
await clickOn("undo");
|
||||
await assertGeneExistsInGeneset(geneToRemove);
|
||||
await clickOn("redo");
|
||||
await assertGeneDoesNotExist(geneToRemove);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ withSubset: true, tag: "subset", categoricalAnno: true },
|
||||
{ withSubset: false, tag: "whole", categoricalAnno: true },
|
||||
])("annotations", (config) => {
|
||||
test("create a category", async () => {
|
||||
await setup(config);
|
||||
|
||||
const categoryName = `category-created-${config.tag}`;
|
||||
|
||||
await assertCategoryDoesNotExist(categoryName);
|
||||
|
||||
await createCategory(categoryName);
|
||||
|
||||
await assertCategoryExists(categoryName);
|
||||
});
|
||||
|
||||
test("delete a category", async () => {
|
||||
await setup(config);
|
||||
|
||||
await deleteCategory(perTestCategoryName);
|
||||
await assertCategoryDoesNotExist(perTestCategoryName);
|
||||
});
|
||||
|
||||
test("rename a category", async () => {
|
||||
await setup(config);
|
||||
|
||||
const newCategoryName = `NEW-${config.tag}`;
|
||||
|
||||
await renameCategory(perTestCategoryName, newCategoryName);
|
||||
await assertCategoryDoesNotExist(perTestCategoryName);
|
||||
await assertCategoryExists(newCategoryName);
|
||||
});
|
||||
|
||||
test("create a label", async () => {
|
||||
await setup(config);
|
||||
|
||||
const labelName = `new-label-${config.tag}`;
|
||||
|
||||
await assertLabelDoesNotExist(perTestCategoryName, labelName);
|
||||
|
||||
await createLabel(perTestCategoryName, labelName);
|
||||
|
||||
await assertLabelExists(perTestCategoryName, labelName);
|
||||
});
|
||||
|
||||
test("delete a label", async () => {
|
||||
await setup(config);
|
||||
|
||||
await deleteLabel(perTestCategoryName, perTestLabelName);
|
||||
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
||||
});
|
||||
|
||||
test("rename a label", async () => {
|
||||
await setup(config);
|
||||
|
||||
const newLabelName = "my-cool-new-label";
|
||||
|
||||
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
|
||||
await renameLabel(perTestCategoryName, perTestLabelName, newLabelName);
|
||||
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
||||
await assertLabelExists(perTestCategoryName, newLabelName);
|
||||
});
|
||||
|
||||
test("check cell count for a label loaded from file", async () => {
|
||||
await setup(config);
|
||||
|
||||
const duplicateCategoryName = "duplicate";
|
||||
await duplicateCategory(duplicateCategoryName);
|
||||
|
||||
await page.reload({ waitUntil: ["networkidle0", "domcontentloaded"] });
|
||||
|
||||
const firstCategoryExpandIcon = await expect(page).toMatchElement(
|
||||
getTestClass("category-expand")
|
||||
);
|
||||
|
||||
await firstCategoryExpandIcon.click();
|
||||
|
||||
const expectedCategoryRow = await expect(page).toMatchElement(
|
||||
getTestClass("categorical-row")
|
||||
);
|
||||
const expectedLabelName = await getInnerText(
|
||||
expectedCategoryRow,
|
||||
"categorical-value"
|
||||
);
|
||||
const expectedLabelCount = await getInnerText(
|
||||
expectedCategoryRow,
|
||||
"categorical-value-count"
|
||||
);
|
||||
|
||||
await expandCategory(duplicateCategoryName);
|
||||
|
||||
const expectedCategory = await expect(page).toMatchElement(
|
||||
getTestClass("category")
|
||||
);
|
||||
|
||||
const actualCategoryRow = await expect(expectedCategory).toMatchElement(
|
||||
getTestClass("categorical-row")
|
||||
);
|
||||
const actualLabelName = await getInnerText(
|
||||
actualCategoryRow,
|
||||
"categorical-value"
|
||||
);
|
||||
const actualLabelCount = await getInnerText(
|
||||
actualCategoryRow,
|
||||
"categorical-value-count"
|
||||
);
|
||||
|
||||
expect(actualLabelName).toBe(expectedLabelName);
|
||||
expect(actualLabelCount).toBe(expectedLabelCount);
|
||||
|
||||
async function getInnerText(element, className) {
|
||||
return element.$eval(getTestClass(className), (node) => node?.innerText);
|
||||
}
|
||||
});
|
||||
|
||||
test("assign cells to a label", async () => {
|
||||
await setup(config);
|
||||
|
||||
await expandCategory(perTestCategoryName);
|
||||
|
||||
const lassoSelection = await calcDragCoordinates(
|
||||
"layout-graph",
|
||||
data.categoryLabel.lasso["coordinates-as-percent"]
|
||||
);
|
||||
|
||||
await drag("layout-graph", lassoSelection.start, lassoSelection.end, true);
|
||||
await waitByID("lasso-element", { visible: true });
|
||||
await clickOn(`${perTestCategoryName}:${perTestLabelName}:see-actions`);
|
||||
await clickOn(
|
||||
`${perTestCategoryName}:${perTestLabelName}:add-current-selection-to-this-label`
|
||||
);
|
||||
|
||||
const result = await waitByID(
|
||||
`categorical-value-count-${perTestCategoryName}-${perTestLabelName}`
|
||||
);
|
||||
|
||||
expect(await result.evaluate((node) => node.innerText)).toBe(
|
||||
data.categoryLabel.newCount.bySubsetConfig[config.withSubset]
|
||||
);
|
||||
});
|
||||
|
||||
test("undo/redo category creation", async () => {
|
||||
await setup(config);
|
||||
|
||||
const categoryName = `category-created-undo-${config.tag}`;
|
||||
|
||||
await assertCategoryDoesNotExist(categoryName);
|
||||
await createCategory(categoryName);
|
||||
await assertCategoryExists(categoryName);
|
||||
await clickOn("undo");
|
||||
await assertCategoryDoesNotExist(categoryName);
|
||||
await clickOn("redo");
|
||||
await assertCategoryExists(categoryName);
|
||||
});
|
||||
|
||||
test("undo/redo category deletion", async () => {
|
||||
await setup(config);
|
||||
|
||||
const categoryName = `category-deleted-undo-${config.tag}`;
|
||||
|
||||
await createCategory(categoryName);
|
||||
await assertCategoryExists(categoryName);
|
||||
await deleteCategory(categoryName);
|
||||
await assertCategoryDoesNotExist(categoryName);
|
||||
await clickOn("undo");
|
||||
await assertCategoryExists(categoryName);
|
||||
await clickOn("redo");
|
||||
await assertCategoryDoesNotExist(categoryName);
|
||||
});
|
||||
|
||||
test("undo/redo category rename", async () => {
|
||||
await setup(config);
|
||||
|
||||
const newCategoryName = `category-renamed-undo-${config.tag}`;
|
||||
|
||||
await assertCategoryDoesNotExist(newCategoryName);
|
||||
await renameCategory(perTestCategoryName, newCategoryName);
|
||||
await assertCategoryExists(newCategoryName);
|
||||
await assertCategoryDoesNotExist(perTestCategoryName);
|
||||
await clickOn("undo");
|
||||
await assertCategoryExists(perTestCategoryName);
|
||||
await assertCategoryDoesNotExist(newCategoryName);
|
||||
await clickOn("redo");
|
||||
await assertCategoryExists(newCategoryName);
|
||||
await assertCategoryDoesNotExist(perTestCategoryName);
|
||||
});
|
||||
|
||||
test("undo/redo label creation", async () => {
|
||||
await setup(config);
|
||||
|
||||
const labelName = `label-created-undo-${config.tag}`;
|
||||
|
||||
await assertLabelDoesNotExist(perTestCategoryName, labelName);
|
||||
await createLabel(perTestCategoryName, labelName);
|
||||
await assertLabelExists(perTestCategoryName, labelName);
|
||||
await clickOn("undo");
|
||||
await assertLabelDoesNotExist(perTestCategoryName);
|
||||
await clickOn("redo");
|
||||
await assertLabelExists(perTestCategoryName, labelName);
|
||||
});
|
||||
|
||||
test("undo/redo label deletion", async () => {
|
||||
await setup(config);
|
||||
|
||||
await deleteLabel(perTestCategoryName, perTestLabelName);
|
||||
await assertLabelDoesNotExist(perTestCategoryName);
|
||||
await clickOn("undo");
|
||||
await assertLabelExists(perTestCategoryName, perTestLabelName);
|
||||
await clickOn("redo");
|
||||
await assertLabelDoesNotExist(perTestCategoryName);
|
||||
});
|
||||
|
||||
test("undo/redo label rename", async () => {
|
||||
await setup(config);
|
||||
|
||||
const newLabelName = `label-renamed-undo-${config.tag}`;
|
||||
|
||||
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
|
||||
await renameLabel(perTestCategoryName, perTestLabelName, newLabelName);
|
||||
await assertLabelExists(perTestCategoryName, newLabelName);
|
||||
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
||||
await clickOn("undo");
|
||||
await assertLabelExists(perTestCategoryName, perTestLabelName);
|
||||
await assertLabelDoesNotExist(perTestCategoryName, newLabelName);
|
||||
await clickOn("redo");
|
||||
await assertLabelExists(perTestCategoryName, newLabelName);
|
||||
await assertLabelDoesNotExist(perTestCategoryName, perTestLabelName);
|
||||
});
|
||||
|
||||
test("stacked bar graph renders", async () => {
|
||||
await setup(config);
|
||||
|
||||
await expandCategory(perTestCategoryName);
|
||||
|
||||
await clickOn(`colorby-louvain`);
|
||||
|
||||
const labels = await getAllByClass("categorical-row");
|
||||
|
||||
const result = await Promise.all(
|
||||
labels.map((label) => page.evaluate((element) => element.outerHTML, label))
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("truncate midpoint whitespace", async () => {
|
||||
await setup(config);
|
||||
const newLabelName = "123 456";
|
||||
await renameLabel(perTestCategoryName, perTestLabelName, newLabelName);
|
||||
const value = await waitByID(
|
||||
`categorical-value-${perTestCategoryName}-${newLabelName}`
|
||||
);
|
||||
const result = await page.evaluate((elem) => elem.outerHTML, value);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("truncate single character", async () => {
|
||||
await setup(config);
|
||||
const newLabelName = "T";
|
||||
await renameLabel(perTestCategoryName, perTestLabelName, newLabelName);
|
||||
const value = await waitByID(
|
||||
`categorical-value-${perTestCategoryName}-${newLabelName}`
|
||||
);
|
||||
const result = await page.evaluate((elem) => elem.outerHTML, value);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
async function assertCategoryExists(categoryName) {
|
||||
const handle = await waitByID(`${categoryName}:category-label`);
|
||||
|
||||
const result = await handle.evaluate((node) =>
|
||||
node.getAttribute("aria-label")
|
||||
);
|
||||
|
||||
return expect(result).toBe(categoryName);
|
||||
}
|
||||
|
||||
async function assertLabelExists(categoryName, labelName) {
|
||||
await expect(page).toMatchElement(
|
||||
getTestId(`${categoryName}:category-expand`)
|
||||
);
|
||||
|
||||
await expandCategory(categoryName);
|
||||
|
||||
const previous = await waitByID(
|
||||
`categorical-value-${categoryName}-${labelName}`
|
||||
);
|
||||
|
||||
expect(
|
||||
await previous.evaluate((node) => node.getAttribute("aria-label"))
|
||||
).toBe(labelName);
|
||||
}
|
||||
|
||||
async function assertLabelDoesNotExist(categoryName, labelName) {
|
||||
await expandCategory(categoryName);
|
||||
const result = await page.$(
|
||||
`[data-testid='categorical-value-${categoryName}-${labelName}']`
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"testRunner": "jest-circus/runner",
|
||||
"preset": "jest-puppeteer",
|
||||
"testMatch": ["**/__tests__/**/?(*.)(spec|test).js?(x)"],
|
||||
"setupFiles": ["../setupMissingGlobals.js"],
|
||||
"setupFilesAfterEnv": ["expect-puppeteer", "./puppeteer.setup.js"],
|
||||
"globalSetup": "../globalSetup.js",
|
||||
"globalTeardown": "jest-environment-puppeteer/teardown",
|
||||
"testEnvironment": "./screenshot_env.js"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* `client/jest-puppeteer.config.js` is for configuring Puppeteer's launch config options
|
||||
* `client/__tests__/e2e/puppeteer.setup.js` is for configuring `jest`, `browser`,
|
||||
* and `page` objects
|
||||
*/
|
||||
|
||||
import { setDefaultOptions } from "expect-puppeteer";
|
||||
import { isDebug, isDev } from "./config";
|
||||
import * as ENV_DEFAULT from "../../../environment.default.json";
|
||||
|
||||
// (thuang): This is the max time a test can take to run.
|
||||
// Since when debugging, we run slowMo and !headless, this means
|
||||
// a test can take more time to finish, so we don't want
|
||||
// jest to shut off the test too soon
|
||||
jest.setTimeout(2 * 60 * 1000);
|
||||
setDefaultOptions({ timeout: 20 * 1000 });
|
||||
|
||||
jest.retryTimes(ENV_DEFAULT.RETRY_ATTEMPTS);
|
||||
|
||||
beforeEach(async () => {
|
||||
await jestPuppeteer.resetBrowser();
|
||||
|
||||
const userAgent = await browser.userAgent();
|
||||
await page.setUserAgent(`${userAgent}bot`);
|
||||
|
||||
await page._client.send("Animation.setPlaybackRate", { playbackRate: 12 });
|
||||
|
||||
page.on("pageerror", (err) => {
|
||||
throw new Error(`Console error: ${err}`);
|
||||
});
|
||||
|
||||
page.on("error", (err) => {
|
||||
throw new Error(`Console error: ${err}`);
|
||||
});
|
||||
|
||||
page.on("console", async (msg) => {
|
||||
if (isDev || isDebug) {
|
||||
// If there is a console.error but an error is not thrown, this will ensure the test fails
|
||||
console.log(`PAGE LOG: ${msg.text()}`);
|
||||
if (msg.type() === "error") {
|
||||
// TODO: chromium does not currently support the CSP directive on the
|
||||
// line below, so we swallow this error. Remove this when the test
|
||||
// suite uses a browser version that supports this directive.
|
||||
if (
|
||||
msg.text() ===
|
||||
"Unrecognized Content-Security-Policy directive 'require-trusted-types-for'.\n"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const errorMsgText = await Promise.all(
|
||||
// TODO can we do this without internal properties?
|
||||
msg.args().map((arg) => arg._remoteObject.description)
|
||||
);
|
||||
throw new Error(`Console error: ${errorMsgText}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/* eslint-disable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
|
||||
export function getTestId(id) {
|
||||
return `[data-testid='${id}']`;
|
||||
}
|
||||
|
||||
export function getTestClass(className) {
|
||||
return `[data-testclass='${className}']`;
|
||||
}
|
||||
|
||||
export async function waitByID(testId, props = {}) {
|
||||
return page.waitForSelector(getTestId(testId), props);
|
||||
}
|
||||
|
||||
export async function waitByClass(testClass, props = {}) {
|
||||
return page.waitForSelector(`[data-testclass='${testClass}']`, props);
|
||||
}
|
||||
|
||||
export async function waitForAllByIds(testIds) {
|
||||
await Promise.all(
|
||||
testIds.map((testId) => page.waitForSelector(getTestId(testId)))
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllByClass(testClass) {
|
||||
return page.$$(`[data-testclass=${testClass}]`);
|
||||
}
|
||||
|
||||
export async function typeInto(testId, text) {
|
||||
// blueprint's typeahead is treating typing weird, clicking & waiting first solves this
|
||||
// only works for text without special characters
|
||||
await waitByID(testId);
|
||||
const selector = getTestId(testId);
|
||||
// type ahead can be annoying if you don't pause before you type
|
||||
await page.click(selector);
|
||||
await page.waitForTimeout(200);
|
||||
await page.type(selector, text);
|
||||
}
|
||||
|
||||
export async function clearInputAndTypeInto(testId, text) {
|
||||
await waitByID(testId);
|
||||
const selector = getTestId(testId);
|
||||
// only works for text without special characters
|
||||
// type ahead can be annoying if you don't pause before you type
|
||||
await page.click(selector);
|
||||
await page.waitForTimeout(200);
|
||||
// select all
|
||||
await page.click(selector, { clickCount: 3 });
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.type(selector, text);
|
||||
}
|
||||
|
||||
export async function clickOn(testId, options = {}) {
|
||||
await expect(page).toClick(getTestId(testId), options);
|
||||
}
|
||||
|
||||
/**
|
||||
* (thuang): There are times when Puppeteer clicks on a button and the page doesn't respond.
|
||||
* So I added clickOnUntil() to retry clicking until a given condition is met.
|
||||
*/
|
||||
export async function clickOnUntil(testId, assert) {
|
||||
const MAX_RETRY = 10;
|
||||
const WAIT_FOR_MS = 200;
|
||||
|
||||
let retry = 0;
|
||||
|
||||
while (retry < MAX_RETRY) {
|
||||
try {
|
||||
await clickOn(testId);
|
||||
await assert();
|
||||
|
||||
break;
|
||||
} catch (error) {
|
||||
retry += 1;
|
||||
|
||||
await page.waitForTimeout(WAIT_FOR_MS);
|
||||
}
|
||||
}
|
||||
|
||||
if (retry === MAX_RETRY) {
|
||||
throw Error("clickOnUntil() assertion failed!");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOneElementInnerHTML(selector, options = {}) {
|
||||
await page.waitForSelector(selector, options);
|
||||
|
||||
return page.$eval(selector, (el) => el.innerHTML);
|
||||
}
|
||||
|
||||
export async function getOneElementInnerText(selector) {
|
||||
expect(page).toMatchElement(selector);
|
||||
|
||||
return page.$eval(selector, (el) => el.innerText);
|
||||
}
|
||||
|
||||
export async function getElementCoordinates(testId) {
|
||||
return page.$eval(getTestId(testId), (elem) => {
|
||||
const { left, top } = elem.getBoundingClientRect();
|
||||
return [left, top];
|
||||
});
|
||||
}
|
||||
|
||||
async function nameNewAnnotation() {
|
||||
if (await isElementPresent(getTestId("annotation-dialog"))) {
|
||||
await typeInto("new-annotation-name", "ignoreE2E");
|
||||
await clickOn("submit-annotation");
|
||||
|
||||
// wait for the page to load
|
||||
await waitByClass("autosave-complete");
|
||||
}
|
||||
}
|
||||
|
||||
export async function goToPage(url) {
|
||||
await page.goto(url, {
|
||||
waitUntil: "networkidle0",
|
||||
});
|
||||
|
||||
await nameNewAnnotation();
|
||||
}
|
||||
|
||||
export async function isElementPresent(selector, options) {
|
||||
return Boolean(await page.$(selector, options));
|
||||
}
|
||||
/* eslint-enable no-await-in-loop -- await in loop is needed to emulate sequential user actions */
|
||||
@@ -0,0 +1,36 @@
|
||||
const PuppeteerEnvironment = require("jest-environment-puppeteer");
|
||||
require("jest-circus");
|
||||
const ENV_DEFAULT = require("../../../environment.default.json");
|
||||
|
||||
const takeScreenshot = require("./takeScreenshot");
|
||||
|
||||
class ScreenshotEnvironment extends PuppeteerEnvironment {
|
||||
async handleTestEvent(event, state) {
|
||||
if (["test_start", "test_done"].includes(event.name)) {
|
||||
console.log("------------------event name:\n", event.name);
|
||||
console.log("~~~~ Current test errors\n", new Date(), event.test.errors);
|
||||
console.log("~~~~ Current test\n", new Date(), event.test);
|
||||
}
|
||||
|
||||
if (event.name === "error") {
|
||||
console.log("error event:", JSON.stringify(event));
|
||||
}
|
||||
|
||||
if (event.name === "test_fn_failure" || event.name === "hook_failure") {
|
||||
console.log("------------------event name:\n", event.name);
|
||||
console.log(">>>> Current state\n", new Date(), state);
|
||||
console.log("===> Failure event\n", new Date(), event);
|
||||
|
||||
// (thuang): We only want to take screenshot on the last try
|
||||
if (
|
||||
state.currentlyRunningTest.invocations <= ENV_DEFAULT.RETRY_ATTEMPTS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await takeScreenshot(state.currentlyRunningTest.name, this.global.page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ScreenshotEnvironment;
|
||||
@@ -0,0 +1,17 @@
|
||||
function toFilename(name) {
|
||||
return name.replace(/[^a-z0-9.-]+/gi, "-");
|
||||
}
|
||||
|
||||
async function takeScreenshot(currentTestName, page) {
|
||||
const testName = toFilename(currentTestName);
|
||||
|
||||
// Take a screenshot at the point of failure
|
||||
const date = new Date().toISOString();
|
||||
const screenshotName = `${date}-${testName}.png`;
|
||||
|
||||
await page.screenshot({
|
||||
path: `./__tests__/screenshots/ignoreE2E-screenshot-${screenshotName}`,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = takeScreenshot;
|
||||
@@ -0,0 +1,19 @@
|
||||
server:
|
||||
app:
|
||||
force_https: true
|
||||
port: 5005
|
||||
|
||||
dataset:
|
||||
presentation:
|
||||
max_categories: 1000
|
||||
custom_colors: true
|
||||
|
||||
user_annotations:
|
||||
enable: false
|
||||
type: local_file_csv
|
||||
local_file_csv:
|
||||
directory: null
|
||||
file: null
|
||||
|
||||
embeddings:
|
||||
names: []
|
||||
@@ -0,0 +1,5 @@
|
||||
const { setup } = require("jest-environment-puppeteer");
|
||||
|
||||
module.exports = async () => {
|
||||
await setup();
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import cascadeReducers from "../../src/reducers/cascade";
|
||||
|
||||
describe("create", () => {
|
||||
test("from Array", () => {
|
||||
expect(cascadeReducers([["foo", () => 0]])).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("from Map", () => {
|
||||
expect(cascadeReducers(new Map([["foo", () => 0]]))).toBeInstanceOf(
|
||||
Function
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cascade", () => {
|
||||
test("expected arguments provided & cascade ordering", () => {
|
||||
const topLevelState = {};
|
||||
const topLevelAction = { type: "test" };
|
||||
|
||||
const reducer = cascadeReducers([
|
||||
[
|
||||
"foo",
|
||||
(currentState, action, nextSharedState, prevSharedState) => {
|
||||
expect(currentState).toBeUndefined();
|
||||
expect(action).toEqual(topLevelAction);
|
||||
expect(nextSharedState).toStrictEqual({});
|
||||
expect(prevSharedState).toBe(topLevelState);
|
||||
return 0;
|
||||
},
|
||||
],
|
||||
[
|
||||
"bar",
|
||||
(currentState, action, nextSharedState, prevSharedState) => {
|
||||
expect(currentState).toBeUndefined();
|
||||
expect(action).toEqual(topLevelAction);
|
||||
expect(nextSharedState).toStrictEqual({ foo: 0 });
|
||||
expect(prevSharedState).toBe(topLevelState);
|
||||
return 99;
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const nextState = reducer(topLevelState, topLevelAction);
|
||||
expect(nextState).toStrictEqual({ foo: 0, bar: 99 });
|
||||
expect(topLevelState).toStrictEqual({});
|
||||
expect(topLevelAction).toStrictEqual({ type: "test" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
import genesetsReducer from "../../src/reducers/genesets";
|
||||
|
||||
describe("initial reducer state", () => {
|
||||
test("some other action", () => {
|
||||
expect(genesetsReducer(undefined, { type: "foo" })).toMatchObject({
|
||||
initialized: false,
|
||||
lastTid: undefined,
|
||||
genesets: new Map(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("geneset: initial load", () => {
|
||||
test("missing JSON response", () => {
|
||||
expect(() =>
|
||||
genesetsReducer(undefined, {
|
||||
type: "geneset: initial load",
|
||||
})
|
||||
).toThrow("missing or malformed JSON response");
|
||||
});
|
||||
|
||||
test("empty geneset", () => {
|
||||
expect(
|
||||
genesetsReducer(undefined, {
|
||||
type: "geneset: initial load",
|
||||
data: {
|
||||
tid: 0,
|
||||
genesets: [],
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
initialized: true,
|
||||
lastTid: 0,
|
||||
genesets: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
test("non-empty geneset", () => {
|
||||
expect(
|
||||
genesetsReducer(undefined, {
|
||||
type: "geneset: initial load",
|
||||
data: {
|
||||
tid: 99,
|
||||
genesets: [
|
||||
{
|
||||
geneset_name: "G1",
|
||||
genes: [{ gene_symbol: "F5" }],
|
||||
},
|
||||
{
|
||||
geneset_name: "G2",
|
||||
geneset_description: "G2 desc",
|
||||
genes: [{ gene_symbol: "F6" }],
|
||||
},
|
||||
{
|
||||
geneset_name: "G3",
|
||||
geneset_description: "G3 desc",
|
||||
genes: [{ gene_symbol: "F7", gene_description: "gene desc" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
initialized: true,
|
||||
lastTid: 99,
|
||||
genesets: new Map([
|
||||
[
|
||||
"G1",
|
||||
{
|
||||
genesetName: "G1",
|
||||
genesetDescription: "",
|
||||
genes: new Map([["F5", { geneSymbol: "F5", geneDescription: "" }]]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"G2",
|
||||
{
|
||||
genesetName: "G2",
|
||||
genesetDescription: "G2 desc",
|
||||
genes: new Map([["F6", { geneSymbol: "F6", geneDescription: "" }]]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"G3",
|
||||
{
|
||||
genesetName: "G3",
|
||||
genesetDescription: "G3 desc",
|
||||
genes: new Map([
|
||||
["F7", { geneSymbol: "F7", geneDescription: "gene desc" }],
|
||||
]),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("geneset: create", () => {
|
||||
const initialState = genesetsReducer(undefined, {
|
||||
type: "geneset: initial load",
|
||||
data: {
|
||||
tid: 0,
|
||||
genesets: [],
|
||||
},
|
||||
});
|
||||
|
||||
test("simple create", () => {
|
||||
expect(
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: create",
|
||||
genesetName: "a geneset",
|
||||
genesetDescription: "",
|
||||
})
|
||||
).toMatchObject({
|
||||
...initialState,
|
||||
genesets: new Map([
|
||||
[
|
||||
"a geneset",
|
||||
{
|
||||
genesetName: "a geneset",
|
||||
genesetDescription: "",
|
||||
genes: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
test("error - duplicate name", () => {
|
||||
expect(() => {
|
||||
genesetsReducer(
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: create",
|
||||
genesetName: "foo",
|
||||
genesetDescription: "foo",
|
||||
}),
|
||||
{
|
||||
type: "geneset: create",
|
||||
genesetName: "foo",
|
||||
genesetDescription: "bar",
|
||||
}
|
||||
);
|
||||
}).toThrow("name already defined");
|
||||
});
|
||||
|
||||
test("error - missing required action values", () => {
|
||||
expect(() => {
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: create",
|
||||
genesetDescription: "foo",
|
||||
});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: create",
|
||||
genesetName: "foo",
|
||||
});
|
||||
}).toThrow("name or description unspecified");
|
||||
});
|
||||
});
|
||||
|
||||
describe("geneset: delete", () => {
|
||||
const initialState = genesetsReducer(undefined, {
|
||||
type: "geneset: initial load",
|
||||
data: {
|
||||
tid: 0,
|
||||
genesets: [],
|
||||
},
|
||||
});
|
||||
|
||||
test("simple delete", () => {
|
||||
expect(
|
||||
genesetsReducer(
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: create",
|
||||
genesetName: "foo",
|
||||
genesetDescription: "foo",
|
||||
}),
|
||||
{
|
||||
type: "geneset: delete",
|
||||
genesetName: "foo",
|
||||
}
|
||||
)
|
||||
).toMatchObject({
|
||||
initialized: true,
|
||||
lastTid: 0,
|
||||
genesets: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
test("error - missing name", () => {
|
||||
expect(() => {
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: delete",
|
||||
genesetName: "foo",
|
||||
});
|
||||
}).toThrow("name does not exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("geneset: update", () => {
|
||||
const initialState = genesetsReducer(undefined, {
|
||||
type: "geneset: initial load",
|
||||
data: {
|
||||
tid: 0,
|
||||
genesets: [],
|
||||
},
|
||||
});
|
||||
|
||||
test("simple update", () => {
|
||||
expect(
|
||||
genesetsReducer(
|
||||
genesetsReducer(
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: create",
|
||||
genesetName: "foo1",
|
||||
genesetDescription: "foo1",
|
||||
}),
|
||||
{
|
||||
type: "geneset: create",
|
||||
genesetName: "foo2",
|
||||
genesetDescription: "foo2",
|
||||
}
|
||||
),
|
||||
{
|
||||
type: "geneset: update",
|
||||
genesetName: "foo1",
|
||||
update: {
|
||||
genesetName: "bar",
|
||||
genesetDescription: "bar",
|
||||
},
|
||||
}
|
||||
)
|
||||
).toMatchObject({
|
||||
initialized: true,
|
||||
lastTid: 0,
|
||||
genesets: new Map([
|
||||
[
|
||||
"bar",
|
||||
{ genesetName: "bar", genesetDescription: "bar", genes: new Map() },
|
||||
],
|
||||
[
|
||||
"foo2",
|
||||
{ genesetName: "foo2", genesetDescription: "foo2", genes: new Map() },
|
||||
],
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
test("error - unknown name", () => {
|
||||
expect(() => {
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: update",
|
||||
genesetName: "foo",
|
||||
update: {
|
||||
genesetName: "foo",
|
||||
genesetDescription: "bar",
|
||||
},
|
||||
});
|
||||
}).toThrow("name unspecified or does not exist");
|
||||
});
|
||||
|
||||
test("error - duplicate name", () => {
|
||||
expect(() => {
|
||||
genesetsReducer(
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: create",
|
||||
genesetName: "foo",
|
||||
genesetDescription: "foo",
|
||||
}),
|
||||
{
|
||||
type: "geneset: update",
|
||||
genesetName: "foo",
|
||||
update: {
|
||||
genesetName: "foo",
|
||||
genesetDescription: "foo",
|
||||
},
|
||||
}
|
||||
);
|
||||
}).toThrow("update specified existing name and description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("geneset: add genes", () => {
|
||||
const initialState = genesetsReducer(
|
||||
genesetsReducer(undefined, {
|
||||
type: "geneset: initial load",
|
||||
data: {
|
||||
tid: 0,
|
||||
genesets: [],
|
||||
},
|
||||
}),
|
||||
{
|
||||
type: "geneset: create",
|
||||
genesetName: "test",
|
||||
genesetDescription: "",
|
||||
}
|
||||
);
|
||||
|
||||
test("add a gene", () => {
|
||||
expect(
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: add genes",
|
||||
genesetName: "test",
|
||||
genes: [{ geneSymbol: "F5" }],
|
||||
})
|
||||
).toMatchObject({
|
||||
...initialState,
|
||||
genesets: new Map([
|
||||
[
|
||||
"test",
|
||||
{
|
||||
genesetName: "test",
|
||||
genesetDescription: "",
|
||||
genes: new Map([["F5", { geneSymbol: "F5", geneDescription: "" }]]),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: add genes",
|
||||
genesetName: "test",
|
||||
genes: [
|
||||
{ geneSymbol: "F5", geneDescription: "desc" },
|
||||
{ geneSymbol: "SET1", geneDescription: "" },
|
||||
],
|
||||
})
|
||||
).toMatchObject({
|
||||
...initialState,
|
||||
genesets: new Map([
|
||||
[
|
||||
"test",
|
||||
{
|
||||
genesetName: "test",
|
||||
genesetDescription: "",
|
||||
genes: new Map([
|
||||
["F5", { geneSymbol: "F5", geneDescription: "desc" }],
|
||||
["SET1", { geneSymbol: "SET1", geneDescription: "" }],
|
||||
]),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
test("no such geneset error", () => {
|
||||
expect(() => {
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: add genes",
|
||||
genesetName: "mumble",
|
||||
genes: [],
|
||||
});
|
||||
}).toThrow("geneset name does not exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("geneset: delete genes", () => {
|
||||
const initialState = genesetsReducer(
|
||||
genesetsReducer(
|
||||
genesetsReducer(undefined, {
|
||||
type: "geneset: initial load",
|
||||
data: {
|
||||
tid: 0,
|
||||
genesets: [],
|
||||
},
|
||||
}),
|
||||
{
|
||||
type: "geneset: create",
|
||||
genesetName: "test",
|
||||
genesetDescription: "",
|
||||
}
|
||||
),
|
||||
{
|
||||
type: "geneset: add genes",
|
||||
genesetName: "test",
|
||||
genes: [{ geneSymbol: "F5" }],
|
||||
}
|
||||
);
|
||||
|
||||
test("simple", () => {
|
||||
expect(
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: delete genes",
|
||||
genesetName: "test",
|
||||
geneSymbols: ["F5"],
|
||||
})
|
||||
).toMatchObject({
|
||||
...initialState,
|
||||
genesets: new Map([
|
||||
[
|
||||
"test",
|
||||
{
|
||||
genesetName: "test",
|
||||
genesetDescription: "",
|
||||
genes: new Map(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
test("no such geneset error", () => {
|
||||
expect(() => {
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: delete genes",
|
||||
genesetName: "mumble",
|
||||
geneSymbols: [],
|
||||
});
|
||||
}).toThrow("name does not exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("geneset: set gene description", () => {
|
||||
const initialState = genesetsReducer(
|
||||
genesetsReducer(
|
||||
genesetsReducer(undefined, {
|
||||
type: "geneset: initial load",
|
||||
data: {
|
||||
tid: 0,
|
||||
genesets: [],
|
||||
},
|
||||
}),
|
||||
{
|
||||
type: "geneset: create",
|
||||
genesetName: "test",
|
||||
genesetDescription: "",
|
||||
}
|
||||
),
|
||||
{
|
||||
type: "geneset: add genes",
|
||||
genesetName: "test",
|
||||
genes: [{ geneSymbol: "F5" }],
|
||||
}
|
||||
);
|
||||
|
||||
test("simple set", () => {
|
||||
expect(
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: set gene description",
|
||||
genesetName: "test",
|
||||
update: {
|
||||
geneSymbol: "F5",
|
||||
geneDescription: "mumble",
|
||||
},
|
||||
})
|
||||
).toMatchObject({
|
||||
...initialState,
|
||||
genesets: new Map([
|
||||
[
|
||||
"test",
|
||||
{
|
||||
genesetName: "test",
|
||||
genesetDescription: "",
|
||||
genes: new Map([
|
||||
["F5", { geneSymbol: "F5", geneDescription: "mumble" }],
|
||||
]),
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
test("no such geneset error", () => {
|
||||
expect(() => {
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: set gene description",
|
||||
genesetName: "does not exist",
|
||||
update: {
|
||||
geneSymbol: "F5",
|
||||
geneDescription: "mumble",
|
||||
},
|
||||
});
|
||||
}).toThrow("geneset name does not exist");
|
||||
});
|
||||
|
||||
test("no such gene error", () => {
|
||||
expect(() => {
|
||||
genesetsReducer(initialState, {
|
||||
type: "geneset: set gene description",
|
||||
genesetName: "test",
|
||||
update: {
|
||||
geneSymbol: "NO SUCH GENE",
|
||||
geneDescription: "mumble",
|
||||
},
|
||||
});
|
||||
}).toThrow("no such gene");
|
||||
});
|
||||
});
|
||||
|
||||
describe("geneset: set tid", () => {
|
||||
test("simple set", () => {
|
||||
expect(
|
||||
genesetsReducer(undefined, {
|
||||
type: "geneset: set tid",
|
||||
tid: 1,
|
||||
})
|
||||
).toMatchObject({ lastTid: 1 });
|
||||
});
|
||||
|
||||
test("not a number error", () => {
|
||||
expect(() => {
|
||||
genesetsReducer(
|
||||
{ lastTid: 1 },
|
||||
{
|
||||
type: "geneset: set tid",
|
||||
tid: "0",
|
||||
}
|
||||
);
|
||||
}).toThrow("must be a positive integer");
|
||||
});
|
||||
|
||||
test("decrement error", () => {
|
||||
expect(() => {
|
||||
genesetsReducer(
|
||||
{ lastTid: 1 },
|
||||
{
|
||||
type: "geneset: set tid",
|
||||
tid: 0,
|
||||
}
|
||||
);
|
||||
}).toThrow("may not be decremented");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import genesetsUIReducer from "../../src/reducers/genesetsUI";
|
||||
|
||||
// Format: GeneSetsUI(state,action)
|
||||
|
||||
const initialState = {
|
||||
createGenesetModeActive: false,
|
||||
isEditingGenesetName: false,
|
||||
isAddingGenesToGeneset: false,
|
||||
};
|
||||
|
||||
/* initial */
|
||||
describe("geneset UI states", () => {
|
||||
test("initial state, some other action", () => {
|
||||
expect(
|
||||
genesetsUIReducer(undefined, {
|
||||
type: "foo",
|
||||
})
|
||||
).toMatchObject(initialState);
|
||||
});
|
||||
test("geneset: activate add new geneset mode", () => {
|
||||
expect(
|
||||
genesetsUIReducer(initialState, {
|
||||
type: "geneset: activate add new geneset mode",
|
||||
})
|
||||
).toMatchObject({
|
||||
createGenesetModeActive: true,
|
||||
isEditingGenesetName: false,
|
||||
isAddingGenesToGeneset: false,
|
||||
});
|
||||
});
|
||||
test("geneset: disable create geneset mode", () => {
|
||||
expect(
|
||||
genesetsUIReducer(undefined, { isEditingGenesetName: false })
|
||||
).toMatchObject(initialState);
|
||||
});
|
||||
|
||||
test("activate add new genes mode", () => {
|
||||
expect(
|
||||
genesetsUIReducer(undefined, {
|
||||
type: "geneset: activate add new genes mode",
|
||||
geneset: "a geneset name",
|
||||
})
|
||||
).toMatchObject({
|
||||
createGenesetModeActive: false,
|
||||
isEditingGenesetName: false,
|
||||
isAddingGenesToGeneset: "a geneset name",
|
||||
});
|
||||
});
|
||||
test("disable create geneset mode", () => {
|
||||
expect(
|
||||
genesetsUIReducer(undefined, {
|
||||
type: "geneset: disable create geneset mode",
|
||||
})
|
||||
).toMatchObject(initialState);
|
||||
});
|
||||
test("activate rename geneset mode", () => {
|
||||
expect(
|
||||
genesetsUIReducer(undefined, {
|
||||
type: "geneset: activate rename geneset mode",
|
||||
data: "a geneset name",
|
||||
})
|
||||
).toMatchObject({
|
||||
createGenesetModeActive: false,
|
||||
isEditingGenesetName: "a geneset name",
|
||||
isAddingGenesToGeneset: false,
|
||||
});
|
||||
});
|
||||
test("disable rename geneset mode", () => {
|
||||
expect(
|
||||
genesetsUIReducer(undefined, {
|
||||
type: "geneset: disable rename geneset mode",
|
||||
})
|
||||
).toMatchObject(initialState);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import undoable from "../../src/reducers/undoable";
|
||||
|
||||
describe("create", () => {
|
||||
test("no keys", () => {
|
||||
expect(() => undoable(() => {})).toThrow();
|
||||
expect(() => undoable(() => {}, null)).toThrow();
|
||||
expect(() => undoable(() => {}, [])).toThrow();
|
||||
expect(() => undoable(() => {}, [], {})).toThrow();
|
||||
});
|
||||
|
||||
test("simple", () => {
|
||||
expect(undoable(() => {}, ["foo"])).toBeInstanceOf(Function);
|
||||
expect(undoable(() => {}, ["foo"], {})).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("handles undefined initial state", () => {
|
||||
expect(
|
||||
undoable(() => {}, ["a"])(undefined, { type: "test" })
|
||||
).toMatchObject({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo", () => {
|
||||
test("expected state modifications", () => {
|
||||
const initialState = { a: 0, b: 1000 };
|
||||
const reducer = (state) => ({ a: state.a + 1, b: state.b + 1 });
|
||||
const undoableReducer = undoable(reducer, ["a"]);
|
||||
|
||||
const s1 = undoableReducer(initialState, { type: "test" });
|
||||
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||
|
||||
// test that only specified keys are undone
|
||||
const s2 = undoableReducer(s1, { type: "@@undoable/undo" });
|
||||
expect(s2).toMatchObject({ a: 0, b: 1001 });
|
||||
|
||||
// test backstop when no more history
|
||||
const s3 = undoableReducer(s2, { type: "@@undoable/undo" });
|
||||
expect(s3).toMatchObject({ a: 0, b: 1001 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("redo", () => {
|
||||
const initialState = { a: 0, b: 1000 };
|
||||
const reducer = (state) => ({ a: state.a + 1, b: state.b + 1 });
|
||||
let UR;
|
||||
|
||||
beforeEach(() => {
|
||||
UR = undoable(reducer, ["a"]);
|
||||
});
|
||||
|
||||
test("expected state modifications", () => {
|
||||
const s1 = UR(initialState, { type: "test" });
|
||||
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||
|
||||
// verify undo->redo reverts state.
|
||||
const s2 = UR(UR(s1, { type: "@@undoable/undo" }), {
|
||||
type: "@@undoable/redo",
|
||||
});
|
||||
expect(s2).toMatchObject({ a: 1, b: 1001 });
|
||||
|
||||
// verify backstop when no redo future
|
||||
const s3 = UR(s2, { type: "@@undoable/redo" });
|
||||
expect(s3).toMatchObject({ a: 1, b: 1001 });
|
||||
});
|
||||
|
||||
test("history cleared", () => {
|
||||
// verify future cleared upon a normal state transition
|
||||
const s1 = UR(initialState, { type: "test" });
|
||||
expect(s1).toMatchObject({ a: 1, b: 1001 });
|
||||
const s2 = UR(s1, { type: "@@undoable/undo" });
|
||||
expect(s2).toMatchObject({ a: 0, b: 1001 });
|
||||
const s3 = UR(s2, { type: "test" });
|
||||
expect(s3).toMatchObject({ a: 1, b: 1002 });
|
||||
const s4 = UR(s3, { type: "@@undoable/redo" });
|
||||
expect(s4).toMatchObject({ a: 1, b: 1002 });
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
TODO:
|
||||
- historyLimit is enforced
|
||||
- action filters
|
||||
*/
|
||||
@@ -0,0 +1,4 @@
|
||||
# Ignore everything in this directory
|
||||
*
|
||||
# Except this file
|
||||
!.gitignore
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Define globals which are present in the client, but not in node (and therefore not in
|
||||
the jest test environment).
|
||||
*/
|
||||
|
||||
import { TextDecoder, TextEncoder } from "util";
|
||||
|
||||
global.TextDecoder = TextDecoder;
|
||||
global.TextEncoder = TextEncoder;
|
||||
@@ -11,14 +11,10 @@ describe("rangeEncodeIndices", () => {
|
||||
|
||||
test("sorted flag", () => {
|
||||
expect(rangeEncodeIndices([1, 9, 432], 10, true)).toMatchObject([
|
||||
1,
|
||||
9,
|
||||
432
|
||||
1, 9, 432,
|
||||
]);
|
||||
expect(rangeEncodeIndices([1, 9, 432], 10, false)).toMatchObject([
|
||||
1,
|
||||
9,
|
||||
432
|
||||
1, 9, 432,
|
||||
]);
|
||||
expect(rangeEncodeIndices([0, 1, 2, 3, 9, 10, 432], 2, true)).toMatchObject(
|
||||
[[0, 3], [9, 10], 432]
|
||||
@@ -34,7 +30,10 @@ describe("rangeEncodeIndices", () => {
|
||||
).toMatchObject([[3, 7], [10, 12], 99]);
|
||||
expect(
|
||||
rangeEncodeIndices([3, 4, 5, 6, 7, 10, 11, 12], 3, false)
|
||||
).toMatchObject([[3, 7], [10, 12]]);
|
||||
).toMatchObject([
|
||||
[3, 7],
|
||||
[10, 12],
|
||||
]);
|
||||
expect(
|
||||
rangeEncodeIndices([0, 3, 4, 5, 6, 7, 10, 11, 12], 3, false)
|
||||
).toMatchObject([0, [3, 7], [10, 12]]);
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
// these TWO statements MUST be first in the file, before any other imports
|
||||
import { enableFetchMocks } from "jest-fetch-mock";
|
||||
import * as serverMocks from "./serverMocks";
|
||||
// OK, continue on!
|
||||
|
||||
import {
|
||||
AnnoMatrixLoader,
|
||||
clip,
|
||||
isubset,
|
||||
isubsetMask,
|
||||
} from "../../../src/annoMatrix";
|
||||
import { Dataframe } from "../../../src/util/dataframe";
|
||||
|
||||
enableFetchMocks();
|
||||
|
||||
describe("AnnoMatrix", () => {
|
||||
let annoMatrix;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetch.resetMocks(); // reset all fetch mocking state
|
||||
annoMatrix = new AnnoMatrixLoader(
|
||||
serverMocks.baseDataURL,
|
||||
serverMocks.schema.schema
|
||||
);
|
||||
});
|
||||
|
||||
describe("basics", () => {
|
||||
test("annomatrix static checks", () => {
|
||||
expect(annoMatrix).toBeDefined();
|
||||
expect(annoMatrix.schema).toMatchObject(serverMocks.schema.schema);
|
||||
expect(annoMatrix.nObs).toEqual(serverMocks.schema.schema.dataframe.nObs);
|
||||
expect(annoMatrix.nVar).toEqual(serverMocks.schema.schema.dataframe.nVar);
|
||||
expect(annoMatrix.isView).toBeFalsy();
|
||||
expect(annoMatrix.viewOf).toBeUndefined();
|
||||
expect(annoMatrix.rowIndex).toBeDefined();
|
||||
});
|
||||
|
||||
test("simple single column fetch", async () => {
|
||||
fetch.once(serverMocks.annotationsObs(["name_0"]));
|
||||
|
||||
const df = await annoMatrix.fetch("obs", "name_0");
|
||||
expect(df).toBeInstanceOf(Dataframe);
|
||||
expect(df.colIndex.labels()).toEqual(["name_0"]);
|
||||
expect(df.dims).toEqual([annoMatrix.nObs, 1]);
|
||||
});
|
||||
|
||||
test("simple multi column fetch", async () => {
|
||||
fetch
|
||||
.once(serverMocks.annotationsObs(["name_0"]))
|
||||
.once(serverMocks.annotationsObs(["n_genes"]));
|
||||
|
||||
await expect(
|
||||
annoMatrix.fetch("obs", ["name_0", "n_genes"])
|
||||
).resolves.toBeInstanceOf(Dataframe);
|
||||
});
|
||||
|
||||
describe("fetch from field", () => {
|
||||
const getLastTwo = async (field) => {
|
||||
const columnNames = annoMatrix.getMatrixColumns(field).slice(-2);
|
||||
fetch.mockResponses(...columnNames.map(() => serverMocks.responder));
|
||||
await expect(
|
||||
annoMatrix.fetch(field, columnNames)
|
||||
).resolves.toBeInstanceOf(Dataframe);
|
||||
};
|
||||
|
||||
test("obs", async () => getLastTwo("obs"));
|
||||
test("var", async () => getLastTwo("var"));
|
||||
test("emb", async () => getLastTwo("emb"));
|
||||
});
|
||||
|
||||
test("fetch - test all query forms", async () => {
|
||||
// single string is a column name
|
||||
fetch.once(serverMocks.annotationsObs(["n_genes"]));
|
||||
await expect(annoMatrix.fetch("obs", "n_genes")).resolves.toBeInstanceOf(
|
||||
Dataframe
|
||||
);
|
||||
|
||||
// array of column names, expecting n_genes to be cached.
|
||||
fetch.once(serverMocks.annotationsObs(["percent_mito"]));
|
||||
await expect(
|
||||
annoMatrix.fetch("obs", ["n_genes", "percent_mito"])
|
||||
).resolves.toBeInstanceOf(Dataframe);
|
||||
|
||||
// more complex value filter query, enumerated
|
||||
fetch.once(serverMocks.responder);
|
||||
await expect(
|
||||
annoMatrix.fetch("X", {
|
||||
where: {
|
||||
field: "var",
|
||||
column: annoMatrix.schema.annotations.var.index,
|
||||
value: "TYMP",
|
||||
},
|
||||
})
|
||||
).resolves.toBeInstanceOf(Dataframe);
|
||||
|
||||
// more complex value filter query, range
|
||||
const varIndex = annoMatrix.schema.annotations.var.index;
|
||||
fetch
|
||||
.once(
|
||||
serverMocks.withExpected("/data/var", [[`var:${varIndex}`, "SUMO3"]])
|
||||
)
|
||||
.once(
|
||||
serverMocks.withExpected("/data/var", [[`var:${varIndex}`, "TYMP"]])
|
||||
);
|
||||
await expect(
|
||||
annoMatrix.fetch("X", [
|
||||
{
|
||||
where: {
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
value: "SUMO3",
|
||||
},
|
||||
},
|
||||
{
|
||||
where: {
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
value: "TYMP",
|
||||
},
|
||||
},
|
||||
])
|
||||
).resolves.toBeInstanceOf(Dataframe);
|
||||
// XXX inspect the wherecache?
|
||||
});
|
||||
|
||||
test("push and pop views", async () => {
|
||||
const am1 = clip(annoMatrix, 0.1, 0.9);
|
||||
expect(am1.viewOf).toBe(annoMatrix);
|
||||
expect(am1.nObs).toEqual(annoMatrix.nObs);
|
||||
expect(am1.nVar).toEqual(annoMatrix.nVar);
|
||||
expect(am1.rowIndex).toBe(annoMatrix.rowIndex);
|
||||
|
||||
const am2 = clip(annoMatrix, 0.1, 0.9);
|
||||
expect(am2.viewOf).toBe(annoMatrix);
|
||||
expect(am2).not.toBe(am1);
|
||||
expect(am2.rowIndex).toBe(annoMatrix.rowIndex);
|
||||
});
|
||||
|
||||
test("schema accessors", () => {
|
||||
expect(annoMatrix.getMatrixFields()).toEqual(
|
||||
expect.arrayContaining(["X", "obs", "emb", "var"])
|
||||
);
|
||||
expect(annoMatrix.getMatrixColumns("obs")).toEqual(
|
||||
expect.arrayContaining(["name_0", "n_genes", "louvain"])
|
||||
);
|
||||
expect(annoMatrix.getColumnSchema("emb", "umap")).toEqual({
|
||||
name: "umap",
|
||||
dims: ["umap_0", "umap_1"],
|
||||
type: "float32",
|
||||
});
|
||||
expect(annoMatrix.getColumnDimensions("emb", "umap")).toEqual([
|
||||
"umap_0",
|
||||
"umap_1",
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
test the mask & label access to subset via isubset and isubsetMask
|
||||
*/
|
||||
test("isubset", async () => {
|
||||
const rowList = [0, 10];
|
||||
const rowMask = new Uint8Array(annoMatrix.nObs);
|
||||
for (let i = 0; i < rowList.length; i += 1) {
|
||||
rowMask[rowList[i]] = 1;
|
||||
}
|
||||
|
||||
const am1 = isubset(annoMatrix, rowList);
|
||||
const am2 = isubsetMask(annoMatrix, rowMask);
|
||||
expect(am1).not.toBe(am2);
|
||||
expect(am1.nObs).toEqual(2);
|
||||
expect(am1.nObs).toEqual(am2.nObs);
|
||||
expect(am1.nVar).toEqual(am2.nVar);
|
||||
|
||||
fetch
|
||||
.once(serverMocks.annotationsObs(["n_genes"]))
|
||||
.once(serverMocks.annotationsObs(["n_genes"]));
|
||||
const ng1 = await am1.fetch("obs", "n_genes");
|
||||
const ng2 = await am2.fetch("obs", "n_genes");
|
||||
expect(ng1).toHaveLength(ng2.length);
|
||||
expect(ng1.colIndex.labels()).toEqual(ng2.colIndex.labels());
|
||||
expect(ng1.col("n_genes").asArray()).toEqual(
|
||||
ng2.col("n_genes").asArray()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add/drop column", () => {
|
||||
async function addDrop(base) {
|
||||
expect(base.getMatrixColumns("obs")).not.toContain("foo");
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(base.fetch("obs", "foo")).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
|
||||
/* add */
|
||||
const am1 = base.addObsColumn(
|
||||
{ name: "foo", type: "float32", writable: true },
|
||||
Float32Array,
|
||||
0
|
||||
);
|
||||
expect(base.getMatrixColumns("obs")).not.toContain("foo");
|
||||
expect(am1.getMatrixColumns("obs")).toContain("foo");
|
||||
const foo = await am1.fetch("obs", "foo");
|
||||
expect(foo).toBeDefined();
|
||||
expect(foo).toBeInstanceOf(Dataframe);
|
||||
expect(foo).toHaveLength(am1.nObs);
|
||||
expect(foo.col("foo").asArray()).toEqual(
|
||||
new Float32Array(am1.nObs).fill(0)
|
||||
);
|
||||
|
||||
/* drop */
|
||||
const am2 = am1.dropObsColumn("foo");
|
||||
expect(base.getMatrixColumns("obs")).not.toContain("foo");
|
||||
expect(am2.getMatrixColumns("obs")).not.toContain("foo");
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(am2.fetch("obs", "foo")).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
}
|
||||
|
||||
test("add/drop column, without view", async () => {
|
||||
await addDrop(annoMatrix);
|
||||
});
|
||||
|
||||
test("add/drop column, with view", async () => {
|
||||
const am1 = clip(annoMatrix, 0.1, 0.9);
|
||||
await addDrop(am1);
|
||||
|
||||
const am2 = isubset(am1, [0, 1, 2, 20, 30, 400]);
|
||||
await addDrop(am2);
|
||||
|
||||
const am3 = isubset(annoMatrix, [10, 0, 7, 3]);
|
||||
await addDrop(am3);
|
||||
|
||||
const am4 = clip(am3, 0, 1);
|
||||
await addDrop(am4);
|
||||
|
||||
fetch.mockResponse(serverMocks.responder);
|
||||
|
||||
await am1.fetch("obs", am1.getMatrixColumns("obs"));
|
||||
await am2.fetch("obs", am2.getMatrixColumns("obs"));
|
||||
await am3.fetch("obs", am3.getMatrixColumns("obs"));
|
||||
await am4.fetch("obs", am4.getMatrixColumns("obs"));
|
||||
|
||||
fetch.resetMocks();
|
||||
|
||||
await addDrop(am1);
|
||||
await addDrop(am2);
|
||||
await addDrop(am3);
|
||||
await addDrop(am4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setObsColumnValues", () => {
|
||||
async function addSetDrop(base) {
|
||||
/* add column */
|
||||
let am = base.addObsColumn(
|
||||
{
|
||||
name: "test",
|
||||
type: "categorical",
|
||||
categories: ["unassigned", "red", "green"],
|
||||
writable: true,
|
||||
},
|
||||
Array,
|
||||
"unassigned"
|
||||
);
|
||||
|
||||
const testVal = await am.fetch("obs", "test");
|
||||
expect(testVal.col("test").asArray()).toEqual(
|
||||
new Array(am.nObs).fill("unassigned")
|
||||
);
|
||||
|
||||
/* set values in column */
|
||||
const whichRows = [1, 2, 10];
|
||||
const am1 = await am.setObsColumnValues("test", whichRows, "yo");
|
||||
const testVal1 = await am1.fetch("obs", "test");
|
||||
const expt = new Array(am1.nObs).fill("unassigned");
|
||||
for (let i = 0; i < whichRows.length; i += 1) {
|
||||
const offset = am1.rowIndex.getOffset(whichRows[i]);
|
||||
expt[offset] = "yo";
|
||||
}
|
||||
expect(testVal1).not.toBe(testVal);
|
||||
expect(testVal1.col("test").asArray()).toEqual(expt);
|
||||
expect(am1.getColumnSchema("obs", "test").type).toBe("categorical");
|
||||
expect(am1.getColumnSchema("obs", "test").categories).toEqual(
|
||||
expect.arrayContaining(["unassigned", "red", "green", "yo"])
|
||||
);
|
||||
|
||||
/* drop column */
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
am = am1.dropObsColumn("test");
|
||||
await expect(am.fetch("obs", "test")).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
}
|
||||
|
||||
test("set, without a view", async () => {
|
||||
await addSetDrop(annoMatrix);
|
||||
});
|
||||
|
||||
test("set, with a view", async () => {
|
||||
const am1 = clip(annoMatrix, 0.1, 0.9);
|
||||
await addSetDrop(am1);
|
||||
|
||||
const am2 = isubset(am1, [0, 1, 2, 10, 20, 30, 400]);
|
||||
await addSetDrop(am2);
|
||||
|
||||
const am3 = isubset(annoMatrix, [10, 1, 0, 30, 2]);
|
||||
await addSetDrop(am3);
|
||||
|
||||
fetch.mockResponse(serverMocks.responder);
|
||||
|
||||
await am1.fetch("obs", am1.getMatrixColumns("obs"));
|
||||
await am2.fetch("obs", am2.getMatrixColumns("obs"));
|
||||
await am3.fetch("obs", am3.getMatrixColumns("obs"));
|
||||
|
||||
await addSetDrop(am1);
|
||||
await addSetDrop(am2);
|
||||
await addSetDrop(am3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,692 @@
|
||||
// these TWO statements MUST be first in the file, before any other imports
|
||||
import { enableFetchMocks } from "jest-fetch-mock";
|
||||
import * as serverMocks from "./serverMocks";
|
||||
// OK, continue on!
|
||||
|
||||
import obsLouvain from "./louvain.json";
|
||||
import obsNGenes from "./n_genes.json";
|
||||
import embUmap from "./umap.json";
|
||||
|
||||
import {
|
||||
AnnoMatrixLoader,
|
||||
AnnoMatrixObsCrossfilter,
|
||||
isubsetMask,
|
||||
} from "../../../src/annoMatrix";
|
||||
import { rangeFill } from "../../../src/util/range";
|
||||
|
||||
enableFetchMocks();
|
||||
|
||||
describe("AnnoMatrixCrossfilter", () => {
|
||||
let annoMatrix;
|
||||
let crossfilter;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetch.resetMocks(); // reset all fetch mocking state
|
||||
annoMatrix = new AnnoMatrixLoader(
|
||||
serverMocks.baseDataURL,
|
||||
serverMocks.schema.schema
|
||||
);
|
||||
crossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||
});
|
||||
|
||||
test("initial state of crossfilter", () => {
|
||||
const { nObs } = annoMatrix;
|
||||
|
||||
expect(crossfilter).toBeDefined();
|
||||
expect(crossfilter.size()).toEqual(nObs);
|
||||
expect(crossfilter.annoMatrix).toBe(annoMatrix);
|
||||
|
||||
/* by default, everything should be selected, even if no data in cache */
|
||||
expect(crossfilter.countSelected()).toEqual(nObs);
|
||||
expect(crossfilter.allSelectedLabels()).toEqual(
|
||||
rangeFill(new Int32Array(nObs))
|
||||
);
|
||||
expect(crossfilter.allSelectedMask()).toEqual(new Uint8Array(nObs).fill(1));
|
||||
expect(crossfilter.fillByIsSelected(new Uint8Array(nObs), 2, 1)).toEqual(
|
||||
new Uint8Array(nObs).fill(2)
|
||||
);
|
||||
});
|
||||
|
||||
describe("select", () => {
|
||||
/*
|
||||
test the selection state via crossfilter proxy
|
||||
*/
|
||||
|
||||
test("select loads index", async () => {
|
||||
/*
|
||||
Select should transparently load/create dimension index.
|
||||
|
||||
Internal dimension names are field/col:col:col..., eg,
|
||||
|
||||
obs:louvain
|
||||
emb:umap_0:umap_1
|
||||
|
||||
*/
|
||||
expect(crossfilter.obsCrossfilter.dimensionNames()).toEqual([]);
|
||||
expect(
|
||||
crossfilter.obsCrossfilter.hasDimension("obs/louvain")
|
||||
).toBeFalsy();
|
||||
|
||||
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
|
||||
let newCrossfilter = await crossfilter.select("obs", "louvain", {
|
||||
mode: "none",
|
||||
});
|
||||
|
||||
expect(newCrossfilter.countSelected()).toEqual(0);
|
||||
expect(
|
||||
newCrossfilter.obsCrossfilter.hasDimension("obs/louvain")
|
||||
).toBeTruthy();
|
||||
expect(fetch.mock.calls).toHaveLength(1);
|
||||
|
||||
newCrossfilter = await crossfilter.select("obs", "louvain", {
|
||||
mode: "all",
|
||||
});
|
||||
expect(newCrossfilter.countSelected()).toEqual(annoMatrix.nObs);
|
||||
});
|
||||
|
||||
test("simple column select", async () => {
|
||||
let xfltr;
|
||||
|
||||
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
|
||||
xfltr = await crossfilter.select("obs", "louvain", {
|
||||
mode: "exact",
|
||||
values: ["NK cells", "B cells"],
|
||||
});
|
||||
|
||||
expect(xfltr).toBeDefined();
|
||||
expect(xfltr.countSelected()).toEqual(496);
|
||||
expect(xfltr.allSelectedMask()).toEqual(
|
||||
Uint8Array.from(
|
||||
obsLouvain.map((val) =>
|
||||
val === "NK cells" || val === "B cells" ? 1 : 0
|
||||
)
|
||||
)
|
||||
);
|
||||
expect(xfltr.allSelectedLabels()).toEqual(
|
||||
Int32Array.from(
|
||||
obsLouvain.reduce((acc, val, idx) => {
|
||||
if (val === "NK cells" || val === "B cells") acc.push(idx);
|
||||
return acc;
|
||||
}, [])
|
||||
)
|
||||
);
|
||||
expect(
|
||||
xfltr.fillByIsSelected(new Uint8Array(annoMatrix.nObs), 3, 1)
|
||||
).toEqual(
|
||||
Uint8Array.from(
|
||||
obsLouvain.map((val) =>
|
||||
val === "NK cells" || val === "B cells" ? 3 : 1
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const df = await annoMatrix.fetch("obs", "louvain");
|
||||
const values = df.col("louvain").asArray();
|
||||
const selected = xfltr.allSelectedMask();
|
||||
values.every(
|
||||
(val, idx) => !["NK cells", "B cells"].includes(val) !== !selected[idx]
|
||||
);
|
||||
|
||||
fetch.once(
|
||||
serverMocks.dataframeResponse(["n_genes"], [new Int32Array(obsNGenes)])
|
||||
);
|
||||
xfltr = await xfltr.select("obs", "n_genes", {
|
||||
mode: "range",
|
||||
lo: 0,
|
||||
hi: 500,
|
||||
inclusive: false,
|
||||
});
|
||||
expect(xfltr.countSelected()).toEqual(33);
|
||||
expect(xfltr.allSelectedLabels()).toEqual(
|
||||
Int32Array.from(
|
||||
obsNGenes.reduce((acc, val, idx) => {
|
||||
const louvain = obsLouvain[idx];
|
||||
if (
|
||||
val >= 0 &&
|
||||
val < 500 &&
|
||||
(louvain === "NK cells" || louvain === "B cells")
|
||||
)
|
||||
acc.push(idx);
|
||||
return acc;
|
||||
}, [])
|
||||
)
|
||||
);
|
||||
|
||||
xfltr = await xfltr.selectAll();
|
||||
expect(xfltr.countSelected()).toEqual(annoMatrix.nObs);
|
||||
});
|
||||
|
||||
test("join column select", async () => {
|
||||
const varIndex = annoMatrix.schema.annotations.var.index;
|
||||
|
||||
const { nObs } = annoMatrix.schema.dataframe;
|
||||
fetch.once(
|
||||
serverMocks.dataframeResponse(
|
||||
["TEST"],
|
||||
[rangeFill(new Float32Array(nObs), 0, 0.1)]
|
||||
)
|
||||
);
|
||||
|
||||
const xfltr = await crossfilter.select(
|
||||
"X",
|
||||
{
|
||||
where: {
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
value: "TYMP",
|
||||
},
|
||||
},
|
||||
{
|
||||
mode: "range",
|
||||
lo: 0,
|
||||
hi: 50,
|
||||
inclusive: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(xfltr).toBeDefined();
|
||||
expect(xfltr.countSelected()).toEqual(501);
|
||||
|
||||
const df = await annoMatrix.fetch("X", {
|
||||
where: {
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
value: "TYMP",
|
||||
},
|
||||
});
|
||||
const values = df.icol(0).asArray();
|
||||
const selected = xfltr.allSelectedMask();
|
||||
values.every((val, idx) => !(val >= 0 && val <= 50) !== !selected[idx]);
|
||||
expect(selected.reduce((acc, val) => (val ? acc + 1 : acc), 0)).toEqual(
|
||||
xfltr.countSelected()
|
||||
);
|
||||
});
|
||||
|
||||
test("spatial column select", async () => {
|
||||
fetch.once(
|
||||
serverMocks.dataframeResponse(
|
||||
["umap_0", "umap_1"],
|
||||
[Float32Array.from(embUmap[0]), Float32Array.from(embUmap[1])]
|
||||
)
|
||||
);
|
||||
const xfltr = await crossfilter.select("emb", "umap", {
|
||||
mode: "within-rect",
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 0.5,
|
||||
maxY: 0.5,
|
||||
});
|
||||
expect(xfltr.countSelected()).toEqual(16);
|
||||
});
|
||||
|
||||
test("select on subset", async () => {
|
||||
const mask = new Uint8Array(annoMatrix.nObs).fill(0);
|
||||
for (let i = 0; i < mask.length; i += 2) {
|
||||
mask[i] = true;
|
||||
}
|
||||
const annoMatrixSubset = isubsetMask(annoMatrix, mask);
|
||||
expect(annoMatrixSubset.nObs).toEqual(Math.floor(annoMatrix.nObs / 2));
|
||||
|
||||
let xfltr = new AnnoMatrixObsCrossfilter(annoMatrixSubset);
|
||||
expect(xfltr.countSelected()).toEqual(annoMatrixSubset.nObs);
|
||||
|
||||
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
|
||||
xfltr = await xfltr.select("obs", "louvain", {
|
||||
mode: "exact",
|
||||
values: ["NK cells", "B cells"],
|
||||
});
|
||||
|
||||
expect(xfltr).toBeDefined();
|
||||
expect(xfltr.countSelected()).toEqual(240);
|
||||
|
||||
const df = await annoMatrixSubset.fetch("obs", "louvain");
|
||||
const values = df.col("louvain").asArray();
|
||||
const selected = xfltr.allSelectedMask();
|
||||
values.every(
|
||||
(val, idx) => !["NK cells", "B cells"].includes(val) !== !selected[idx]
|
||||
);
|
||||
});
|
||||
|
||||
test("select catches errors", async () => {
|
||||
await expect(crossfilter.select("NADA", "foo")).rejects.toThrow(
|
||||
"Unknown field name"
|
||||
);
|
||||
|
||||
await expect(crossfilter.select("var", "foo")).rejects.toThrow(
|
||||
"unable to obsSelect upon the var dimension"
|
||||
);
|
||||
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(crossfilter.select("obs", "foo")).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mutate matrix", () => {
|
||||
/*
|
||||
test the matrix mutators via crossfilter proxy
|
||||
*/
|
||||
async function helperAddTestCol(cf, colName, colSchema = null) {
|
||||
expect(
|
||||
cf.annoMatrix.getMatrixColumns("obs").includes(colName)
|
||||
).toBeFalsy();
|
||||
|
||||
if (colSchema === null) {
|
||||
colSchema = {
|
||||
name: colName,
|
||||
type: "categorical",
|
||||
categories: ["toasty"],
|
||||
};
|
||||
}
|
||||
colSchema.name = colName;
|
||||
const initValue = colSchema.categories[0];
|
||||
const xfltr = cf.addObsColumn(colSchema, Array, initValue);
|
||||
expect(
|
||||
xfltr.annoMatrix.schema.annotations.obs.columns.filter(
|
||||
(v) => v.name === colName
|
||||
)
|
||||
).toHaveLength(1);
|
||||
const df = await xfltr.annoMatrix.fetch("obs", colName);
|
||||
expect(df.hasCol(colName)).toBeTruthy();
|
||||
return xfltr;
|
||||
}
|
||||
|
||||
test("addObsColumn", async () => {
|
||||
expect(crossfilter.countSelected()).toBe(annoMatrix.nObs);
|
||||
expect(
|
||||
crossfilter.annoMatrix.getMatrixColumns("obs").includes("foo")
|
||||
).toBeFalsy();
|
||||
const xfltr = crossfilter.addObsColumn(
|
||||
{ name: "foo", type: "categorical", categories: ["A"] },
|
||||
Array,
|
||||
"A"
|
||||
);
|
||||
|
||||
// check schema updates correctly.
|
||||
expect(xfltr.countSelected()).toBe(annoMatrix.nObs);
|
||||
expect(
|
||||
xfltr.annoMatrix.getMatrixColumns("obs").includes("foo")
|
||||
).toBeTruthy();
|
||||
expect(xfltr.annoMatrix.schema.annotations.obsByName.foo).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
});
|
||||
expect(
|
||||
xfltr.annoMatrix.schema.annotations.obs.columns.filter(
|
||||
(v) => v.name === "foo"
|
||||
)
|
||||
).toHaveLength(1);
|
||||
|
||||
// check data update.
|
||||
const df = await xfltr.annoMatrix.fetch("obs", "foo");
|
||||
expect(
|
||||
df
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.every((v) => v === "A")
|
||||
).toBeTruthy();
|
||||
|
||||
// check that we catch dups
|
||||
expect(() =>
|
||||
xfltr.addObsColumn(
|
||||
{ name: "foo", type: "categorical" },
|
||||
Array,
|
||||
"toasty"
|
||||
)
|
||||
).toThrow("column already exists");
|
||||
expect(() =>
|
||||
xfltr.addObsColumn(
|
||||
{ name: "louvain", type: "categorical" },
|
||||
Array,
|
||||
"toasty"
|
||||
)
|
||||
).toThrow("column already exists");
|
||||
});
|
||||
|
||||
test("dropObsColumn", async () => {
|
||||
let xfltr;
|
||||
|
||||
/* check that we catch attempt to drop readonly dimension */
|
||||
expect(() => crossfilter.dropObsColumn("louvain")).toThrow(
|
||||
"Unknown or readonly obs column"
|
||||
);
|
||||
/* non-existent column */
|
||||
expect(() => crossfilter.dropObsColumn("does-not-exist")).toThrow(
|
||||
"Unknown or readonly obs column"
|
||||
);
|
||||
|
||||
// add a column, then drop it.
|
||||
xfltr = await helperAddTestCol(crossfilter, "foo");
|
||||
xfltr = xfltr.dropObsColumn("foo");
|
||||
expect(
|
||||
xfltr.annoMatrix.schema.annotations.obs.columns.filter(
|
||||
(v) => v.name === "foo"
|
||||
)
|
||||
).toHaveLength(0);
|
||||
expect(xfltr.annoMatrix.schema.annotations.obsByName.foo).toBeUndefined();
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(xfltr.annoMatrix.fetch("obs", "foo")).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
|
||||
// now same, but ensure we have built an index before doing the drop
|
||||
xfltr = await helperAddTestCol(crossfilter, "bar");
|
||||
xfltr = await xfltr.select("obs", "bar", {
|
||||
mode: "exact",
|
||||
values: "whatever",
|
||||
});
|
||||
xfltr = xfltr.dropObsColumn("bar");
|
||||
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(xfltr.select("obs", "bar", { mode: "all" })).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
});
|
||||
|
||||
test("renameObsColumn", async () => {
|
||||
let xfltr;
|
||||
|
||||
/* catch attempts to rename non-existent or readonly columns */
|
||||
expect(() =>
|
||||
crossfilter.renameObsColumn("does-not-exist", "foo")
|
||||
).toThrow("Unknown or readonly obs column");
|
||||
expect(() => crossfilter.renameObsColumn("louvain", "foo")).toThrow(
|
||||
"Unknown or readonly obs column"
|
||||
);
|
||||
|
||||
// add a column, then rename it.
|
||||
xfltr = await helperAddTestCol(crossfilter, "foo");
|
||||
xfltr = xfltr.renameObsColumn("foo", "bar");
|
||||
expect(xfltr.annoMatrix.getColumnSchema("obs", "foo")).toBeUndefined();
|
||||
expect(xfltr.annoMatrix.getColumnSchema("obs", "bar")).toMatchObject({
|
||||
name: "bar",
|
||||
type: "categorical",
|
||||
});
|
||||
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(xfltr.annoMatrix.fetch("obs", "foo")).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
const df = await xfltr.annoMatrix.fetch("obs", "bar");
|
||||
expect(df.hasCol("bar")).toBeTruthy();
|
||||
|
||||
// now same, but ensure we have built an index before doing the rename
|
||||
xfltr = await helperAddTestCol(crossfilter, "bar");
|
||||
xfltr = await xfltr.select("obs", "bar", {
|
||||
mode: "exact",
|
||||
values: "whatever",
|
||||
});
|
||||
xfltr = xfltr.renameObsColumn("bar", "xyz");
|
||||
|
||||
fetch.mockRejectOnce(new Error("unknown column name"));
|
||||
await expect(xfltr.select("obs", "bar", { mode: "all" })).rejects.toThrow(
|
||||
"unknown column name"
|
||||
);
|
||||
await expect(
|
||||
xfltr.select("obs", "xyz", { mode: "none" })
|
||||
).resolves.toBeInstanceOf(AnnoMatrixObsCrossfilter);
|
||||
});
|
||||
|
||||
test("addObsAnnoCategory", async () => {
|
||||
let xfltr;
|
||||
|
||||
// catch unknown or readonly columns
|
||||
expect(() => crossfilter.addObsAnnoCategory("louvain", "mumble")).toThrow(
|
||||
"Unknown or readonly obs column"
|
||||
);
|
||||
expect(() =>
|
||||
crossfilter.addObsAnnoCategory("undefined-name", "mumble")
|
||||
).toThrow("Unknown or readonly obs column");
|
||||
|
||||
// add a column and then add category to it
|
||||
xfltr = await helperAddTestCol(crossfilter, "foo", {
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: ["unassigned"],
|
||||
});
|
||||
xfltr = xfltr.addObsAnnoCategory("foo", "a-new-label");
|
||||
expect(xfltr.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining(["a-new-label", "unassigned"]),
|
||||
});
|
||||
|
||||
// do it again, dup; should throw
|
||||
expect(() => xfltr.addObsAnnoCategory("foo", "a-new-label")).toThrow(
|
||||
"category already exists"
|
||||
);
|
||||
|
||||
// now same, but ensure we have built an index before doing the operation
|
||||
xfltr = await helperAddTestCol(crossfilter, "bar", {
|
||||
name: "bar",
|
||||
type: "categorical",
|
||||
categories: ["unassigned"],
|
||||
});
|
||||
xfltr = await xfltr.select("obs", "bar", {
|
||||
mode: "exact",
|
||||
values: "something",
|
||||
});
|
||||
xfltr = xfltr.addObsAnnoCategory("bar", "a-new-label");
|
||||
expect(xfltr.annoMatrix.getColumnSchema("obs", "bar")).toMatchObject({
|
||||
name: "bar",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining(["a-new-label", "unassigned"]),
|
||||
});
|
||||
});
|
||||
|
||||
test("removeObsAnnoCategory", async () => {
|
||||
let xfltr;
|
||||
|
||||
// catch unknown or readonly categories
|
||||
await expect(() =>
|
||||
crossfilter.removeObsAnnoCategory("louvain", "mumble", "unassigned")
|
||||
).rejects.toThrow("Unknown or readonly obs column");
|
||||
await expect(() =>
|
||||
crossfilter.removeObsAnnoCategory("undefined-name", "mumble")
|
||||
).rejects.toThrow("Unknown or readonly obs column");
|
||||
|
||||
xfltr = await helperAddTestCol(crossfilter, "foo", {
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: ["unassigned", "red", "green", "blue"],
|
||||
});
|
||||
xfltr = await xfltr.select("obs", "foo", { mode: "all" });
|
||||
expect(
|
||||
(await xfltr.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.every((v) => v === "unassigned")
|
||||
).toBeTruthy();
|
||||
expect(xfltr.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining([
|
||||
"unassigned",
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
]),
|
||||
});
|
||||
|
||||
// remove an unused category
|
||||
const xfltr1 = await xfltr.removeObsAnnoCategory("foo", "red", "mumble");
|
||||
expect(
|
||||
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.every((v) => v === "unassigned")
|
||||
).toBeTruthy();
|
||||
expect(xfltr1.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining([
|
||||
"unassigned",
|
||||
"green",
|
||||
"blue",
|
||||
"mumble",
|
||||
]),
|
||||
});
|
||||
|
||||
// remove a used category
|
||||
const xfltr2 = await xfltr.removeObsAnnoCategory(
|
||||
"foo",
|
||||
"unassigned",
|
||||
"red"
|
||||
);
|
||||
expect(
|
||||
(await xfltr2.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.every((v) => v === "red")
|
||||
).toBeTruthy();
|
||||
expect(xfltr2.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining(["green", "blue", "red"]),
|
||||
});
|
||||
});
|
||||
|
||||
test("setObsColumnValues", async () => {
|
||||
// catch unknown or readonly categories
|
||||
await expect(() =>
|
||||
crossfilter.setObsColumnValues("louvain", [0, 1], "unassigned")
|
||||
).rejects.toThrow("Unknown or readonly obs column");
|
||||
await expect(() =>
|
||||
crossfilter.setObsColumnValues("undefined-name", [0], "mumble")
|
||||
).rejects.toThrow("Unknown or readonly obs column");
|
||||
|
||||
let xfltr = await helperAddTestCol(crossfilter, "foo", {
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: ["unassigned", "red", "green", "blue"],
|
||||
});
|
||||
xfltr = await xfltr.select("obs", "foo", { mode: "all" });
|
||||
|
||||
// catch unknown row label
|
||||
await expect(() =>
|
||||
xfltr.setObsColumnValues("foo", [-1], "red")
|
||||
).rejects.toThrow("Unknown row label");
|
||||
|
||||
// set a few rows
|
||||
expect(
|
||||
(await xfltr.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.every((v) => v === "unassigned")
|
||||
).toBeTruthy();
|
||||
const xfltr1 = await xfltr.setObsColumnValues("foo", [0, 10], "purple");
|
||||
expect(
|
||||
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.every(
|
||||
(v, i) =>
|
||||
v === "unassigned" || (v === "purple" && (i === 0 || i === 10))
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(xfltr1.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining([
|
||||
"unassigned",
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"purple",
|
||||
]),
|
||||
});
|
||||
|
||||
expect(xfltr1.countSelected()).toEqual(xfltr1.annoMatrix.nObs);
|
||||
const xfltr2 = await xfltr1.select("obs", "foo", {
|
||||
mode: "exact",
|
||||
values: ["purple"],
|
||||
});
|
||||
expect(xfltr2.countSelected()).toEqual(2);
|
||||
expect(xfltr2.allSelectedLabels()).toEqual(Int32Array.from([0, 10]));
|
||||
});
|
||||
|
||||
test("resetObsColumnValues", async () => {
|
||||
// catch unknown or readonly categories
|
||||
await expect(() =>
|
||||
crossfilter.resetObsColumnValues("louvain", "red", "blue")
|
||||
).rejects.toThrow("Unknown or readonly obs column");
|
||||
await expect(() =>
|
||||
crossfilter.resetObsColumnValues("undefined-name", "red", "blue")
|
||||
).rejects.toThrow("Unknown or readonly obs column");
|
||||
|
||||
let xfltr = await helperAddTestCol(crossfilter, "foo", {
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: ["unassigned", "red", "green", "blue"],
|
||||
});
|
||||
xfltr = await xfltr.select("obs", "foo", {
|
||||
mode: "exact",
|
||||
values: "red",
|
||||
});
|
||||
|
||||
// catch unknown category name label
|
||||
await expect(() =>
|
||||
xfltr.resetObsColumnValues("foo", "unknown-label", "red")
|
||||
).rejects.toThrow("unknown category");
|
||||
|
||||
let xfltr1 = await xfltr.setObsColumnValues("foo", [0, 10], "purple");
|
||||
xfltr1 = await xfltr1.select("obs", "foo", {
|
||||
mode: "exact",
|
||||
values: "purple",
|
||||
});
|
||||
expect(
|
||||
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.filter((v) => v === "purple")
|
||||
).toHaveLength(2);
|
||||
|
||||
xfltr1 = await xfltr1.resetObsColumnValues("foo", "purple", "magenta");
|
||||
expect(
|
||||
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.filter((v) => v === "magenta")
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
(await xfltr1.annoMatrix.fetch("obs", "foo"))
|
||||
.col("foo")
|
||||
.asArray()
|
||||
.filter((v) => v === "purple")
|
||||
).toHaveLength(0);
|
||||
expect(xfltr1.annoMatrix.getColumnSchema("obs", "foo")).toMatchObject({
|
||||
name: "foo",
|
||||
type: "categorical",
|
||||
categories: expect.arrayContaining([
|
||||
"unassigned",
|
||||
"red",
|
||||
"green",
|
||||
"blue",
|
||||
"purple",
|
||||
"magenta",
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("transition from empty annoMatrix", async () => {
|
||||
// select before fetch needs to work
|
||||
fetch.once(serverMocks.dataframeResponse(["louvain"], [obsLouvain]));
|
||||
const xfltr = await crossfilter.select("obs", "louvain", {
|
||||
mode: "exact",
|
||||
values: "B cells",
|
||||
});
|
||||
expect(fetch.mock.calls).toHaveLength(1);
|
||||
expect(xfltr.obsCrossfilter.hasDimension("obs/louvain")).toBeTruthy();
|
||||
expect(xfltr.obsCrossfilter.all()).toBe(xfltr.annoMatrix._cache.obs);
|
||||
expect(xfltr.countSelected()).toEqual(
|
||||
obsLouvain.reduce(
|
||||
(count, v) => (v === "B cells" ? count + 1 : count),
|
||||
0
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
[
|
||||
781, 1352, 1131, 960, 522, 782, 783, 790, 533, 550, 1116, 751, 866, 1059, 458,
|
||||
335, 1424, 1014, 1446, 446, 1020, 417, 878, 789, 510, 824, 1545, 996, 937,
|
||||
1368, 428, 406, 1020, 786, 1019, 750, 822, 982, 876, 930, 838, 1014, 732, 877,
|
||||
782, 787, 791, 880, 801, 1215, 343, 1460, 1250, 756, 836, 824, 827, 1238,
|
||||
1243, 1652, 843, 825, 656, 776, 766, 1465, 790, 871, 803, 965, 800, 876, 690,
|
||||
988, 906, 741, 620, 867, 916, 969, 803, 732, 555, 790, 862, 900, 674, 397,
|
||||
663, 563, 786, 859, 568, 412, 1043, 1206, 702, 1263, 929, 1079, 938, 316, 900,
|
||||
919, 862, 903, 390, 1717, 819, 1877, 660, 791, 478, 769, 481, 819, 866, 600,
|
||||
1185, 650, 775, 699, 642, 857, 832, 388, 710, 341, 894, 935, 604, 1008, 985,
|
||||
679, 603, 864, 1031, 887, 603, 610, 1119, 669, 794, 963, 756, 637, 1032, 776,
|
||||
860, 825, 852, 745, 858, 925, 1228, 806, 715, 668, 779, 1197, 888, 1273, 873,
|
||||
847, 781, 959, 805, 554, 604, 785, 978, 910, 936, 997, 961, 1314, 799, 1112,
|
||||
677, 775, 1298, 657, 626, 1313, 467, 936, 977, 780, 1311, 432, 579, 850, 736,
|
||||
800, 892, 860, 720, 822, 681, 954, 889, 1265, 919, 799, 833, 496, 1476, 848,
|
||||
869, 1059, 490, 897, 832, 864, 419, 856, 907, 791, 756, 771, 957, 1190, 680,
|
||||
524, 908, 506, 851, 775, 793, 748, 951, 643, 1277, 828, 480, 969, 1112, 648,
|
||||
805, 1223, 1023, 669, 489, 390, 350, 1113, 837, 1547, 840, 581, 748, 1861,
|
||||
735, 488, 1016, 585, 797, 769, 490, 1307, 895, 686, 602, 772, 704, 892, 1169,
|
||||
1375, 1189, 892, 2455, 355, 1856, 1317, 703, 825, 736, 1997, 892, 1034, 545,
|
||||
1188, 659, 1056, 819, 979, 632, 598, 690, 310, 803, 743, 560, 1073, 844, 882,
|
||||
841, 815, 771, 976, 986, 820, 957, 640, 1012, 927, 794, 753, 791, 366, 539,
|
||||
752, 769, 650, 947, 771, 824, 744, 837, 723, 640, 923, 1174, 1597, 699, 618,
|
||||
1418, 820, 1047, 981, 866, 527, 762, 717, 860, 603, 828, 476, 1071, 775, 614,
|
||||
913, 836, 669, 942, 792, 871, 1046, 859, 793, 822, 751, 435, 1142, 781, 718,
|
||||
471, 1750, 892, 841, 1156, 1031, 912, 873, 824, 1233, 1312, 575, 605, 717,
|
||||
1019, 1215, 928, 1780, 657, 718, 646, 808, 1120, 750, 390, 1100, 456, 755,
|
||||
1118, 571, 867, 728, 916, 491, 960, 625, 1090, 772, 968, 480, 810, 725, 1016,
|
||||
1011, 1075, 808, 672, 950, 862, 766, 963, 507, 570, 678, 768, 1037, 885, 1426,
|
||||
1496, 587, 879, 924, 783, 696, 1057, 783, 867, 919, 1251, 1023, 727, 645,
|
||||
1217, 929, 792, 994, 1025, 946, 600, 881, 975, 1609, 758, 772, 682, 998, 979,
|
||||
1045, 706, 808, 855, 819, 1147, 742, 914, 969, 704, 1398, 581, 809, 921, 805,
|
||||
542, 888, 519, 1092, 762, 698, 752, 771, 899, 1101, 760, 881, 1124, 809, 445,
|
||||
1703, 789, 641, 819, 890, 767, 806, 1323, 942, 807, 981, 888, 726, 1190, 826,
|
||||
661, 713, 816, 822, 806, 864, 464, 664, 931, 860, 674, 803, 464, 788, 1068,
|
||||
781, 843, 779, 873, 707, 492, 669, 982, 749, 789, 780, 561, 655, 432, 801,
|
||||
945, 770, 503, 766, 776, 970, 989, 654, 762, 882, 1236, 1499, 626, 1380, 1170,
|
||||
491, 833, 924, 581, 842, 596, 811, 542, 1572, 758, 854, 274, 1413, 872, 559,
|
||||
907, 951, 1175, 946, 905, 737, 604, 843, 606, 1079, 668, 785, 726, 978, 941,
|
||||
994, 655, 1013, 987, 591, 1041, 625, 582, 814, 570, 775, 822, 715, 738, 956,
|
||||
1178, 743, 1861, 841, 944, 783, 643, 924, 936, 431, 490, 532, 524, 620, 749,
|
||||
1334, 834, 790, 702, 892, 693, 784, 944, 471, 839, 529, 729, 1084, 802, 886,
|
||||
815, 856, 746, 1318, 545, 696, 872, 1154, 467, 725, 1027, 479, 728, 926, 1282,
|
||||
907, 833, 1024, 838, 900, 737, 367, 459, 1030, 1279, 756, 662, 1323, 1003,
|
||||
359, 770, 813, 634, 924, 1184, 901, 816, 1421, 771, 706, 953, 348, 716, 870,
|
||||
715, 550, 689, 947, 1157, 690, 383, 374, 882, 697, 246, 833, 1006, 1181, 974,
|
||||
856, 978, 1551, 965, 907, 565, 417, 907, 927, 966, 658, 727, 743, 381, 385,
|
||||
905, 645, 1167, 936, 724, 618, 1038, 853, 808, 1403, 762, 741, 687, 932, 1096,
|
||||
601, 652, 895, 682, 1553, 604, 823, 803, 646, 917, 942, 850, 795, 771, 949,
|
||||
516, 664, 1001, 637, 619, 907, 961, 812, 793, 1043, 1343, 1326, 981, 675, 937,
|
||||
631, 1026, 1135, 499, 948, 801, 848, 741, 604, 864, 1076, 1106, 1111, 624,
|
||||
1008, 908, 815, 346, 1062, 803, 749, 779, 1027, 1032, 1040, 654, 631, 755,
|
||||
854, 850, 798, 864, 1078, 690, 864, 1523, 838, 966, 389, 1654, 808, 885, 1665,
|
||||
920, 855, 807, 859, 1276, 987, 1079, 678, 626, 831, 829, 1009, 547, 893, 722,
|
||||
656, 415, 773, 1262, 1218, 365, 661, 805, 1409, 1094, 779, 898, 830, 1242,
|
||||
864, 576, 901, 1274, 852, 1006, 719, 763, 683, 957, 831, 724, 354, 763, 910,
|
||||
749, 626, 870, 795, 1078, 736, 835, 402, 1203, 699, 755, 697, 1229, 637, 666,
|
||||
846, 1036, 1027, 831, 999, 942, 891, 1007, 712, 990, 725, 745, 921, 333, 1042,
|
||||
895, 873, 1612, 724, 929, 601, 862, 908, 658, 775, 724, 753, 741, 690, 379,
|
||||
608, 927, 777, 969, 827, 709, 385, 690, 769, 554, 892, 761, 367, 731, 1103,
|
||||
944, 832, 675, 652, 418, 727, 745, 872, 1336, 863, 934, 844, 721, 432, 782,
|
||||
1006, 834, 840, 840, 819, 699, 489, 665, 576, 1291, 1102, 826, 880, 738, 904,
|
||||
686, 874, 887, 873, 560, 766, 710, 1135, 1054, 805, 724, 973, 1201, 575, 838,
|
||||
865, 546, 811, 884, 886, 791, 1026, 2000, 644, 763, 969, 800, 359, 624, 993,
|
||||
800, 1167, 833, 1871, 616, 822, 647, 1000, 618, 734, 618, 1938, 861, 945,
|
||||
1032, 723, 984, 994, 771, 738, 1583, 1113, 614, 1146, 615, 848, 983, 677, 972,
|
||||
791, 827, 804, 395, 843, 493, 741, 941, 1659, 742, 1517, 559, 937, 740, 781,
|
||||
819, 813, 578, 1022, 1191, 824, 1146, 757, 638, 830, 713, 609, 1271, 680, 769,
|
||||
1119, 731, 804, 781, 916, 735, 835, 1257, 472, 879, 851, 1023, 661, 1008, 748,
|
||||
845, 393, 675, 843, 876, 939, 932, 760, 735, 1561, 752, 940, 705, 405, 690,
|
||||
1071, 544, 927, 817, 388, 560, 1322, 640, 886, 1075, 689, 524, 606, 802, 868,
|
||||
939, 753, 770, 1105, 841, 786, 445, 703, 593, 875, 901, 927, 798, 1221, 415,
|
||||
1381, 949, 1322, 1169, 745, 727, 799, 490, 767, 943, 808, 926, 664, 569, 843,
|
||||
727, 1222, 457, 1515, 1138, 1174, 525, 878, 525, 999, 778, 772, 819, 1015,
|
||||
939, 856, 715, 793, 837, 1193, 764, 834, 677, 625, 420, 837, 874, 679, 811,
|
||||
546, 787, 587, 821, 669, 813, 780, 649, 924, 1322, 701, 792, 817, 667, 804,
|
||||
593, 740, 1243, 859, 685, 596, 1193, 859, 775, 947, 689, 907, 734, 621, 336,
|
||||
922, 802, 812, 941, 943, 868, 947, 767, 701, 692, 423, 775, 500, 1100, 812,
|
||||
728, 616, 928, 835, 454, 812, 769, 1058, 914, 628, 649, 452, 754, 1327, 776,
|
||||
670, 1017, 599, 431, 967, 1077, 2033, 731, 533, 926, 725, 559, 870, 964, 1341,
|
||||
1981, 1103, 326, 428, 808, 821, 554, 596, 680, 1034, 849, 566, 879, 1091, 823,
|
||||
447, 1688, 868, 1254, 942, 462, 1055, 852, 738, 804, 775, 726, 993, 1462,
|
||||
1007, 798, 1036, 786, 948, 743, 761, 838, 1040, 859, 867, 1188, 846, 687, 672,
|
||||
629, 725, 660, 809, 469, 600, 812, 856, 397, 786, 895, 882, 449, 890, 823,
|
||||
1051, 823, 1055, 741, 999, 1241, 790, 878, 778, 1066, 815, 465, 1079, 743,
|
||||
1098, 807, 1120, 1025, 805, 676, 828, 763, 997, 852, 866, 1118, 508, 928, 958,
|
||||
932, 892, 905, 494, 710, 1068, 795, 787, 951, 720, 842, 890, 1355, 1005, 872,
|
||||
1185, 912, 869, 894, 997, 770, 554, 806, 1426, 1012, 452, 896, 426, 239, 829,
|
||||
895, 787, 1139, 925, 1015, 1360, 1097, 650, 853, 549, 1052, 307, 1152, 907,
|
||||
1628, 731, 897, 1749, 762, 712, 1195, 851, 864, 968, 845, 331, 840, 734, 948,
|
||||
842, 1543, 661, 981, 912, 912, 1063, 683, 823, 996, 695, 1483, 927, 574, 1052,
|
||||
571, 1028, 1263, 671, 958, 747, 866, 896, 489, 643, 923, 820, 1466, 550, 1112,
|
||||
1006, 1448, 727, 899, 998, 563, 870, 903, 516, 754, 879, 588, 740, 798, 798,
|
||||
653, 902, 990, 724, 953, 891, 1437, 653, 714, 956, 877, 1012, 824, 1077, 740,
|
||||
692, 1063, 771, 808, 1389, 1264, 952, 816, 795, 795, 760, 886, 349, 868, 842,
|
||||
819, 626, 418, 903, 838, 723, 436, 1112, 724, 1299, 719, 843, 1090, 696, 885,
|
||||
627, 809, 423, 729, 853, 855, 608, 627, 823, 1063, 575, 743, 1528, 681, 544,
|
||||
422, 731, 920, 761, 884, 982, 784, 496, 573, 521, 663, 794, 975, 856, 978,
|
||||
590, 905, 695, 816, 976, 816, 753, 791, 858, 813, 841, 1085, 1692, 716, 955,
|
||||
1467, 741, 296, 738, 1573, 1119, 918, 283, 703, 842, 1253, 676, 1636, 1273,
|
||||
380, 799, 1491, 878, 939, 725, 1365, 818, 719, 1343, 905, 837, 803, 990, 1084,
|
||||
976, 1630, 795, 1408, 771, 650, 779, 648, 817, 1127, 882, 954, 830, 732, 783,
|
||||
756, 708, 976, 718, 887, 809, 795, 662, 912, 1550, 1509, 1021, 1751, 776, 910,
|
||||
714, 530, 846, 631, 1152, 1118, 755, 573, 1176, 267, 918, 1132, 849, 938,
|
||||
1140, 909, 840, 806, 904, 788, 778, 715, 869, 714, 883, 767, 858, 788, 553,
|
||||
634, 1230, 1131, 849, 811, 827, 1753, 713, 484, 783, 722, 596, 514, 372, 925,
|
||||
747, 840, 673, 955, 796, 719, 718, 857, 578, 1063, 594, 997, 1268, 341, 388,
|
||||
753, 834, 593, 1189, 911, 738, 476, 816, 641, 746, 635, 953, 801, 326, 849,
|
||||
499, 865, 1420, 487, 876, 797, 981, 756, 850, 1097, 998, 829, 1159, 955, 1061,
|
||||
696, 786, 743, 1211, 893, 491, 744, 1447, 767, 1050, 853, 1118, 1428, 555,
|
||||
612, 854, 789, 889, 784, 712, 1323, 921, 682, 794, 846, 1012, 839, 807, 587,
|
||||
881, 781, 1010, 1182, 1149, 812, 607, 1155, 714, 642, 1088, 1291, 1196, 325,
|
||||
804, 370, 359, 855, 1165, 836, 923, 863, 1284, 1011, 889, 984, 512, 939, 883,
|
||||
697, 1211, 362, 969, 1135, 1239, 580, 1103, 975, 825, 1170, 921, 640, 1180,
|
||||
378, 982, 916, 1122, 792, 619, 750, 913, 775, 661, 766, 768, 675, 944, 940,
|
||||
761, 727, 767, 873, 1043, 850, 995, 680, 595, 700, 753, 736, 891, 685, 780,
|
||||
986, 989, 830, 810, 685, 784, 642, 742, 961, 906, 829, 621, 822, 717, 1210,
|
||||
800, 1963, 749, 757, 570, 831, 721, 336, 802, 1001, 886, 631, 759, 631, 550,
|
||||
984, 767, 835, 777, 639, 860, 1413, 747, 779, 540, 367, 1629, 1380, 689, 1001,
|
||||
809, 337, 1103, 796, 966, 782, 1018, 642, 967, 436, 826, 779, 1000, 601, 796,
|
||||
945, 1679, 1123, 596, 995, 720, 588, 759, 452, 780, 836, 515, 846, 392, 283,
|
||||
710, 1158, 796, 895, 585, 559, 859, 879, 858, 842, 643, 1308, 595, 1181, 909,
|
||||
710, 821, 817, 841, 1197, 640, 1425, 947, 900, 852, 460, 1100, 824, 780, 932,
|
||||
542, 1137, 1225, 997, 572, 780, 765, 906, 793, 753, 772, 854, 936, 1048, 819,
|
||||
645, 619, 314, 726, 737, 1162, 1081, 868, 1032, 913, 476, 490, 799, 1201, 997,
|
||||
898, 212, 1586, 427, 947, 937, 724, 380, 715, 739, 931, 973, 773, 1497, 906,
|
||||
798, 953, 471, 821, 806, 714, 828, 727, 773, 976, 856, 727, 761, 1084, 1557,
|
||||
693, 559, 627, 795, 750, 838, 803, 453, 734, 607, 1029, 805, 669, 505, 858,
|
||||
832, 1019, 585, 1225, 1287, 903, 752, 2020, 774, 666, 843, 857, 887, 1082,
|
||||
656, 674, 911, 734, 910, 672, 802, 539, 699, 941, 828, 800, 642, 733, 607,
|
||||
992, 379, 562, 847, 787, 1461, 732, 941, 785, 696, 795, 809, 828, 651, 882,
|
||||
972, 1323, 679, 774, 784, 766, 520, 671, 796, 644, 1549, 756, 723, 788, 643,
|
||||
856, 825, 730, 831, 653, 429, 641, 637, 812, 1527, 859, 972, 744, 869, 508,
|
||||
624, 923, 976, 801, 1014, 1429, 586, 692, 704, 1176, 806, 883, 1249, 765, 743,
|
||||
907, 666, 669, 364, 794, 959, 766, 937, 1398, 942, 1469, 905, 812, 572, 1378,
|
||||
1058, 1215, 697, 531, 676, 1819, 503, 801, 943, 874, 813, 694, 494, 660, 1467,
|
||||
976, 833, 689, 921, 625, 1428, 817, 909, 956, 765, 1207, 829, 1648, 554, 1500,
|
||||
953, 647, 537, 786, 814, 761, 862, 838, 1102, 1392, 1042, 372, 971, 1364,
|
||||
1137, 847, 935, 710, 1070, 914, 855, 759, 654, 981, 1193, 397, 1123, 616, 747,
|
||||
876, 949, 965, 789, 752, 717, 1198, 952, 625, 784, 914, 659, 913, 863, 959,
|
||||
1637, 796, 1508, 906, 714, 1195, 867, 819, 375, 656, 1047, 745, 866, 1186,
|
||||
1669, 539, 942, 839, 927, 796, 734, 831, 967, 620, 814, 605, 1391, 655, 1512,
|
||||
625, 719, 547, 864, 902, 853, 1143, 990, 858, 604, 1291, 701, 859, 768, 1621,
|
||||
715, 594, 783, 1608, 927, 740, 805, 705, 491, 514, 768, 880, 993, 356, 748,
|
||||
993, 801, 843, 1194, 794, 606, 810, 882, 682, 1126, 792, 829, 1657, 786, 610,
|
||||
664, 790, 852, 678, 589, 930, 690, 840, 647, 622, 427, 856, 522, 1262, 722,
|
||||
641, 997, 983, 1099, 607, 704, 947, 886, 1062, 659, 995, 492, 754, 676, 837,
|
||||
532, 598, 1274, 921, 700, 828, 828, 543, 815, 709, 842, 1200, 1126, 1327, 849,
|
||||
1160, 707, 675, 709, 990, 270, 445, 892, 1040, 832, 815, 437, 1401, 1341, 480,
|
||||
1517, 704, 622, 823, 731, 781, 849, 1209, 472, 838, 643, 1101, 879, 895, 408,
|
||||
832, 845, 928, 920, 1269, 652, 1463, 840, 706, 956, 1219, 612, 970, 789, 382,
|
||||
533, 627, 388, 655, 1103, 782, 1231, 617, 589, 758, 882, 909, 661, 558, 947,
|
||||
491, 1386, 1481, 823, 1064, 681, 652, 363, 824, 933, 611, 886, 806, 531, 864,
|
||||
648, 1142, 736, 955, 826, 725, 1174, 841, 1006, 1418, 955, 829, 1038, 632,
|
||||
750, 846, 1033, 859, 890, 893, 1030, 913, 1385, 750, 416, 703, 522, 856, 914,
|
||||
742, 381, 1079, 671, 1101, 858, 663, 581, 845, 881, 823, 835, 1290, 598, 1130,
|
||||
793, 838, 765, 768, 815, 810, 912, 818, 790, 665, 703, 875, 657, 1567, 688,
|
||||
749, 1076, 782, 543, 1100, 841, 809, 789, 1263, 758, 368, 1022, 730, 1101,
|
||||
478, 488, 618, 940, 771, 784, 847, 1303, 821, 1111, 752, 1128, 958, 742, 782,
|
||||
824, 751, 872, 939, 606, 698, 705, 963, 607, 621, 1650, 1093, 545, 670, 617,
|
||||
723, 1194, 1019, 1291, 758, 855, 1549, 743, 1372, 802, 337, 1121, 1028, 1524,
|
||||
645, 847, 866, 941, 751, 583, 796, 793, 975, 936, 524, 659, 607, 1433, 562,
|
||||
696, 927, 517, 719, 599, 639, 977, 1019, 816, 672, 1903, 1162, 964, 936, 947,
|
||||
989, 790, 902, 982, 673, 856, 629, 692, 843, 940, 795, 780, 821, 471, 702,
|
||||
631, 1557, 868, 901, 798, 1020, 885, 881, 719, 1043, 1238, 565, 776, 696, 725,
|
||||
365, 811, 1212, 1178, 1132, 661, 831, 786, 471, 835, 564, 929, 958, 706, 388,
|
||||
842, 965, 1088, 511, 794, 900, 865, 789, 504, 701, 817, 796, 972, 906, 871,
|
||||
922, 724, 628, 1479, 533, 1101, 1913, 855, 1266, 884, 817, 619, 591, 685, 887,
|
||||
1336, 656, 1227, 980, 817, 582, 1370, 460, 638, 471, 650, 414, 907, 1147, 732,
|
||||
992, 801, 822, 529, 737, 806, 816, 889, 1305, 588, 657, 1154, 713, 326, 1129,
|
||||
1603, 879, 1156, 642, 285, 825, 823, 719, 1253, 971, 853, 916, 1053, 515,
|
||||
1017, 953, 832, 645, 667, 1326, 547, 636, 1783, 1211, 788, 807, 1104, 884,
|
||||
848, 788, 1013, 1003, 916, 818, 828, 882, 959, 395, 368, 787, 929, 1379, 711,
|
||||
733, 752, 464, 626, 735, 946, 876, 647, 536, 954, 486, 712, 786, 438, 807,
|
||||
1016, 551, 841, 929, 757, 971, 708, 567, 881, 801, 873, 805, 1359, 866, 945,
|
||||
1068, 819, 815, 1058, 845, 881, 1051, 1179, 718, 657, 882, 709, 754, 735, 603,
|
||||
944, 1794, 712, 721, 1097, 813, 788, 917, 656, 1104, 1268, 1239, 862, 739,
|
||||
858, 1066, 752, 615, 721, 571, 861, 933, 807, 1082, 820, 887, 850, 1567, 803,
|
||||
1156, 721, 692, 700, 458, 637, 873, 1544, 1155, 1227, 622, 454, 724
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
export const baseDataURL = "https://a.fake.url/api/v0.2";
|
||||
|
||||
export { schema } from "./schema";
|
||||
export * from "./routes";
|
||||
@@ -0,0 +1,211 @@
|
||||
import { schema } from "./schema";
|
||||
import { Dataframe, KeyIndex } from "../../../../src/util/dataframe";
|
||||
import { encodeMatrixFBS } from "../../../../src/util/stateManager/matrix";
|
||||
|
||||
const indexedSchema = {
|
||||
obsByName: Object.fromEntries(
|
||||
schema.schema.annotations.obs.columns.map((v) => [v.name, v]) ?? []
|
||||
),
|
||||
varByName: Object.fromEntries(
|
||||
schema.schema.annotations.var.columns.map((v) => [v.name, v]) ?? []
|
||||
),
|
||||
embByName: Object.fromEntries(
|
||||
schema.schema.layout.obs.map((v) => [v.name, v]) ?? []
|
||||
),
|
||||
};
|
||||
|
||||
function makeMockColumn(s, length) {
|
||||
const { type } = s;
|
||||
switch (type) {
|
||||
case "int32":
|
||||
return new Int32Array(length).fill(Math.floor(99 * Math.random()));
|
||||
|
||||
case "string":
|
||||
return new Array(length).fill("test");
|
||||
|
||||
case "float32":
|
||||
return new Float32Array(length).fill(99 * Math.random());
|
||||
|
||||
case "boolean":
|
||||
return new Array(length).fill(false);
|
||||
|
||||
case "categorical":
|
||||
return new Array(length).fill(s.categories[0]);
|
||||
|
||||
default:
|
||||
throw new Error("unkonwn type");
|
||||
}
|
||||
}
|
||||
|
||||
function getEncodedDataframe(colNames, length, colSchemas) {
|
||||
const colIndex = new KeyIndex(colNames);
|
||||
const columns = colSchemas.map((s) => makeMockColumn(s, length));
|
||||
const df = new Dataframe([length, colNames.length], columns, null, colIndex);
|
||||
const body = encodeMatrixFBS(df);
|
||||
return body;
|
||||
}
|
||||
|
||||
export function dataframeResponse(colNames, columns) {
|
||||
const colIndex = new KeyIndex(colNames);
|
||||
const df = new Dataframe(
|
||||
[columns[0].length, colNames.length],
|
||||
columns,
|
||||
null,
|
||||
colIndex
|
||||
);
|
||||
const body = encodeMatrixFBS(df);
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
});
|
||||
return () => Promise.resolve({ body, init: { status: 200, headers } });
|
||||
}
|
||||
|
||||
function annotationObsResponse(request) {
|
||||
const url = new URL(request.url);
|
||||
const params = Array.from(url.searchParams.entries());
|
||||
const names = params
|
||||
.filter(([k]) => k === "annotation-name")
|
||||
.map(([, v]) => v);
|
||||
if (!names.every((n) => indexedSchema.obsByName[n])) {
|
||||
return Promise.reject(new Error("bad obs annotation name in URL"));
|
||||
}
|
||||
const colSchemas = names.map((n) => indexedSchema.obsByName[n]);
|
||||
const body = getEncodedDataframe(
|
||||
names,
|
||||
schema.schema.dataframe.nObs,
|
||||
colSchemas
|
||||
);
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
});
|
||||
return Promise.resolve({
|
||||
body,
|
||||
init: { status: 200, headers },
|
||||
});
|
||||
}
|
||||
|
||||
function annotationVarResponse(request) {
|
||||
const url = new URL(request.url);
|
||||
const params = Array.from(url.searchParams.entries());
|
||||
const names = params
|
||||
.filter(([k]) => k === "annotation-name")
|
||||
.map(([, v]) => v);
|
||||
if (!names.every((n) => indexedSchema.varByName[n])) {
|
||||
return Promise.reject(new Error("bad var annotation name in URL"));
|
||||
}
|
||||
const colSchemas = names.map((n) => indexedSchema.varByName[n]);
|
||||
const body = getEncodedDataframe(
|
||||
names,
|
||||
schema.schema.dataframe.nVar,
|
||||
colSchemas
|
||||
);
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
});
|
||||
return Promise.resolve({
|
||||
body,
|
||||
init: { status: 200, headers },
|
||||
});
|
||||
}
|
||||
|
||||
function layoutObsResponse(request) {
|
||||
const url = new URL(request.url);
|
||||
const params = Array.from(url.searchParams.entries());
|
||||
const names = params.filter(([k]) => k === "layout-name").map(([, v]) => v);
|
||||
if (!names.every((n) => indexedSchema.embByName[n])) {
|
||||
return Promise.reject(new Error("bad layout name in URL"));
|
||||
}
|
||||
const dims = names.map((n) => indexedSchema.embByName[n].dims).flat();
|
||||
const colSchemas = names
|
||||
.map((n) => [indexedSchema.embByName[n], indexedSchema.embByName[n]])
|
||||
.flat();
|
||||
const body = getEncodedDataframe(
|
||||
dims,
|
||||
schema.schema.dataframe.nObs,
|
||||
colSchemas
|
||||
);
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
});
|
||||
return Promise.resolve({
|
||||
body,
|
||||
init: { status: 200, headers },
|
||||
});
|
||||
}
|
||||
|
||||
function dataVarResponse(request) {
|
||||
const url = new URL(request.url);
|
||||
const params = Array.from(url.searchParams.entries());
|
||||
|
||||
const colNames = params.map((v) => `${v[0]}/${v[1]}`);
|
||||
const colSchemas = colNames.map(() => schema.schema.dataframe);
|
||||
const body = getEncodedDataframe(
|
||||
colNames,
|
||||
schema.schema.dataframe.nObs,
|
||||
colSchemas
|
||||
);
|
||||
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
});
|
||||
return Promise.resolve({
|
||||
body,
|
||||
init: { status: 200, headers },
|
||||
});
|
||||
}
|
||||
|
||||
export function responder(request) {
|
||||
const url = new URL(request.url);
|
||||
const { pathname } = url;
|
||||
if (pathname.endsWith("/annotations/obs")) {
|
||||
return annotationObsResponse(request);
|
||||
}
|
||||
if (pathname.endsWith("/annotations/var")) {
|
||||
return annotationVarResponse(request);
|
||||
}
|
||||
if (pathname.endsWith("/layout/obs")) {
|
||||
return layoutObsResponse(request);
|
||||
}
|
||||
if (pathname.endsWith("/data/var")) {
|
||||
return dataVarResponse(request);
|
||||
}
|
||||
return Promise.reject(new Error("bad URL"));
|
||||
}
|
||||
|
||||
export function withExpected(expectedURL, expectedParams) {
|
||||
/*
|
||||
Do some additional error checking
|
||||
*/
|
||||
return (request) => {
|
||||
// if URL is bogus, reject the promise
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith(expectedURL)) {
|
||||
return Promise.reject(new Error("Unexpected URL!"));
|
||||
}
|
||||
const params = Array.from(url.searchParams.entries()).sort(
|
||||
(a, b) => a[0] < b[0]
|
||||
);
|
||||
expectedParams = expectedParams.slice().sort((a, b) => a[0] < b[0]);
|
||||
|
||||
if (
|
||||
params.length !== expectedParams.length ||
|
||||
!params.every(
|
||||
(p, i) => p[0] === expectedParams[i][0] && p[1] === expectedParams[i][1]
|
||||
)
|
||||
) {
|
||||
return Promise.reject(new Error("unexpected name requested in URL"));
|
||||
}
|
||||
|
||||
return responder(request);
|
||||
};
|
||||
}
|
||||
|
||||
export function annotationsObs(names) {
|
||||
return withExpected(
|
||||
"/annotations/obs",
|
||||
names.map((name) => ["annotation-name", name])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export const schema = {
|
||||
schema: {
|
||||
annotations: {
|
||||
obs: {
|
||||
columns: [
|
||||
{
|
||||
name: "name_0",
|
||||
type: "string",
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
name: "n_genes",
|
||||
type: "int32",
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
name: "percent_mito",
|
||||
type: "float32",
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
name: "n_counts",
|
||||
type: "float32",
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
name: "louvain",
|
||||
type: "string",
|
||||
writable: false,
|
||||
},
|
||||
],
|
||||
index: "name_0",
|
||||
},
|
||||
var: {
|
||||
columns: [
|
||||
{
|
||||
name: "name_0",
|
||||
type: "string",
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
name: "n_cells",
|
||||
type: "int32",
|
||||
writable: false,
|
||||
},
|
||||
],
|
||||
index: "name_0",
|
||||
},
|
||||
},
|
||||
dataframe: {
|
||||
nObs: 2638,
|
||||
nVar: 1838,
|
||||
type: "float32",
|
||||
},
|
||||
layout: {
|
||||
obs: [
|
||||
{
|
||||
dims: ["draw_graph_fr_0", "draw_graph_fr_1"],
|
||||
name: "draw_graph_fr",
|
||||
type: "float32",
|
||||
},
|
||||
{
|
||||
dims: ["pca_0", "pca_1"],
|
||||
name: "pca",
|
||||
type: "float32",
|
||||
},
|
||||
{
|
||||
dims: ["tsne_0", "tsne_1"],
|
||||
name: "tsne",
|
||||
type: "float32",
|
||||
},
|
||||
{
|
||||
dims: ["umap_0", "umap_1"],
|
||||
name: "umap",
|
||||
type: "float32",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
import sha1 from "sha1";
|
||||
import {
|
||||
_whereCacheGet,
|
||||
_whereCacheCreate,
|
||||
_whereCacheMerge,
|
||||
} from "../../../src/annoMatrix/whereCache";
|
||||
|
||||
const schema = {};
|
||||
|
||||
describe("whereCache", () => {
|
||||
test("whereCacheGet - where query, missing cache values", () => {
|
||||
expect(
|
||||
_whereCacheGet({}, schema, "X", {
|
||||
where: {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "bar",
|
||||
},
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet({}, schema, "X", {
|
||||
summarize: {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
values: ["bar"],
|
||||
},
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet({ where: { X: {} } }, schema, "X", {
|
||||
where: {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "bar",
|
||||
},
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet({ where: { X: { var: new Map() } } }, schema, "X", {
|
||||
where: {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "bar",
|
||||
},
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet(
|
||||
{ where: { X: { var: new Map([["foo", new Map()]]) } } },
|
||||
schema,
|
||||
"X",
|
||||
{
|
||||
where: {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "bar",
|
||||
},
|
||||
}
|
||||
)
|
||||
).toEqual([undefined]);
|
||||
});
|
||||
|
||||
test("whereCacheGet - summarize query, missing cache values", () => {
|
||||
expect(
|
||||
_whereCacheGet({}, schema, "X", {
|
||||
summarize: {
|
||||
method: "mean",
|
||||
field: "var",
|
||||
column: "foo",
|
||||
values: ["bar"],
|
||||
},
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet(
|
||||
{ summarize: { X: { mean: { var: new Map() } } } },
|
||||
schema,
|
||||
"X",
|
||||
{
|
||||
summarize: {
|
||||
method: "mean",
|
||||
field: "var",
|
||||
column: "foo",
|
||||
values: ["bar"],
|
||||
},
|
||||
}
|
||||
)
|
||||
).toEqual([undefined]);
|
||||
});
|
||||
|
||||
test("whereCacheGet - varied lookups", () => {
|
||||
const whereCache = {
|
||||
where: {
|
||||
X: {
|
||||
var: new Map([
|
||||
[
|
||||
"foo",
|
||||
new Map([
|
||||
["bar", [0]],
|
||||
["baz", [1, 2]],
|
||||
]),
|
||||
],
|
||||
]),
|
||||
},
|
||||
},
|
||||
summarize: {
|
||||
X: {
|
||||
mean: {
|
||||
var: new Map([
|
||||
[
|
||||
"foo",
|
||||
new Map([
|
||||
[sha1("bar"), [0]],
|
||||
[sha1("baz"), [1, 2]],
|
||||
]),
|
||||
],
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
_whereCacheGet(whereCache, schema, "X", {
|
||||
where: {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "bar",
|
||||
},
|
||||
})
|
||||
).toEqual([0]);
|
||||
expect(
|
||||
_whereCacheGet(whereCache, schema, "X", {
|
||||
summarize: {
|
||||
method: "mean",
|
||||
field: "var",
|
||||
column: "foo",
|
||||
values: ["bar"],
|
||||
},
|
||||
})
|
||||
).toEqual([0]);
|
||||
expect(
|
||||
_whereCacheGet(whereCache, schema, "X", {
|
||||
where: {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "baz",
|
||||
},
|
||||
})
|
||||
).toEqual([1, 2]);
|
||||
expect(
|
||||
_whereCacheGet(whereCache, schema, "X", {
|
||||
summarize: {
|
||||
method: "mean",
|
||||
field: "var",
|
||||
column: "foo",
|
||||
values: ["baz"],
|
||||
},
|
||||
})
|
||||
).toEqual([1, 2]);
|
||||
expect(_whereCacheGet(whereCache, schema, "Y", {})).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet(whereCache, schema, "X", {
|
||||
where: {
|
||||
field: "whoknows",
|
||||
column: "whatever",
|
||||
value: "snork",
|
||||
},
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet(whereCache, schema, "X", {
|
||||
where: {
|
||||
field: "var",
|
||||
column: "whatever",
|
||||
value: "snork",
|
||||
},
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
expect(
|
||||
_whereCacheGet(whereCache, schema, "X", {
|
||||
where: {
|
||||
field: "var",
|
||||
column: "foo",
|
||||
value: "snork",
|
||||
},
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
});
|
||||
|
||||
test("whereCacheCreate, where query", () => {
|
||||
const query = {
|
||||
where: {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "queryValue",
|
||||
},
|
||||
};
|
||||
const wc = _whereCacheCreate(
|
||||
"field",
|
||||
{
|
||||
where: {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "queryValue",
|
||||
},
|
||||
},
|
||||
[0, 1, 2]
|
||||
);
|
||||
expect(wc).toBeDefined();
|
||||
expect(wc).toEqual(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
field: {
|
||||
queryField: expect.any(Map),
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(wc.where.field.queryField.has("queryColumn")).toEqual(true);
|
||||
expect(wc.where.field.queryField.get("queryColumn")).toBeInstanceOf(Map);
|
||||
expect(
|
||||
wc.where.field.queryField.get("queryColumn").has("queryValue")
|
||||
).toEqual(true);
|
||||
expect(_whereCacheGet(wc, schema, "field", query)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
test("whereCacheCreate, summarize query", () => {
|
||||
const query = {
|
||||
summarize: {
|
||||
method: "method",
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
values: ["queryValue"],
|
||||
},
|
||||
};
|
||||
const wc = _whereCacheCreate("field", query, [0, 1, 2]);
|
||||
expect(_whereCacheGet(wc, schema, "field", query)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
test("whereCacheCreate, unknown query type", () => {
|
||||
expect(_whereCacheCreate("field", { foobar: true }, [1])).toEqual({});
|
||||
});
|
||||
|
||||
test("whereCacheMerge, where queries", () => {
|
||||
let wc;
|
||||
|
||||
// remember, will mutate dst
|
||||
const src = _whereCacheCreate(
|
||||
"field",
|
||||
{ where: { field: "queryField", column: "queryColumn", value: "foo" } },
|
||||
["foo"]
|
||||
);
|
||||
|
||||
const dst1 = _whereCacheCreate(
|
||||
"field",
|
||||
{ where: { field: "queryField", column: "queryColumn", value: "bar" } },
|
||||
["dst1"]
|
||||
);
|
||||
wc = _whereCacheMerge(dst1, src);
|
||||
expect(
|
||||
_whereCacheGet(wc, schema, "field", {
|
||||
where: {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "foo",
|
||||
},
|
||||
})
|
||||
).toEqual(["foo"]);
|
||||
expect(
|
||||
_whereCacheGet(wc, schema, "field", {
|
||||
where: {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "bar",
|
||||
},
|
||||
})
|
||||
).toEqual(["dst1"]);
|
||||
|
||||
const dst2 = _whereCacheCreate(
|
||||
"field",
|
||||
{ where: { field: "queryField", column: "queryColumn", value: "bar" } },
|
||||
["dst2"]
|
||||
);
|
||||
wc = _whereCacheMerge(dst2, dst1, src);
|
||||
expect(
|
||||
_whereCacheGet(wc, schema, "field", {
|
||||
where: {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "foo",
|
||||
},
|
||||
})
|
||||
).toEqual(["foo"]);
|
||||
expect(
|
||||
_whereCacheGet(wc, schema, "field", {
|
||||
where: {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "bar",
|
||||
},
|
||||
})
|
||||
).toEqual(["dst1"]);
|
||||
|
||||
wc = _whereCacheMerge({}, src);
|
||||
expect(wc).toEqual(src);
|
||||
|
||||
wc = _whereCacheMerge({ where: { field: { queryField: new Map() } } }, src);
|
||||
expect(wc).toEqual(src);
|
||||
});
|
||||
|
||||
test("whereCacheMerge, mixed queries", () => {
|
||||
const wc = _whereCacheMerge(
|
||||
_whereCacheCreate(
|
||||
"field",
|
||||
{
|
||||
where: {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "foo",
|
||||
},
|
||||
},
|
||||
["a"]
|
||||
),
|
||||
_whereCacheCreate(
|
||||
"field",
|
||||
{
|
||||
summarize: {
|
||||
method: "mean",
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
values: ["foo", "bar", "baz"],
|
||||
},
|
||||
},
|
||||
["b"]
|
||||
)
|
||||
);
|
||||
|
||||
expect(
|
||||
_whereCacheGet(wc, schema, "field", {
|
||||
where: {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "foo",
|
||||
},
|
||||
})
|
||||
).toEqual(["a"]);
|
||||
|
||||
expect(
|
||||
_whereCacheGet(wc, schema, "field", {
|
||||
summarize: {
|
||||
method: "mean",
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
values: ["foo", "bar", "baz"],
|
||||
},
|
||||
})
|
||||
).toEqual(["b"]);
|
||||
|
||||
expect(
|
||||
_whereCacheGet(wc, schema, "field", {
|
||||
where: {
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
value: "does-not-exist",
|
||||
},
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
|
||||
expect(
|
||||
_whereCacheGet(wc, schema, "field", {
|
||||
summarize: {
|
||||
method: "no-such-method",
|
||||
field: "queryField",
|
||||
column: "queryColumn",
|
||||
values: ["does-not-exist"],
|
||||
},
|
||||
})
|
||||
).toEqual([undefined]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import cloneDeep from "lodash.clonedeep";
|
||||
|
||||
import calcCentroid from "../../src/util/centroid";
|
||||
import quantile from "../../src/util/quantile";
|
||||
import { matrixFBSToDataframe } from "../../src/util/stateManager/matrix";
|
||||
import * as REST from "./stateManager/sampleResponses";
|
||||
import { indexEntireSchema } from "../../src/util/stateManager/schemaHelpers";
|
||||
import { normalizeWritableCategoricalSchema } from "../../src/annoMatrix/normalize";
|
||||
|
||||
describe("centroid", () => {
|
||||
let schema;
|
||||
let obsAnnotations;
|
||||
let obsLayout;
|
||||
|
||||
beforeAll(() => {
|
||||
schema = indexEntireSchema(cloneDeep(REST.schema.schema));
|
||||
obsAnnotations = matrixFBSToDataframe(REST.annotationsObs);
|
||||
obsLayout = matrixFBSToDataframe(REST.layoutObs);
|
||||
|
||||
normalizeWritableCategoricalSchema(
|
||||
schema.annotations.obsByName.field3,
|
||||
obsAnnotations.col("field3")
|
||||
);
|
||||
});
|
||||
|
||||
test("field4 (categorical obsAnnotation)", () => {
|
||||
const centroidResult = calcCentroid(
|
||||
schema,
|
||||
"field4",
|
||||
obsAnnotations,
|
||||
{ current: "umap", currentDimNames: ["umap_0", "umap_1"] },
|
||||
obsLayout
|
||||
);
|
||||
|
||||
// Check to see that a centroid has been calculated for every categorical value
|
||||
const keysAsArray = Array.from(centroidResult.keys());
|
||||
expect(keysAsArray).toEqual(
|
||||
expect.arrayContaining([83, true, "foo", 2.222222])
|
||||
);
|
||||
|
||||
// This expected result assumes that all cells belong in all categorical values inside of sample response
|
||||
const expectedResult = [
|
||||
quantile([0.5], obsLayout.col("umap_0").asArray())[0],
|
||||
quantile([0.5], obsLayout.col("umap_1").asArray())[0],
|
||||
];
|
||||
|
||||
centroidResult.forEach((coordinate) => {
|
||||
expect(coordinate).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
test("field3 (boolean obsAnnotation)", () => {
|
||||
const centroidResult = calcCentroid(
|
||||
schema,
|
||||
"field3",
|
||||
obsAnnotations,
|
||||
{ current: "umap", currentDimNames: ["umap_0", "umap_1"] },
|
||||
obsLayout
|
||||
);
|
||||
|
||||
// Check to see that a centroid has been calculated for every categorical value
|
||||
const keysAsArray = Array.from(centroidResult.keys());
|
||||
expect(keysAsArray).toEqual(expect.arrayContaining([false, true]));
|
||||
|
||||
// This expected result assumes that all cells belong in all categorical values inside of sample response
|
||||
const expectedResult = [
|
||||
quantile([0.5], obsLayout.col("umap_0").asArray())[0],
|
||||
quantile([0.5], obsLayout.col("umap_1").asArray())[0],
|
||||
];
|
||||
|
||||
centroidResult.forEach((coordinate) => {
|
||||
expect(coordinate).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
import * as Dataframe from "../../../src/util/dataframe";
|
||||
|
||||
describe("Dataframe column histogram", () => {
|
||||
test("categorical by categorical", () => {
|
||||
const df = new Dataframe.Dataframe(
|
||||
[3, 3],
|
||||
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["name", "cat", "value"])
|
||||
);
|
||||
|
||||
const h1 = df.col("cat").histogram(df.col("name"));
|
||||
expect(h1).toMatchObject(
|
||||
new Map([
|
||||
["n1", new Map([["c1", 1]])],
|
||||
["n2", new Map([["c2", 1]])],
|
||||
["n3", new Map([["c3", 1]])],
|
||||
])
|
||||
);
|
||||
// memoized?
|
||||
expect(df.col("cat").histogram(df.col("name"))).toMatchObject(h1);
|
||||
});
|
||||
|
||||
test("continuous by categorical", () => {
|
||||
const df = new Dataframe.Dataframe(
|
||||
[3, 3],
|
||||
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["name", "cat", "value"])
|
||||
);
|
||||
|
||||
const h1 = df.col("value").histogram(3, [0, 2], df.col("name"));
|
||||
expect(h1).toMatchObject(
|
||||
new Map([
|
||||
["n1", [1, 0, 0]],
|
||||
["n2", [0, 1, 0]],
|
||||
["n3", [0, 0, 1]],
|
||||
])
|
||||
);
|
||||
// memoized?
|
||||
expect(df.col("value").histogram(3, [0, 2], df.col("name"))).toMatchObject(
|
||||
h1
|
||||
);
|
||||
});
|
||||
|
||||
test("categorical", () => {
|
||||
const df = new Dataframe.Dataframe(
|
||||
[3, 3],
|
||||
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["name", "cat", "value"])
|
||||
);
|
||||
|
||||
const h1 = df.col("cat").histogram();
|
||||
expect(h1).toMatchObject(
|
||||
new Map([
|
||||
["c1", 1],
|
||||
["c2", 1],
|
||||
["c3", 1],
|
||||
])
|
||||
);
|
||||
// memoized?
|
||||
expect(df.col("value").histogram(3, [0, 2])).toMatchObject(h1);
|
||||
});
|
||||
|
||||
test("continuous", () => {
|
||||
const df = new Dataframe.Dataframe(
|
||||
[3, 3],
|
||||
[["n1", "n2", "n3"], ["c1", "c2", "c3"], new Int32Array([0, 1, 2])],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["name", "cat", "value"])
|
||||
);
|
||||
|
||||
const h1 = df.col("value").histogram(3, [0, 2]);
|
||||
expect(h1).toMatchObject([1, 1, 1]);
|
||||
// memoized?
|
||||
expect(df.col("value").histogram(3, [0, 2])).toMatchObject(h1);
|
||||
});
|
||||
|
||||
test("continuous thesholds correct", () => {
|
||||
const vals = [0, 1, 9, 10, 11, 20, 99, 100];
|
||||
const df = new Dataframe.Dataframe(
|
||||
[8, 2],
|
||||
[new Int32Array(vals), new Float32Array(vals)]
|
||||
);
|
||||
|
||||
expect(df.col(0).histogram(5, [0, 100])).toEqual([5, 1, 0, 0, 2]);
|
||||
expect(df.col(1).histogram(5, [0, 100])).toEqual([5, 1, 0, 0, 2]);
|
||||
expect(df.col(0).histogram(2, [0, 10])).toEqual([2, 2]);
|
||||
expect(df.col(0).histogram(10, [0, 100])).toEqual([
|
||||
3, 2, 1, 0, 0, 0, 0, 0, 0, 2,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import * as Dataframe from "../../../src/util/dataframe";
|
||||
|
||||
function float32Conversion(f) {
|
||||
return new Float32Array([f])[0];
|
||||
}
|
||||
|
||||
describe("Dataframe column summary", () => {
|
||||
test("empty column test", () => {
|
||||
const df = Dataframe.Dataframe.create([0, 1], [[]]);
|
||||
const summary = df.icol(0).summarize();
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: [],
|
||||
categoryCounts: new Map(),
|
||||
numCategories: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("simple test", () => {
|
||||
const df = new Dataframe.Dataframe(
|
||||
[1, 6],
|
||||
[
|
||||
["n1"],
|
||||
["hi"],
|
||||
[true],
|
||||
new Float32Array([39.3]),
|
||||
new Int32Array([99]),
|
||||
[1],
|
||||
],
|
||||
null,
|
||||
new Dataframe.KeyIndex([
|
||||
"name",
|
||||
"nameString",
|
||||
"nameBoolean",
|
||||
"nameFloat32",
|
||||
"nameInt32",
|
||||
"nameCategorical",
|
||||
])
|
||||
);
|
||||
|
||||
expect(df.icol(0).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: ["n1"],
|
||||
categoryCounts: new Map([["n1", 1]]),
|
||||
numCategories: 1,
|
||||
})
|
||||
);
|
||||
expect(df.icol(1).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: ["hi"],
|
||||
categoryCounts: new Map([["hi", 1]]),
|
||||
numCategories: 1,
|
||||
})
|
||||
);
|
||||
expect(df.icol(2).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: [true],
|
||||
categoryCounts: new Map([[true, 1]]),
|
||||
numCategories: 1,
|
||||
})
|
||||
);
|
||||
expect(df.icol(3).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: false,
|
||||
min: float32Conversion(39.3),
|
||||
max: float32Conversion(39.3),
|
||||
nan: 0,
|
||||
ninf: 0,
|
||||
pinf: 0,
|
||||
})
|
||||
);
|
||||
expect(df.icol(4).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: false,
|
||||
min: 99,
|
||||
max: 99,
|
||||
nan: 0,
|
||||
ninf: 0,
|
||||
pinf: 0,
|
||||
})
|
||||
);
|
||||
expect(df.icol(5).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: [1],
|
||||
categoryCounts: new Map([[1, 1]]),
|
||||
numCategories: 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("multi test", () => {
|
||||
const df = new Dataframe.Dataframe(
|
||||
[3, 6],
|
||||
[
|
||||
["n0", "n1", "n2"],
|
||||
["hi", "hi", "bye"],
|
||||
[false, true, true],
|
||||
new Float32Array([39.3, 39.3, 0]),
|
||||
new Int32Array([99, 99, 99]),
|
||||
[1, false, "0"],
|
||||
],
|
||||
null,
|
||||
new Dataframe.KeyIndex([
|
||||
"name",
|
||||
"nameString",
|
||||
"nameBoolean",
|
||||
"nameFloat32",
|
||||
"nameInt32",
|
||||
"nameCategorical",
|
||||
])
|
||||
);
|
||||
|
||||
expect(df.icol(0).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: expect.arrayContaining(["n0", "n1", "n2"]),
|
||||
categoryCounts: new Map([
|
||||
["n0", 1],
|
||||
["n1", 1],
|
||||
["n2", 1],
|
||||
]),
|
||||
numCategories: 3,
|
||||
})
|
||||
);
|
||||
expect(df.icol(1).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: expect.arrayContaining(["hi", "bye"]),
|
||||
categoryCounts: new Map([
|
||||
["hi", 2],
|
||||
["bye", 1],
|
||||
]),
|
||||
numCategories: 2,
|
||||
})
|
||||
);
|
||||
expect(df.icol(2).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: expect.arrayContaining([true, false]),
|
||||
categoryCounts: new Map([
|
||||
[true, 2],
|
||||
[false, 1],
|
||||
]),
|
||||
numCategories: 2,
|
||||
})
|
||||
);
|
||||
expect(df.icol(3).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: false,
|
||||
min: 0,
|
||||
max: float32Conversion(39.3),
|
||||
nan: 0,
|
||||
ninf: 0,
|
||||
pinf: 0,
|
||||
})
|
||||
);
|
||||
expect(df.icol(4).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: false,
|
||||
min: 99,
|
||||
max: 99,
|
||||
nan: 0,
|
||||
ninf: 0,
|
||||
pinf: 0,
|
||||
})
|
||||
);
|
||||
expect(df.icol(5).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: expect.arrayContaining([1, false, "0"]),
|
||||
categoryCounts: new Map([
|
||||
[1, 1],
|
||||
[false, 1],
|
||||
["0", 1],
|
||||
]),
|
||||
numCategories: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("non-finite numbers", () => {
|
||||
const df = new Dataframe.Dataframe(
|
||||
[4, 6],
|
||||
[
|
||||
["n0", "n1", "n2", "n2"],
|
||||
["hi", "hi", "bye", "bye"],
|
||||
[false, true, true, true],
|
||||
new Float32Array([
|
||||
39.3,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
]),
|
||||
new Int32Array([99, 99, 99, 99]),
|
||||
[1, false, "0", "0"],
|
||||
],
|
||||
null,
|
||||
new Dataframe.KeyIndex([
|
||||
"name",
|
||||
"nameString",
|
||||
"nameBoolean",
|
||||
"nameFloat32",
|
||||
"nameInt32",
|
||||
"nameCategorical",
|
||||
])
|
||||
);
|
||||
|
||||
expect(df.icol(0).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: expect.arrayContaining(["n0", "n1", "n2"]),
|
||||
categoryCounts: new Map([
|
||||
["n0", 1],
|
||||
["n1", 1],
|
||||
["n2", 2],
|
||||
]),
|
||||
numCategories: 3,
|
||||
})
|
||||
);
|
||||
expect(df.icol(1).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: expect.arrayContaining(["hi", "bye"]),
|
||||
categoryCounts: new Map([
|
||||
["hi", 2],
|
||||
["bye", 1],
|
||||
]),
|
||||
numCategories: 2,
|
||||
})
|
||||
);
|
||||
expect(df.icol(2).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: expect.arrayContaining([true, false]),
|
||||
categoryCounts: new Map([
|
||||
[true, 2],
|
||||
[false, 1],
|
||||
]),
|
||||
numCategories: 2,
|
||||
})
|
||||
);
|
||||
expect(df.icol(3).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: false,
|
||||
min: float32Conversion(39.3),
|
||||
max: float32Conversion(39.3),
|
||||
nan: 1,
|
||||
ninf: 1,
|
||||
pinf: 1,
|
||||
})
|
||||
);
|
||||
expect(df.icol(4).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: false,
|
||||
min: 99,
|
||||
max: 99,
|
||||
nan: 0,
|
||||
ninf: 0,
|
||||
pinf: 0,
|
||||
})
|
||||
);
|
||||
expect(df.icol(5).summarize()).toEqual(
|
||||
expect.objectContaining({
|
||||
categorical: true,
|
||||
categories: expect.arrayContaining([1, false, "0"]),
|
||||
categoryCounts: new Map([
|
||||
[1, 1],
|
||||
[false, 1],
|
||||
["0", 1],
|
||||
]),
|
||||
numCategories: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
obsAnnoDimensionName,
|
||||
diffexpDimensionName,
|
||||
userDefinedDimensionName,
|
||||
makeContinuousDimensionName
|
||||
makeContinuousDimensionName,
|
||||
} from "../../src/util/nameCreators";
|
||||
|
||||
describe("nameCreators", () => {
|
||||
@@ -14,25 +14,25 @@ describe("nameCreators", () => {
|
||||
layoutDimensionName,
|
||||
obsAnnoDimensionName,
|
||||
diffexpDimensionName,
|
||||
userDefinedDimensionName
|
||||
userDefinedDimensionName,
|
||||
];
|
||||
|
||||
test("check for namespace isolation", () => {
|
||||
const foo = "foo";
|
||||
nameCreators.forEach(fn => expect(fn(foo)).not.toBe(foo));
|
||||
nameCreators.forEach((fn) => expect(fn(foo)).not.toBe(foo));
|
||||
|
||||
const bar = "bar";
|
||||
nameCreators.forEach(fn => expect(fn(foo)).not.toBe(fn(bar)));
|
||||
nameCreators.forEach((fn) => expect(fn(foo)).not.toBe(fn(bar)));
|
||||
|
||||
nameCreators.forEach(fn => {
|
||||
const allOtherFn = nameCreators.filter(elmnt => elmnt !== fn);
|
||||
allOtherFn.forEach(otherFn => expect(fn(foo)).not.toBe(otherFn(foo)));
|
||||
nameCreators.forEach((fn) => {
|
||||
const allOtherFn = nameCreators.filter((elmnt) => elmnt !== fn);
|
||||
allOtherFn.forEach((otherFn) => expect(fn(foo)).not.toBe(otherFn(foo)));
|
||||
});
|
||||
});
|
||||
|
||||
test("check for legal keys", () => {
|
||||
/* need namespace creators to return strings only */
|
||||
nameCreators.forEach(fn => expect(fn("X")).toMatch(/X/));
|
||||
nameCreators.forEach((fn) => expect(fn("X")).toMatch(/X/));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import PromiseLimit from "../../src/util/promiseLimit";
|
||||
import { range } from "../../src/util/range";
|
||||
|
||||
const delay = (t) => new Promise((resolve) => setTimeout(resolve, t));
|
||||
|
||||
describe("PromiseLimit", () => {
|
||||
test("simple evaluation, concurrency 1", async () => {
|
||||
const plimit = new PromiseLimit(1);
|
||||
const result = await Promise.all([
|
||||
plimit.add(() => Promise.resolve(1)),
|
||||
plimit.add(() => Promise.resolve(2)),
|
||||
plimit.add(() => Promise.resolve(3)),
|
||||
plimit.add(() => Promise.resolve(4)),
|
||||
]);
|
||||
expect(result).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test("simple evaluation, concurrency > 1", async () => {
|
||||
const plimit = new PromiseLimit(100);
|
||||
const result = await Promise.all([
|
||||
plimit.add(() => Promise.resolve(1)),
|
||||
plimit.add(() => Promise.resolve(2)),
|
||||
plimit.add(() => Promise.resolve(3)),
|
||||
plimit.add(() => Promise.resolve(4)),
|
||||
]);
|
||||
expect(result).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test("eval in order of insertion", async () => {
|
||||
const plimit = new PromiseLimit(100);
|
||||
|
||||
const result = await Promise.all([
|
||||
plimit.add(() => Promise.resolve(1)),
|
||||
plimit.add(() => Promise.resolve(2)),
|
||||
plimit.add(() => Promise.resolve(3)),
|
||||
plimit.add(() => Promise.resolve(4)),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test("obeys concurrency limit", async () => {
|
||||
const plimit = new PromiseLimit(2);
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
|
||||
const callback = async () => {
|
||||
running += 1;
|
||||
maxRunning = running > maxRunning ? running : maxRunning;
|
||||
await delay(100);
|
||||
running -= 1;
|
||||
};
|
||||
|
||||
await Promise.all(range(10).map((i) => plimit.add(() => callback(i))));
|
||||
|
||||
expect(maxRunning).toEqual(2);
|
||||
});
|
||||
|
||||
test("rejection", async () => {
|
||||
const plimit = new PromiseLimit(2);
|
||||
const result = await Promise.all([
|
||||
plimit.add(() => Promise.resolve("OK")),
|
||||
// eslint-disable-next-line prefer-promise-reject-errors -- unit test
|
||||
plimit.add(() => Promise.reject("not OK")).catch((e) => e),
|
||||
plimit.add(() => Promise.resolve("OK")),
|
||||
plimit
|
||||
.add(() => {
|
||||
throw new Error("not OK");
|
||||
})
|
||||
.catch((e) => e.message),
|
||||
]);
|
||||
expect(result).toEqual(["OK", "not OK", "OK", "not OK"]);
|
||||
});
|
||||
|
||||
test("priority queue", async () => {
|
||||
const plimit = new PromiseLimit(1);
|
||||
|
||||
let finishOrder = 0;
|
||||
const callback = () => async () => {
|
||||
await delay(100);
|
||||
const result = finishOrder;
|
||||
finishOrder += 1;
|
||||
return result;
|
||||
};
|
||||
|
||||
const result = await Promise.all([
|
||||
plimit.add(callback()),
|
||||
plimit.priorityAdd(4, callback()),
|
||||
plimit.priorityAdd(0, callback()),
|
||||
plimit.priorityAdd(1, callback()),
|
||||
plimit.priorityAdd(-1, callback()),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([0, 4, 2, 3, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import quantile from "../../src/util/quantile";
|
||||
|
||||
describe("quantile", () => {
|
||||
test("single q", () => {
|
||||
const arr = new Float32Array([9, 3, 5, 6, 0]);
|
||||
expect(quantile([1.0], arr)).toMatchObject([9]);
|
||||
expect(quantile([0.9], arr)).toMatchObject([9]);
|
||||
expect(quantile([0.8], arr)).toMatchObject([9]);
|
||||
expect(quantile([0.7], arr)).toMatchObject([6]);
|
||||
expect(quantile([0.6], arr)).toMatchObject([6]);
|
||||
expect(quantile([0.5], arr)).toMatchObject([5]);
|
||||
expect(quantile([0.4], arr)).toMatchObject([5]);
|
||||
expect(quantile([0.3], arr)).toMatchObject([3]);
|
||||
expect(quantile([0.2], arr)).toMatchObject([3]);
|
||||
expect(quantile([0.1], arr)).toMatchObject([0]);
|
||||
expect(quantile([0], arr)).toMatchObject([0]);
|
||||
});
|
||||
|
||||
test("multi q", () => {
|
||||
const arr = new Float32Array([9, 3, 5, 6, 0]);
|
||||
expect(quantile([0, 0.25, 0.5, 0.75, 1.0], arr)).toMatchObject([
|
||||
0, 3, 5, 6, 9,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { range, rangeFill, linspace } from "../../src/util/range";
|
||||
|
||||
describe("range", () => {
|
||||
test("no defaults", () => {
|
||||
expect(range(0, 3, 1)).toMatchObject([0, 1, 2]);
|
||||
});
|
||||
|
||||
test("range(stop)", () => {
|
||||
expect(range(3)).toMatchObject([0, 1, 2]);
|
||||
expect(range(0)).toMatchObject([]);
|
||||
expect(range(1)).toMatchObject([0]);
|
||||
});
|
||||
|
||||
test("range(start,stop)", () => {
|
||||
expect(range(0, 0)).toMatchObject([]);
|
||||
expect(range(0, 2)).toMatchObject([0, 1]);
|
||||
expect(range(4, 8)).toMatchObject([4, 5, 6, 7]);
|
||||
});
|
||||
|
||||
test("range(start, stop, step", () => {
|
||||
expect(range(4, 0, -1)).toMatchObject([4, 3, 2, 1]);
|
||||
expect(range(0, 4, 2)).toMatchObject([0, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rangefill", () => {
|
||||
test("rangeFill(arr)", () => {
|
||||
expect(rangeFill(new Int32Array(3))).toMatchObject(
|
||||
new Int32Array([0, 1, 2])
|
||||
);
|
||||
});
|
||||
test("rangeFill(arr, start)", () => {
|
||||
expect(rangeFill(new Int32Array(2), 1)).toMatchObject(
|
||||
new Int32Array([1, 2])
|
||||
);
|
||||
});
|
||||
test("rangeFill(arr, start, step)", () => {
|
||||
expect(rangeFill(new Int32Array(3), 2, -1)).toMatchObject(
|
||||
new Int32Array([2, 1, 0])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("linspace", () => {
|
||||
test("linspace(arr, start, step)", () => {
|
||||
expect(linspace(0.0, 2.0, 5)).toMatchObject([0.0, 0.5, 1.0, 1.5, 2.0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/* eslint-disable no-bitwise -- unsigned right shift better than Math.round */
|
||||
|
||||
/*
|
||||
test color helpers
|
||||
*/
|
||||
import {
|
||||
createColorTable,
|
||||
loadUserColorConfig,
|
||||
} from "../../../src/util/stateManager/colorHelpers";
|
||||
import * as Dataframe from "../../../src/util/dataframe";
|
||||
|
||||
describe("categorical color helpers", () => {
|
||||
/*
|
||||
Primary test constraint for categorical colors is that they are ordered/identified
|
||||
by schema order, NOT by value. Ie,
|
||||
|
||||
scale(schemaIndex) should match rgb[obsOffset]
|
||||
*/
|
||||
|
||||
const schema = indexSchema({
|
||||
annotations: {
|
||||
obs: {
|
||||
columns: [
|
||||
{
|
||||
name: "name_0",
|
||||
type: "string",
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
name: "continuousColumn",
|
||||
type: "float32",
|
||||
writable: false,
|
||||
},
|
||||
{
|
||||
categories: [
|
||||
"CD4 T cells",
|
||||
"CD14+ Monocytes",
|
||||
"B cells",
|
||||
"CD8 T cells",
|
||||
"NK cells",
|
||||
"FCGR3A+ Monocytes",
|
||||
"Dendritic cells",
|
||||
"Megakaryocytes",
|
||||
],
|
||||
name: "categoricalColumn",
|
||||
type: "categorical",
|
||||
writable: false,
|
||||
},
|
||||
],
|
||||
index: "name_0",
|
||||
},
|
||||
var: {
|
||||
columns: [
|
||||
{
|
||||
name: "name_0",
|
||||
type: "string",
|
||||
writable: false,
|
||||
},
|
||||
],
|
||||
index: "name_0",
|
||||
},
|
||||
},
|
||||
dataframe: {
|
||||
nObs: 2638,
|
||||
nVar: 1838,
|
||||
type: "float32",
|
||||
},
|
||||
layout: {},
|
||||
});
|
||||
|
||||
const catColCategories = schema.annotations.obs.columns[2].categories;
|
||||
const obsDataframe = new Dataframe.Dataframe(
|
||||
[schema.dataframe.nObs, 2],
|
||||
[
|
||||
new Float32Array(schema.dataframe.nObs).map(() => Math.random()),
|
||||
new Array(schema.dataframe.nObs)
|
||||
.fill("")
|
||||
.map(
|
||||
() =>
|
||||
catColCategories[(Math.random() * catColCategories.length) >>> 0]
|
||||
),
|
||||
],
|
||||
null,
|
||||
new Dataframe.KeyIndex(["continuousColumn", "categoricalColumn"])
|
||||
);
|
||||
|
||||
test("default category order", () => {
|
||||
const ct = createColorTable(
|
||||
"color by categorical metadata",
|
||||
"categoricalColumn",
|
||||
obsDataframe,
|
||||
schema
|
||||
);
|
||||
expect(ct).toBeDefined();
|
||||
const data = obsDataframe.col("categoricalColumn").asArray();
|
||||
const cats = schema.annotations.obsByName.categoricalColumn.categories;
|
||||
for (let i = 0; i < schema.dataframe.nObs; i += 1) {
|
||||
expect(makeScale(ct.rgb[i])).toEqual(ct.scale(cats.indexOf(data[i])));
|
||||
}
|
||||
});
|
||||
|
||||
test("shuffle category order", () => {
|
||||
const schemaClone = indexSchema(JSON.parse(JSON.stringify(schema)));
|
||||
shuffle(schemaClone.annotations.obsByName.categoricalColumn.categories);
|
||||
const ct = createColorTable(
|
||||
"color by categorical metadata",
|
||||
"categoricalColumn",
|
||||
obsDataframe,
|
||||
schemaClone
|
||||
);
|
||||
expect(ct).toBeDefined();
|
||||
const data = obsDataframe.col("categoricalColumn").asArray();
|
||||
const cats = schemaClone.annotations.obsByName.categoricalColumn.categories;
|
||||
for (let i = 0; i < schemaClone.dataframe.nObs; i += 1) {
|
||||
expect(makeScale(ct.rgb[i])).toEqual(ct.scale(cats.indexOf(data[i])));
|
||||
}
|
||||
});
|
||||
|
||||
test("user defined color order", () => {
|
||||
const cats = schema.annotations.obsByName.categoricalColumn.categories;
|
||||
const shuffleCats = shuffle(
|
||||
Array.from(schema.annotations.obsByName.categoricalColumn.categories)
|
||||
);
|
||||
const userDefinedColorTable = {
|
||||
categoricalColumn: shuffleCats.reduce((acc, label) => {
|
||||
acc[label] = randRGBColor();
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
const userColors = loadUserColorConfig(userDefinedColorTable);
|
||||
expect(userColors).toBeDefined();
|
||||
|
||||
const ct = createColorTable(
|
||||
"color by categorical metadata",
|
||||
"categoricalColumn",
|
||||
obsDataframe,
|
||||
schema,
|
||||
userColors
|
||||
);
|
||||
expect(ct).toBeDefined();
|
||||
const data = obsDataframe.col("categoricalColumn").asArray();
|
||||
for (let i = 0; i < schema.dataframe.nObs; i += 1) {
|
||||
expect(makeScale(ct.rgb[i])).toEqual(
|
||||
ct.scale(cats.indexOf(data[i])).toString()
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
TODO:
|
||||
1. mix up category order in schema to make sure it works with varied order
|
||||
2. user defined colors
|
||||
*/
|
||||
|
||||
function indexSchema(schema) {
|
||||
schema.annotations.obsByName = Object.fromEntries(
|
||||
schema.annotations?.obs?.columns?.map((v) => [v.name, v]) ?? []
|
||||
);
|
||||
schema.annotations.varByName = Object.fromEntries(
|
||||
schema.annotations?.var?.columns?.map((v) => [v.name, v]) ?? []
|
||||
);
|
||||
schema.layout.obsByName = Object.fromEntries(
|
||||
schema.layout?.obs?.map((v) => [v.name, v]) ?? []
|
||||
);
|
||||
schema.layout.varByName = Object.fromEntries(
|
||||
schema.layout?.var?.map((v) => [v.name, v]) ?? []
|
||||
);
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
function makeScale(rgb) {
|
||||
// make a scale string from a rgb float triple
|
||||
return `rgb(${(rgb[0] * 255) >>> 0}, ${(rgb[1] * 255) >>> 0}, ${
|
||||
(rgb[2] * 256) >>> 0
|
||||
})`;
|
||||
}
|
||||
|
||||
function shuffle(array) {
|
||||
for (let i = array.length - 1; i > 0; i -= 1) {
|
||||
const j = (Math.random() * (i + 1)) >>> 0;
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
function randHexColor() {
|
||||
const hex = ((Math.random() * 255) >>> 0).toString(16);
|
||||
return `0${hex}`.slice(-2);
|
||||
}
|
||||
|
||||
function randRGBColor() {
|
||||
return `#${randHexColor()}${randHexColor()}${randHexColor()}`;
|
||||
}
|
||||
|
||||
/* eslint-enable no-bitwise -- unsigned right shift better than Math.round */
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
test controls helpers
|
||||
*/
|
||||
// TODO #2227 test: improve test coverage on control helper functions
|
||||
// (`isSelectableCategoryName()`, `selectableCategoryNames()`, `createCategorySummaryFromDfCol()`, `createCategoricalSelection()`, )
|
||||
|
||||
describe("controls helpers", () => {
|
||||
test("placeholder", () => {});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
test FBS encode/decode API
|
||||
*/
|
||||
import { Dataframe, KeyIndex } from "../../../src/util/dataframe";
|
||||
import {
|
||||
decodeMatrixFBS,
|
||||
encodeMatrixFBS,
|
||||
} from "../../../src/util/stateManager/matrix";
|
||||
|
||||
describe("encode/decode", () => {
|
||||
test("round trip", () => {
|
||||
const columns = [
|
||||
["red", "green", "blue"],
|
||||
new Int32Array(3).fill(0),
|
||||
new Uint32Array(3).fill(1),
|
||||
new Float32Array(3).fill(2),
|
||||
];
|
||||
|
||||
const dfNoColIdx = new Dataframe([3, 4], columns);
|
||||
const dfA = decodeMatrixFBS(encodeMatrixFBS(dfNoColIdx));
|
||||
expect([dfA.nRows, dfA.nCols]).toEqual(dfNoColIdx.dims);
|
||||
expect(dfA.colIdx).toBeNull();
|
||||
expect(dfA.rowIdx).toBeNull();
|
||||
expect(dfA.columns).toEqual(columns);
|
||||
|
||||
const colIndex = new KeyIndex(["a", "b", "c", "d"]);
|
||||
const dfWithColIdx = new Dataframe([3, 4], columns, null, colIndex);
|
||||
const dfB = decodeMatrixFBS(encodeMatrixFBS(dfWithColIdx));
|
||||
expect([dfB.nRows, dfB.nCols]).toEqual(dfWithColIdx.dims);
|
||||
expect(dfB.colIdx).toEqual(colIndex.labels());
|
||||
expect(dfB.rowIdx).toBeNull();
|
||||
expect(dfB.columns).toEqual(columns);
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
import _ from "lodash";
|
||||
import * as kvCache from "../../../src/util/stateManager/keyvalcache";
|
||||
|
||||
/*
|
||||
This is PRIVATE to keyvalcache and must be kept in sync with
|
||||
any changs ot that module. Need to Know - to enable error handling test
|
||||
*/
|
||||
const cachePrivateKey = "__kvcachekey__";
|
||||
|
||||
/*
|
||||
helper function - promisify setTimeout()
|
||||
*/
|
||||
function timeout(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe("kvcache API", () => {
|
||||
/*
|
||||
test the happy path create/set/get API
|
||||
*/
|
||||
|
||||
test("simple create", () => {
|
||||
/* with defaults */
|
||||
const kvc = kvCache.create();
|
||||
expect(kvc).toBeDefined();
|
||||
expect(kvc).toEqual(expect.objectContaining({}));
|
||||
expect(kvCache.get(kvc, "test")).toBeUndefined();
|
||||
|
||||
/* with params */
|
||||
const kvc1 = kvCache.create(/* lowWatermark */ 99, /* minTTL */ 0);
|
||||
expect(kvc1).toBeDefined();
|
||||
expect(kvc1).toEqual(expect.objectContaining({}));
|
||||
});
|
||||
|
||||
test("set/get", () => {
|
||||
/*
|
||||
- check basic get/set functionality
|
||||
- check set does not mutate source cache
|
||||
*/
|
||||
const keyName = "foo";
|
||||
const kvc1 = kvCache.create();
|
||||
expect(kvc1).toBeDefined();
|
||||
expect(kvCache.get(kvc1, keyName)).toBeUndefined();
|
||||
|
||||
const val2 = [2];
|
||||
const kvc2 = kvCache.set(kvc1, keyName, val2);
|
||||
expect(kvc2).toBeDefined();
|
||||
expect(kvc2).not.toBe(kvc1);
|
||||
expect(kvCache.get(kvc1, keyName)).toBeUndefined();
|
||||
expect(kvCache.get(kvc2, keyName)).toBe(val2);
|
||||
|
||||
const val3 = [3];
|
||||
const kvc3 = kvCache.set(kvc2, keyName, val3);
|
||||
expect(kvc3).toBeDefined();
|
||||
expect(kvc3).not.toBe(kvc1);
|
||||
expect(kvc3).not.toBe(kvc2);
|
||||
expect(kvCache.get(kvc1, keyName)).toBeUndefined();
|
||||
expect(kvCache.get(kvc2, keyName)).toBe(val2);
|
||||
expect(kvCache.get(kvc3, keyName)).toBe(val3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("common error handling", () => {
|
||||
/*
|
||||
Test common error handlers
|
||||
*/
|
||||
|
||||
test("set() protection from namespace pollution", () => {
|
||||
/*
|
||||
Test that set() will not allow use of the private cache key
|
||||
*/
|
||||
const kvc = kvCache.create();
|
||||
expect(() => {
|
||||
kvCache.set(kvc, cachePrivateKey, {});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test("create() does not accept bogus config", () => {
|
||||
expect(() => {
|
||||
kvCache.create([], {});
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
kvCache.create(-99, 0);
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
kvCache.create(100, -1);
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
kvCache.create(1000, "foobar");
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
kvCache.create(null, 8);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("map", () => {
|
||||
/*
|
||||
Test kvCache.map() - create new cache that is a transformation of an
|
||||
existing cache
|
||||
*/
|
||||
test("map of empty cache", () => {
|
||||
const kvc = kvCache.create();
|
||||
const callback = jest.fn();
|
||||
const kvcMapped = kvCache.map(kvc, callback);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
expect(kvcMapped).toBeDefined();
|
||||
expect(kvcMapped).not.toBe(kvc); // immutable operation
|
||||
expect(kvcMapped).toEqual(kvc);
|
||||
});
|
||||
|
||||
test("map of non-empty cache", () => {
|
||||
const key = "aKey";
|
||||
const val = [0, 1, 2];
|
||||
let kvc = kvCache.create();
|
||||
kvc = kvCache.set(kvc, key, val);
|
||||
const mockCB = jest.fn().mockImplementation(v => [...v]);
|
||||
const kvcMapped = kvCache.map(kvc, mockCB);
|
||||
|
||||
expect(kvcMapped).toBeDefined();
|
||||
expect(kvcMapped).not.toBe(kvc); // immutable operation
|
||||
expect(_.isEqual(kvc, kvcMapped)).toBe(true);
|
||||
|
||||
expect(mockCB).toHaveBeenCalledTimes(1);
|
||||
expect(mockCB).toHaveBeenLastCalledWith(val, key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flush", () => {
|
||||
/*
|
||||
test various cache flush behavior
|
||||
*/
|
||||
test("flush - lowWatermark, disable minTTL", () => {
|
||||
/*
|
||||
verify lowWatermark functions correctly
|
||||
*/
|
||||
|
||||
// set lowWatermark to 2, set three times - only the final two
|
||||
// should remain.
|
||||
let kvc = kvCache.create(2, 0);
|
||||
["a", "b", "c"].forEach(k => {
|
||||
kvc = kvCache.set(kvc, k, []);
|
||||
});
|
||||
|
||||
expect(kvc).toEqual(
|
||||
expect.objectContaining({
|
||||
b: expect.arrayContaining([]),
|
||||
c: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
expect(kvc).toEqual(
|
||||
expect.not.objectContaining({
|
||||
a: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("flush - minTTL, disable lowWatermark", async () => {
|
||||
/*
|
||||
verify minTTL functions correctly
|
||||
*/
|
||||
|
||||
// set minTTL to 1 ms
|
||||
let kvc = kvCache.create(0, 10);
|
||||
kvc = kvCache.set(kvc, "a", []);
|
||||
await timeout(20);
|
||||
["b", "c"].forEach(k => {
|
||||
kvc = kvCache.set(kvc, k, []);
|
||||
});
|
||||
|
||||
expect(kvc).toEqual(
|
||||
expect.objectContaining({
|
||||
b: expect.arrayContaining([]),
|
||||
c: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
expect(kvc).toEqual(
|
||||
expect.not.objectContaining({
|
||||
a: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("flush - minTTL and lowWatermark", async () => {
|
||||
/*
|
||||
verify minTTL functions correctly
|
||||
*/
|
||||
|
||||
// set lowwatermark to 3, minTTL to 1 ms
|
||||
let kvc = kvCache.create(3, 10);
|
||||
kvc = kvCache.set(kvc, "a", []);
|
||||
// delay
|
||||
await timeout(20);
|
||||
["b", "c"].forEach(k => {
|
||||
kvc = kvCache.set(kvc, k, []);
|
||||
});
|
||||
|
||||
expect(kvc).toEqual(
|
||||
expect.objectContaining({
|
||||
a: expect.arrayContaining([]),
|
||||
b: expect.arrayContaining([]),
|
||||
c: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
|
||||
kvc = kvCache.set(kvc, "d", []);
|
||||
expect(kvc).toEqual(
|
||||
expect.objectContaining({
|
||||
b: expect.arrayContaining([]),
|
||||
c: expect.arrayContaining([]),
|
||||
d: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
expect(kvc).toEqual(
|
||||
expect.not.objectContaining({
|
||||
a: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("manual flush", async () => {
|
||||
let kvc = kvCache.create(1, 10);
|
||||
["a", "b", "c", "d"].forEach(k => {
|
||||
kvc = kvCache.set(kvc, k, []);
|
||||
});
|
||||
|
||||
// Before TTL has expired, should have all values in cache.
|
||||
expect(kvc).toEqual(
|
||||
expect.objectContaining({
|
||||
a: expect.arrayContaining([]),
|
||||
b: expect.arrayContaining([]),
|
||||
c: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
|
||||
// let TTL expire
|
||||
await timeout(10);
|
||||
|
||||
// manually flush
|
||||
const postFlushKvc = kvCache.flush(kvc);
|
||||
expect(postFlushKvc).toBeDefined();
|
||||
expect(postFlushKvc).not.toBe(kvc);
|
||||
expect(postFlushKvc).toEqual(
|
||||
expect.objectContaining({
|
||||
d: expect.arrayContaining([])
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
/* eslint no-bitwise: "off" */
|
||||
import every from "lodash.every";
|
||||
import map from "lodash.map";
|
||||
import isNumber from "lodash.isnumber";
|
||||
import zip from "lodash.zip";
|
||||
import _ from "lodash";
|
||||
import { flatbuffers } from "flatbuffers";
|
||||
import { NetEncoding } from "../../../src/util/stateManager/matrix_generated";
|
||||
|
||||
/*
|
||||
test data mocking REST 0.2 API responses. Used in several tests.
|
||||
@@ -15,13 +20,13 @@ const aConfigResponse = {
|
||||
{ method: "POST", path: "/cluster/", available: false },
|
||||
{ method: "POST", path: "/layout/", available: false },
|
||||
{ method: "POST", path: "/diffexp/", available: false },
|
||||
{ method: "POST", path: "/saveLocal/", available: false }
|
||||
{ method: "POST", path: "/saveLocal/", available: false },
|
||||
],
|
||||
displayNames: {
|
||||
engine: "the little engine that could",
|
||||
dataset: "all your zeros are mine"
|
||||
}
|
||||
}
|
||||
dataset: "all your zeros are mine",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const aSchemaResponse = {
|
||||
@@ -29,88 +34,173 @@ const aSchemaResponse = {
|
||||
dataframe: {
|
||||
nObs,
|
||||
nVar,
|
||||
type: "float32"
|
||||
type: "float32",
|
||||
},
|
||||
annotations: {
|
||||
obs: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "field1", type: "int32" },
|
||||
{ name: "field2", type: "float32" },
|
||||
{ name: "field3", type: "boolean" },
|
||||
{
|
||||
name: "field4",
|
||||
type: "categorical",
|
||||
categories: field4Categories
|
||||
}
|
||||
],
|
||||
var: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "fieldA", type: "int32" },
|
||||
{ name: "fieldB", type: "float32" },
|
||||
{ name: "fieldC", type: "boolean" },
|
||||
{
|
||||
name: "fieldD",
|
||||
type: "categorical",
|
||||
categories: fieldDCategories
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
obs: {
|
||||
index: "name",
|
||||
columns: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "field1", type: "int32" },
|
||||
{ name: "field2", type: "float32" },
|
||||
{ name: "field3", type: "boolean" },
|
||||
{
|
||||
name: "field4",
|
||||
type: "categorical",
|
||||
categories: field4Categories,
|
||||
},
|
||||
],
|
||||
},
|
||||
var: {
|
||||
index: "name",
|
||||
columns: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "fieldA", type: "int32" },
|
||||
{ name: "fieldB", type: "float32" },
|
||||
{ name: "fieldC", type: "boolean" },
|
||||
{
|
||||
name: "fieldD",
|
||||
type: "categorical",
|
||||
categories: fieldDCategories,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
obs: [{ name: "umap", type: "float32", dims: ["umap_0", "umap_1"] }],
|
||||
var: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const anAnnotationsObsResponse = {
|
||||
const anAnnotationsObsJSONResponse = {
|
||||
names: ["name", "field1", "field2", "field3", "field4"],
|
||||
data: _()
|
||||
.range(nObs)
|
||||
.map(idx => [
|
||||
.map((idx) => [
|
||||
idx,
|
||||
`obs${idx}`,
|
||||
2 * idx,
|
||||
idx + 0.0133,
|
||||
// eslint-disable-next-line no-bitwise -- idx & 1 to check for odd numbers
|
||||
!!(idx & 1),
|
||||
field4Categories[idx % field4Categories.length]
|
||||
field4Categories[idx % field4Categories.length],
|
||||
])
|
||||
.value()
|
||||
.value(),
|
||||
};
|
||||
|
||||
const anAnnotationsVarResponse = {
|
||||
const anAnnotationsVarJSONResponse = {
|
||||
names: ["fieldA", "fieldB", "fieldC", "fieldD", "name"],
|
||||
data: _()
|
||||
.range(nVar)
|
||||
.map(idx => [
|
||||
.map((idx) => [
|
||||
idx,
|
||||
10 * idx,
|
||||
idx + 2.90143,
|
||||
// eslint-disable-next-line no-bitwise -- idx & 1 to check for odd numbers
|
||||
!!(idx & 1),
|
||||
fieldDCategories[idx % fieldDCategories.length],
|
||||
`var${idx}`
|
||||
`var${idx}`,
|
||||
])
|
||||
.value()
|
||||
.value(),
|
||||
};
|
||||
|
||||
const aLayoutResponse = {
|
||||
layout: {
|
||||
ndims: 2,
|
||||
coordinates: _()
|
||||
.range(nObs)
|
||||
.map(idx => [idx, Math.random(), Math.random()])
|
||||
.value()
|
||||
function encodeTypedArray(builder, uType, uData) {
|
||||
const uTypeName = NetEncoding.TypedArray[uType];
|
||||
const ArrayType = NetEncoding[uTypeName];
|
||||
const dv = ArrayType.createDataVector(builder, uData);
|
||||
builder.startObject(1);
|
||||
builder.addFieldOffset(0, dv, 0);
|
||||
return builder.endObject();
|
||||
}
|
||||
|
||||
function encodeMatrix(columns, colIndex = undefined) {
|
||||
/*
|
||||
IMPORTANT: this is not a general purpose encoder. in particular,
|
||||
it doesn't correctly handle all column index types, nor does it
|
||||
handle all column typedarray types.
|
||||
|
||||
encodeMatrixFBS in matrix.py is more general. This is used only
|
||||
as a testing santity check (alt implementation).
|
||||
*/
|
||||
const utf8Encoder = new TextEncoder("utf-8");
|
||||
const builder = new flatbuffers.Builder(1024);
|
||||
const cols = map(columns, (carr) => {
|
||||
let uType;
|
||||
let tarr;
|
||||
if (every(carr, isNumber)) {
|
||||
uType = NetEncoding.TypedArray.Float32Array;
|
||||
tarr = encodeTypedArray(builder, uType, new Float32Array(carr));
|
||||
} else {
|
||||
uType = NetEncoding.TypedArray.JSONEncodedArray;
|
||||
const json = JSON.stringify(carr);
|
||||
const jsonUTF8 = utf8Encoder.encode(json);
|
||||
tarr = encodeTypedArray(builder, uType, jsonUTF8);
|
||||
}
|
||||
NetEncoding.Column.startColumn(builder);
|
||||
NetEncoding.Column.addUType(builder, uType);
|
||||
NetEncoding.Column.addU(builder, tarr);
|
||||
return NetEncoding.Column.endColumn(builder);
|
||||
});
|
||||
|
||||
const encColumns = NetEncoding.Matrix.createColumnsVector(builder, cols);
|
||||
|
||||
let encColIndex;
|
||||
if (colIndex) {
|
||||
encColIndex = encodeTypedArray(
|
||||
builder,
|
||||
NetEncoding.TypedArray.JSONEncodedArray,
|
||||
utf8Encoder.encode(JSON.stringify(colIndex))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
NetEncoding.Matrix.startMatrix(builder);
|
||||
NetEncoding.Matrix.addNRows(builder, columns[0].length);
|
||||
NetEncoding.Matrix.addNCols(builder, columns.length);
|
||||
NetEncoding.Matrix.addColumns(builder, encColumns);
|
||||
if (colIndex) {
|
||||
NetEncoding.Matrix.addColIndexType(
|
||||
builder,
|
||||
NetEncoding.TypedArray.JSONEncodedArray
|
||||
);
|
||||
NetEncoding.Matrix.addColIndex(builder, encColIndex);
|
||||
}
|
||||
const root = NetEncoding.Matrix.endMatrix(builder);
|
||||
builder.finish(root);
|
||||
return builder.asUint8Array();
|
||||
}
|
||||
|
||||
const anAnnotationsObsFBSResponse = (() => {
|
||||
const columns = zip(...anAnnotationsObsJSONResponse.data).slice(1);
|
||||
return encodeMatrix(columns, anAnnotationsObsJSONResponse.names);
|
||||
})();
|
||||
|
||||
const anAnnotationsVarFBSResponse = (() => {
|
||||
const columns = zip(...anAnnotationsVarJSONResponse.data).slice(1);
|
||||
return encodeMatrix(columns, anAnnotationsVarJSONResponse.names);
|
||||
})();
|
||||
|
||||
const aLayoutFBSResponse = (() => {
|
||||
const coords = [
|
||||
new Float32Array(nObs).fill(Math.random()),
|
||||
new Float32Array(nObs).fill(Math.random()),
|
||||
];
|
||||
return encodeMatrix(coords, ["umap_0", "umap_1"]);
|
||||
})();
|
||||
|
||||
const aDataObsResponse = {
|
||||
var: [2, 4, 29],
|
||||
obs: _()
|
||||
.range(nObs)
|
||||
.map(idx => [idx, Math.random(), Math.random(), Math.random()])
|
||||
.value()
|
||||
.map((idx) => [idx, Math.random(), Math.random(), Math.random()])
|
||||
.value(),
|
||||
};
|
||||
|
||||
export {
|
||||
aLayoutResponse as layoutObs,
|
||||
aLayoutFBSResponse as layoutObs,
|
||||
aDataObsResponse as dataObs,
|
||||
anAnnotationsVarResponse as annotationsVar,
|
||||
anAnnotationsObsResponse as annotationsObs,
|
||||
anAnnotationsVarFBSResponse as annotationsVar,
|
||||
anAnnotationsObsFBSResponse as annotationsObs,
|
||||
aSchemaResponse as schema,
|
||||
aConfigResponse as config
|
||||
aConfigResponse as config,
|
||||
};
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import _ from "lodash";
|
||||
import * as Universe from "../../../src/util/stateManager/universe";
|
||||
import * as REST from "./sampleResponses";
|
||||
|
||||
describe("createUniverseFromRestV02Response", () => {
|
||||
/*
|
||||
test createUniverseFromRestV02Response - this function converts
|
||||
a set of REST 0.2 responses into a "new" Universe.
|
||||
|
||||
createUniverseFromRestV02Response(
|
||||
configResponse,
|
||||
schemaResponse,
|
||||
annotationsObsResponse,
|
||||
annotationsVarResponse,
|
||||
layoutObsResponse
|
||||
) --> Universe
|
||||
|
||||
where:
|
||||
configResponse: GET /.../config
|
||||
schemaResponse: GET /.../schema
|
||||
annotationsObsResponse: GET /.../annotations/obs
|
||||
annotationsVarResponse: GET /.../annotations/var
|
||||
layoutObsResponse: GET /.../layout/obs
|
||||
|
||||
See spec in docs/REST_API.md.
|
||||
*/
|
||||
|
||||
test("create from test data", () => {
|
||||
/*
|
||||
create a universe from sample data nad validate its shape & contents
|
||||
*/
|
||||
const { nObs, nVar } = REST.schema.schema.dataframe;
|
||||
|
||||
const universe = Universe.createUniverseFromRestV02Response(
|
||||
REST.config,
|
||||
REST.schema,
|
||||
REST.annotationsObs,
|
||||
REST.annotationsVar,
|
||||
REST.layoutObs
|
||||
);
|
||||
|
||||
expect(universe).toBeDefined();
|
||||
expect(universe).toMatchObject(
|
||||
expect.objectContaining({
|
||||
api: "0.2",
|
||||
nObs,
|
||||
nVar,
|
||||
schema: REST.schema.schema,
|
||||
obsAnnotations: expect.any(Array),
|
||||
varAnnotations: expect.any(Array),
|
||||
obsNameToIndexMap: expect.any(Object),
|
||||
varNameToIndexMap: expect.any(Object),
|
||||
obsLayout: expect.objectContaining({
|
||||
X: expect.any(Float32Array),
|
||||
Y: expect.any(Float32Array)
|
||||
}),
|
||||
varDataCache: expect.any(Object)
|
||||
})
|
||||
);
|
||||
|
||||
expect(universe.obsAnnotations).toHaveLength(nObs);
|
||||
expect(_.keys(universe.obsNameToIndexMap)).toHaveLength(nObs);
|
||||
expect(universe.obsLayout.X).toHaveLength(nObs);
|
||||
expect(universe.obsLayout.Y).toHaveLength(nObs);
|
||||
expect(universe.varAnnotations).toHaveLength(nVar);
|
||||
expect(_.keys(universe.varNameToIndexMap)).toHaveLength(nVar);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertExpressionRESTv02ToObject", () => {
|
||||
/*
|
||||
test convertExpressionRESTv02ToObject
|
||||
|
||||
convertExpressionRESTv02ToObject(
|
||||
universe,
|
||||
response) --> { geneName: Float32Array, geneName: Float32Array, ... }
|
||||
|
||||
reponse is a /data/obs response:
|
||||
{
|
||||
var: [ varIndices fetched ],
|
||||
obs: [
|
||||
[ obsIndex, evalue, ... ],
|
||||
...
|
||||
]
|
||||
}
|
||||
*/
|
||||
test("create from response data", () => {
|
||||
const universe = Universe.createUniverseFromRestV02Response(
|
||||
REST.config,
|
||||
REST.schema,
|
||||
REST.annotationsObs,
|
||||
REST.annotationsVar,
|
||||
REST.layoutObs
|
||||
);
|
||||
const expression = Universe.convertExpressionRESTv02ToObject(
|
||||
universe,
|
||||
REST.dataObs
|
||||
);
|
||||
|
||||
/* Check that the expected keys are present */
|
||||
const expectedGeneNames = _.map(
|
||||
REST.dataObs.var,
|
||||
v => REST.annotationsVar.data[v][5]
|
||||
);
|
||||
expect(Object.keys(expression)).toEqual(
|
||||
expect.arrayContaining(expectedGeneNames)
|
||||
);
|
||||
|
||||
const expectedExpressionValues = _.map(
|
||||
_.unzip(REST.dataObs.obs),
|
||||
a => new Float32Array(a)
|
||||
);
|
||||
|
||||
_.forEach(REST.dataObs.var, (varIdx, idx) => {
|
||||
const varName = universe.varAnnotations[varIdx].name;
|
||||
expect(varName).toBeDefined();
|
||||
expect(varIdx).toBe(universe.varNameToIndexMap[varName]);
|
||||
expect(expression[varName]).toEqual(expectedExpressionValues[idx + 1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,255 +0,0 @@
|
||||
import _ from "lodash";
|
||||
import * as Universe from "../../../src/util/stateManager/universe";
|
||||
import * as World from "../../../src/util/stateManager/world";
|
||||
import Crossfilter from "../../../src/util/typedCrossfilter";
|
||||
import * as REST from "./sampleResponses";
|
||||
import {
|
||||
obsAnnoDimensionName,
|
||||
layoutDimensionName
|
||||
} from "../../../src/util/nameCreators";
|
||||
import * as kvCache from "../../../src/util/stateManager/keyvalcache";
|
||||
|
||||
/*
|
||||
Helper - creates universe, world, corssfilter and dimensionMap from
|
||||
the default REST test response.
|
||||
*/
|
||||
const defaultBigBang = () => {
|
||||
/* create unverse, world, crossfilter and dimensionMap */
|
||||
/* create universe */
|
||||
const universe = Universe.createUniverseFromRestV02Response(
|
||||
REST.config,
|
||||
REST.schema,
|
||||
REST.annotationsObs,
|
||||
REST.annotationsVar,
|
||||
REST.layoutObs
|
||||
);
|
||||
/* create world */
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
/* create crossfilter */
|
||||
const crossfilter = Crossfilter(world.obsAnnotations);
|
||||
/* create dimension map */
|
||||
const dimensionMap = World.createObsDimensionMap(crossfilter, world);
|
||||
|
||||
return {
|
||||
universe,
|
||||
world,
|
||||
crossfilter,
|
||||
dimensionMap
|
||||
};
|
||||
};
|
||||
|
||||
describe("createWorldFromEntireUniverse", () => {
|
||||
test("create from REST sample", () => {
|
||||
const universe = Universe.createUniverseFromRestV02Response(
|
||||
REST.config,
|
||||
REST.schema,
|
||||
REST.annotationsObs,
|
||||
REST.annotationsVar,
|
||||
REST.layoutObs
|
||||
);
|
||||
expect(universe).toBeDefined();
|
||||
|
||||
const world = World.createWorldFromEntireUniverse(universe);
|
||||
expect(world).toBeDefined();
|
||||
|
||||
expect(world).toMatchObject(
|
||||
expect.objectContaining({
|
||||
api: "0.2",
|
||||
nObs: universe.nObs,
|
||||
nVar: universe.nVar,
|
||||
schema: universe.schema,
|
||||
obsAnnotations: universe.obsAnnotations,
|
||||
varAnnotations: universe.varAnnotations,
|
||||
obsLayout: universe.obsLayout,
|
||||
|
||||
summary: expect.objectContaining({
|
||||
obs: _(REST.schema.schema.annotations.obs)
|
||||
.filter(v => v.name !== "name")
|
||||
.keyBy("name")
|
||||
.mapValues(() => expect.any(Object))
|
||||
.value(),
|
||||
var: _(REST.schema.schema.annotations.var)
|
||||
.filter(v => v.name !== "name")
|
||||
.keyBy("name")
|
||||
.mapValues(() => expect.any(Object))
|
||||
.value()
|
||||
}),
|
||||
|
||||
varDataCache: expect.any(Object),
|
||||
|
||||
obsIndex: null, // null indicating full universe
|
||||
obsBackIndex: null
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorldFromCurrentSelection", () => {
|
||||
test("create from REST sample", () => {
|
||||
const {
|
||||
universe,
|
||||
world: originalWorld,
|
||||
crossfilter,
|
||||
dimensionMap
|
||||
} = defaultBigBang();
|
||||
|
||||
/* mock a selection */
|
||||
dimensionMap[obsAnnoDimensionName("field1")].filterRange([0, 5]);
|
||||
dimensionMap[obsAnnoDimensionName("field3")].filterExact(false);
|
||||
|
||||
/* create the world from the selection */
|
||||
const world = World.createWorldFromCurrentSelection(
|
||||
universe,
|
||||
originalWorld,
|
||||
crossfilter
|
||||
);
|
||||
expect(world).toBeDefined();
|
||||
expect(world.nObs).toEqual(crossfilter.countFiltered());
|
||||
|
||||
/*
|
||||
calculate expected values and match against result
|
||||
*/
|
||||
|
||||
/* matchFilter must match the dimension filters above */
|
||||
const matchFilter = val => val.field1 >= 0 && val.field1 < 5 && !val.field3;
|
||||
const universeIndices = _()
|
||||
.range(universe.nObs)
|
||||
.filter(idx => matchFilter(universe.obsAnnotations[idx]))
|
||||
.value();
|
||||
|
||||
const expected = {
|
||||
nObs: universeIndices.length,
|
||||
obsAnnotations: _.map(universeIndices, i => universe.obsAnnotations[i]),
|
||||
obsLayout: {
|
||||
X: new Float32Array(
|
||||
_.map(universeIndices, i => universe.obsLayout.X[i])
|
||||
),
|
||||
Y: new Float32Array(
|
||||
_.map(universeIndices, i => universe.obsLayout.Y[i])
|
||||
)
|
||||
},
|
||||
obsBackIndex: _.transform(
|
||||
universeIndices,
|
||||
(result, univIdx, worldIdx) => {
|
||||
result[univIdx] = worldIdx;
|
||||
},
|
||||
new Uint32Array(universe.nObs).fill(-1)
|
||||
),
|
||||
obsIndex: new Uint32Array(universeIndices)
|
||||
};
|
||||
|
||||
expect(world).toMatchObject(
|
||||
expect.objectContaining({
|
||||
api: "0.2",
|
||||
nObs: expected.nObs,
|
||||
nVar: universe.nVar,
|
||||
schema: universe.schema,
|
||||
obsAnnotations: expected.obsAnnotations,
|
||||
varAnnotations: universe.varAnnotations,
|
||||
obsLayout: expected.obsLayout,
|
||||
summary: {
|
||||
obs: expect.any(Object) /* we could do better! */,
|
||||
var: expect.any(Object) /* we could do better! */
|
||||
},
|
||||
varDataCache: expect.any(Object),
|
||||
obsIndex: expected.obsIndex,
|
||||
obsBackIndex: expected.obsBackIndex
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createObsDimensionMap", () => {
|
||||
test("when universe eq world", () => {
|
||||
/*
|
||||
check for:
|
||||
- creates a dimension for all obsAnnotations, PLUS X/Y layout
|
||||
- check that dimension typing is sane
|
||||
*/
|
||||
|
||||
const { dimensionMap } = defaultBigBang();
|
||||
|
||||
const schemaByObsName = _.keyBy(REST.schema.schema.annotations.obs, "name");
|
||||
expect(dimensionMap).toBeDefined();
|
||||
REST.annotationsObs.names.forEach(name => {
|
||||
const dim = dimensionMap[obsAnnoDimensionName(name)];
|
||||
if (name === "name") {
|
||||
expect(dim).toBeUndefined();
|
||||
} else {
|
||||
const { type } = schemaByObsName[name];
|
||||
if (type === "string" || type === "boolean" || type === "categorical") {
|
||||
expect(dim).toBeInstanceOf(Crossfilter.EnumDimension);
|
||||
} else {
|
||||
expect(dim).toBeInstanceOf(Crossfilter.ScalarDimension);
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(dimensionMap[layoutDimensionName("X")]).toBeInstanceOf(
|
||||
Crossfilter.ScalarDimension
|
||||
);
|
||||
expect(dimensionMap[layoutDimensionName("Y")]).toBeInstanceOf(
|
||||
Crossfilter.ScalarDimension
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subsetVarData", () => {
|
||||
test("when world eq universe", () => {
|
||||
const { universe, world } = defaultBigBang();
|
||||
/* create a mock varData array for subsetting */
|
||||
const sourceVarData = new Float32Array(universe.nObs);
|
||||
|
||||
/* expect literally the same object back */
|
||||
const result = World.subsetVarData(world, universe, sourceVarData);
|
||||
expect(result).toBe(sourceVarData);
|
||||
});
|
||||
|
||||
test("when world neq universe", () => {
|
||||
const { universe, world, crossfilter, dimensionMap } = defaultBigBang();
|
||||
/* create a mock varData array for subsetting */
|
||||
const sourceVarData = Float32Array.from(_.range(universe.nObs));
|
||||
|
||||
/* mock a selection */
|
||||
dimensionMap[obsAnnoDimensionName("field1")].filterRange([0, 5]);
|
||||
dimensionMap[obsAnnoDimensionName("field3")].filterExact(false);
|
||||
|
||||
/* create the world from the selection */
|
||||
const newWorld = World.createWorldFromCurrentSelection(
|
||||
universe,
|
||||
world,
|
||||
crossfilter
|
||||
);
|
||||
expect(newWorld.obsIndex).toMatchObject(new Uint32Array([0, 2]));
|
||||
|
||||
/* expect a subset */
|
||||
const result = World.subsetVarData(newWorld, universe, sourceVarData);
|
||||
expect(result).not.toBe(sourceVarData);
|
||||
expect(result).toHaveLength(newWorld.nObs);
|
||||
/* check that we have expected source var content */
|
||||
expect(result).toMatchObject(new Float32Array([0, 2]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVarDimension", () => {
|
||||
/* create default universe */
|
||||
const { world, crossfilter } = defaultBigBang();
|
||||
/* create a mock var data cache */
|
||||
const varDataCache = kvCache.set(
|
||||
kvCache.create(),
|
||||
"GENE",
|
||||
Float32Array.from(_.range(world.nObs))
|
||||
);
|
||||
const result = World.createVarDimension(
|
||||
world,
|
||||
varDataCache,
|
||||
crossfilter,
|
||||
"GENE"
|
||||
);
|
||||
expect(result).toBeInstanceOf(Crossfilter.ScalarDimension);
|
||||
});
|
||||
|
||||
describe("worldEqUniverse", () => {
|
||||
const { universe, world } = defaultBigBang();
|
||||
const result = World.worldEqUniverse(world, universe);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
|
||||
import BitArray from "../../../src/util/typedCrossfilter/bitArray";
|
||||
|
||||
const defaultTestLength = 8;
|
||||
@@ -118,16 +116,16 @@ describe("selectionCount", () => {
|
||||
const dim2 = ba.allocDimension();
|
||||
expect(dim2).toBeDefined();
|
||||
|
||||
expect(ba.selectionCount).toEqual(0);
|
||||
expect(ba.selectionCount()).toEqual(0);
|
||||
ba.selectAll(dim1);
|
||||
expect(ba.selectionCount).toEqual(0);
|
||||
expect(ba.selectionCount()).toEqual(0);
|
||||
ba.selectAll(dim2);
|
||||
expect(ba.selectionCount).toEqual(defaultTestLength);
|
||||
expect(ba.selectionCount()).toEqual(defaultTestLength);
|
||||
|
||||
for (let i = 0; i < defaultTestLength; i += 1) {
|
||||
ba.deselectOne(dim1, i);
|
||||
expect(ba.selectionCount).toEqual(defaultTestLength - i - 1);
|
||||
expect(ba.selectionCount).toEqual(ba.countAllOnes());
|
||||
expect(ba.selectionCount()).toEqual(defaultTestLength - i - 1);
|
||||
expect(ba.selectionCount()).toEqual(ba.countAllOnes());
|
||||
}
|
||||
|
||||
ba.freeDimension(dim1);
|
||||
@@ -183,3 +181,24 @@ describe("fillBySelection", () => {
|
||||
expect(arr).toEqual(truth);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wide bitarray", () => {
|
||||
test.each([9, 30, 31, 32, 33, 54, 63, 64, 65, 127, 128, 129])(
|
||||
"more than %d dimensions",
|
||||
(ndim) => {
|
||||
/* ensure we move across the uint boundary correctly */
|
||||
|
||||
const ba = new BitArray(defaultTestLength);
|
||||
expect(ba).toBeDefined();
|
||||
|
||||
for (let i = 0; i < ndim; i += 1) {
|
||||
expect(ba.allocDimension()).toEqual(i);
|
||||
}
|
||||
ba.freeDimension(0);
|
||||
expect(ba.allocDimension()).toEqual(0);
|
||||
|
||||
ba.freeDimension(ndim - 1);
|
||||
expect(ba.allocDimension()).toEqual(ndim - 1);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import filter from "lodash.filter";
|
||||
import zip from "lodash.zip";
|
||||
|
||||
import Crossfilter from "../../../src/util/typedCrossfilter";
|
||||
|
||||
const someData = [
|
||||
{
|
||||
date: "2011-11-14T16:17:54Z",
|
||||
quantity: 2,
|
||||
total: 190,
|
||||
tip: 100,
|
||||
type: "tab",
|
||||
productIDs: ["001"],
|
||||
coords: [0, 0],
|
||||
nonFinite: 0.0,
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:20:19Z",
|
||||
quantity: 2,
|
||||
total: 190,
|
||||
tip: 100,
|
||||
type: "tab",
|
||||
productIDs: ["001", "005"],
|
||||
coords: [0.4, 0.4],
|
||||
nonFinite: Number.NaN,
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:28:54Z",
|
||||
quantity: 1,
|
||||
total: 300,
|
||||
tip: 200,
|
||||
type: "visa",
|
||||
productIDs: ["004", "005"],
|
||||
coords: [0.3, 0.1],
|
||||
nonFinite: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:30:43Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "002"],
|
||||
coords: [0.392, 0.1],
|
||||
nonFinite: Number.NEGATIVE_INFINITY,
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:48:46Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["005"],
|
||||
coords: [0.7, 0.0482],
|
||||
nonFinite: 1.0,
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:53:41Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "004", "005"],
|
||||
coords: [0.9999, 1.0],
|
||||
nonFinite: Number.NaN,
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:54:06Z",
|
||||
quantity: 1,
|
||||
total: 100,
|
||||
tip: 0,
|
||||
type: "cash",
|
||||
productIDs: ["001", "002", "003", "004", "005"],
|
||||
coords: [0.384, 0.6938],
|
||||
nonFinite: 99.0,
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:58:03Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001"],
|
||||
coords: [0.4822, 0.482],
|
||||
nonFinite: Number.NaN,
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:07:21Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["004", "005"],
|
||||
coords: [0.2234, 0],
|
||||
nonFinite: Number.NaN,
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:22:59Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "002", "004", "005"],
|
||||
coords: [0.382, 0.38485],
|
||||
nonFinite: -1,
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:25:45Z",
|
||||
quantity: 2,
|
||||
total: 200,
|
||||
tip: 0,
|
||||
type: "cash",
|
||||
productIDs: ["002"],
|
||||
coords: [0.998, 0.8472],
|
||||
nonFinite: 0.0,
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:29:52Z",
|
||||
quantity: 1,
|
||||
total: 200,
|
||||
tip: 100,
|
||||
type: "visa",
|
||||
productIDs: ["004"],
|
||||
coords: [0.8273, 0.3384],
|
||||
nonFinite: 0.0,
|
||||
},
|
||||
];
|
||||
|
||||
let payments = null;
|
||||
beforeEach(() => {
|
||||
payments = new Crossfilter(someData);
|
||||
});
|
||||
|
||||
describe("ImmutableTypedCrossfilter", () => {
|
||||
test("create crossfilter", () => {
|
||||
expect(payments).toBeDefined();
|
||||
expect(payments.size()).toEqual(someData.length);
|
||||
expect(payments.all()).toEqual(someData);
|
||||
|
||||
const p = payments
|
||||
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||
.select("quantity", { mode: "all" });
|
||||
expect(p).toBeDefined();
|
||||
expect(p.all()).toEqual(someData);
|
||||
expect(p.size()).toEqual(someData.length);
|
||||
expect(p.isElementSelected(0)).toBeTruthy();
|
||||
expect(p.countSelected()).toEqual(someData.length);
|
||||
expect(p.allSelected()).toEqual(someData);
|
||||
});
|
||||
|
||||
test("immutability", () => {
|
||||
/*
|
||||
the following should return a new crossfilter:
|
||||
- addDimension()
|
||||
- delDimension()
|
||||
- select
|
||||
*/
|
||||
const p2 = payments.addDimension(
|
||||
"quantity",
|
||||
"scalar",
|
||||
(i, data) => data[i].quantity,
|
||||
Int32Array
|
||||
);
|
||||
|
||||
expect(payments).not.toBe(p2);
|
||||
const p3 = p2.select("quantity", { mode: "all" });
|
||||
expect(p3).not.toBe(p2);
|
||||
|
||||
const p4 = p3.delDimension("quantity");
|
||||
expect(p4).not.toBe(p3);
|
||||
|
||||
const p5 = p2.renameDimension("quantity", "Quantity");
|
||||
expect(p5).not.toBe(p2);
|
||||
});
|
||||
|
||||
test("select all and none", () => {
|
||||
let p = payments
|
||||
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||
.addDimension("tip", "scalar", (i, d) => d[i].tip, Float32Array)
|
||||
.addDimension("total", "scalar", (i, d) => d[i].total, Float32Array)
|
||||
.addDimension("type", "enum", (i, d) => d[i].type);
|
||||
expect(p).toBeDefined();
|
||||
|
||||
/* expect all records to be selected - default init state */
|
||||
expect(p.allSelected()).toEqual(someData);
|
||||
expect(p.countSelected()).toEqual(someData.length);
|
||||
expect(p.allSelectedMask()).toEqual(
|
||||
new Uint8Array(someData.length).fill(1)
|
||||
);
|
||||
expect(p.fillByIsSelected(new Uint8Array(someData.length), 99, 0)).toEqual(
|
||||
new Uint8Array(someData.length).fill(99)
|
||||
);
|
||||
for (let i = 0; i < someData.length; i += 1) {
|
||||
expect(p.isElementSelected(i)).toBeTruthy();
|
||||
}
|
||||
|
||||
/* expect a selectAll on one dimension to change nothing */
|
||||
p = p.select("tip", { mode: "all" });
|
||||
expect(p.allSelected()).toEqual(someData);
|
||||
|
||||
/* ditto */
|
||||
p = p.select("quantity", { mode: "all" });
|
||||
expect(p.allSelected()).toEqual(someData);
|
||||
|
||||
/* select none on one dimension */
|
||||
p = p.select("type", { mode: "none" });
|
||||
expect(p.allSelected()).toEqual([]);
|
||||
expect(p.countSelected()).toEqual(0);
|
||||
expect(p.allSelectedMask()).toEqual(
|
||||
new Uint8Array(someData.length).fill(0)
|
||||
);
|
||||
expect(p.fillByIsSelected(new Uint8Array(someData.length), 99, 0)).toEqual(
|
||||
new Uint8Array(someData.length).fill(0)
|
||||
);
|
||||
for (let i = 0; i < someData.length; i += 1) {
|
||||
expect(p.isElementSelected(i)).toBeFalsy();
|
||||
}
|
||||
|
||||
p = p.select("quantity", { mode: "none" });
|
||||
expect(p.allSelected()).toEqual([]);
|
||||
|
||||
// invert the first none; should have no effect because type is
|
||||
// still not filtered.
|
||||
p = p.select("quantity", { mode: "all" });
|
||||
expect(p.allSelected()).toEqual([]);
|
||||
|
||||
/* select all of type; should select all records */
|
||||
p = p.select("type", { mode: "all" });
|
||||
expect(p.allSelected()).toEqual(someData);
|
||||
});
|
||||
|
||||
describe("scalar dimension", () => {
|
||||
let p;
|
||||
beforeEach(() => {
|
||||
p = payments
|
||||
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||
.addDimension("tip", "scalar", (i, d) => d[i].tip, Float32Array)
|
||||
.select("tip", { mode: "all" });
|
||||
});
|
||||
|
||||
/*
|
||||
select modes: all, none, exact, range
|
||||
*/
|
||||
test("all", () => {
|
||||
expect(p.select("quantity", { mode: "all" }).countSelected()).toEqual(
|
||||
someData.length
|
||||
);
|
||||
});
|
||||
test("none", () => {
|
||||
expect(p.select("quantity", { mode: "none" }).countSelected()).toEqual(0);
|
||||
});
|
||||
test.each([[[]], [[2]], [[2, 1]], [[9, 82]], [[0, 1]]])("exact: %p", (v) =>
|
||||
expect(
|
||||
p.select("quantity", { mode: "exact", values: v }).countSelected()
|
||||
).toEqual(filter(someData, (d) => v.includes(d.quantity)).length)
|
||||
);
|
||||
test("single value exact", () => {
|
||||
expect(
|
||||
p.select("quantity", { mode: "exact", values: 2 }).countSelected()
|
||||
).toEqual(filter(someData, (d) => d.quantity === 2).length);
|
||||
});
|
||||
test.each([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[0, 99],
|
||||
[99, 100000],
|
||||
])("range %p", (lo, hi) =>
|
||||
expect(
|
||||
p.select("quantity", { mode: "range", lo, hi }).countSelected()
|
||||
).toEqual(
|
||||
filter(someData, (d) => d.quantity >= lo && d.quantity < hi).length
|
||||
)
|
||||
);
|
||||
test("bad mode", () => {
|
||||
expect(() => p.select("type", { mode: "bad mode" })).toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enum dimension", () => {
|
||||
let p;
|
||||
beforeEach(() => {
|
||||
p = payments.addDimension("type", "enum", (i, d) => d[i].type);
|
||||
});
|
||||
|
||||
test("all", () => {
|
||||
expect(p.select("type", { mode: "all" }).countSelected()).toEqual(
|
||||
someData.length
|
||||
);
|
||||
});
|
||||
test("none", () => {
|
||||
expect(p.select("type", { mode: "none" }).countSelected()).toEqual(0);
|
||||
});
|
||||
test.each([
|
||||
[[]],
|
||||
[["tab"]],
|
||||
[["visa"]],
|
||||
[["visa", "tab"]],
|
||||
[["cash", "tab", "visa"]],
|
||||
])("exact: %p", (v) =>
|
||||
expect(
|
||||
p.select("type", { mode: "exact", values: v }).countSelected()
|
||||
).toEqual(filter(someData, (d) => v.includes(d.type)).length)
|
||||
);
|
||||
test("single value exact", () => {
|
||||
expect(
|
||||
p.select("type", { mode: "exact", values: "tab" }).countSelected()
|
||||
).toEqual(filter(someData, (d) => d.type === "tab").length);
|
||||
});
|
||||
test("range", () => {
|
||||
expect(() => p.select("type", { mode: "range", lo: 0, hi: 9 })).toThrow(
|
||||
Error
|
||||
);
|
||||
});
|
||||
test("bad mode", () => {
|
||||
expect(() => p.select("type", { mode: "bad mode" })).toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spatial dimension", () => {
|
||||
let p;
|
||||
beforeEach(() => {
|
||||
const X = someData.map((r) => r.coords[0]);
|
||||
const Y = someData.map((r) => r.coords[1]);
|
||||
p = payments.addDimension("coords", "spatial", X, Y);
|
||||
});
|
||||
|
||||
test("all", () => {
|
||||
expect(p.select("coords", { mode: "all" }).countSelected()).toEqual(
|
||||
someData.length
|
||||
);
|
||||
});
|
||||
test("none", () => {
|
||||
expect(p.select("coords", { mode: "none" }).countSelected()).toEqual(0);
|
||||
});
|
||||
test.each([
|
||||
[0, 0, 1, 1],
|
||||
[0, 0, 0.5, 0.5],
|
||||
[0.5, 0.5, 1, 1],
|
||||
])("within-rect %d %d %d %d", (minX, minY, maxX, maxY) => {
|
||||
expect(
|
||||
p
|
||||
.select("coords", { mode: "within-rect", minX, minY, maxX, maxY })
|
||||
.allSelected()
|
||||
).toEqual(
|
||||
filter(someData, (d) => {
|
||||
const [x, y] = d.coords;
|
||||
return minX <= x && x < maxX && minY <= y && y < maxY;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, 0],
|
||||
],
|
||||
[
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[0, 0],
|
||||
[0, 0.5],
|
||||
[0.5, 0.5],
|
||||
[0.5, 0],
|
||||
],
|
||||
[
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
],
|
||||
],
|
||||
])("within-polygon %p", (polygon, expected) => {
|
||||
expect(
|
||||
p.select("coords", { mode: "within-polygon", polygon }).allSelected()
|
||||
).toEqual(
|
||||
zip(someData, expected)
|
||||
.filter((x) => x[1])
|
||||
.map((x) => x[0])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-finite scalars", () => {
|
||||
let p;
|
||||
beforeEach(() => {
|
||||
p = payments
|
||||
.addDimension("quantity", "scalar", (i, d) => d[i].quantity, Int32Array)
|
||||
.addDimension(
|
||||
"nonFinite",
|
||||
"scalar",
|
||||
(i, d) => d[i].nonFinite,
|
||||
Float32Array
|
||||
)
|
||||
.select("quantity", { mode: "all" });
|
||||
});
|
||||
|
||||
test("all or none", () => {
|
||||
expect(p.select("nonFinite", { mode: "all" }).countSelected()).toEqual(
|
||||
someData.length
|
||||
);
|
||||
expect(p.select("nonFinite", { mode: "none" }).countSelected()).toEqual(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test("exact", () => {
|
||||
expect(
|
||||
p.select("nonFinite", { mode: "exact", values: [0] }).countSelected()
|
||||
).toEqual(3);
|
||||
expect(
|
||||
p.select("nonFinite", { mode: "exact", values: [1] }).countSelected()
|
||||
).toEqual(1);
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", {
|
||||
mode: "exact",
|
||||
values: [Number.POSITIVE_INFINITY],
|
||||
})
|
||||
.countSelected()
|
||||
).toEqual(1);
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", {
|
||||
mode: "exact",
|
||||
values: [Number.NEGATIVE_INFINITY],
|
||||
})
|
||||
.countSelected()
|
||||
).toEqual(1);
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", { mode: "exact", values: [Number.NaN] })
|
||||
.countSelected()
|
||||
).toEqual(4);
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", {
|
||||
mode: "exact",
|
||||
values: [Number.POSITIVE_INFINITY, 0, 1, 99],
|
||||
})
|
||||
.countSelected()
|
||||
).toEqual(6);
|
||||
});
|
||||
|
||||
test("range", () => {
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", {
|
||||
mode: "range",
|
||||
lo: 0,
|
||||
hi: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
.countSelected()
|
||||
).toEqual(5);
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", {
|
||||
mode: "range",
|
||||
lo: 0,
|
||||
hi: Number.NaN,
|
||||
})
|
||||
.countSelected()
|
||||
).toEqual(6);
|
||||
expect(
|
||||
p
|
||||
.select("nonFinite", {
|
||||
mode: "range",
|
||||
lo: Number.NEGATIVE_INFINITY,
|
||||
hi: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
.countSelected()
|
||||
).toEqual(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
// jshint esversion: 6
|
||||
|
||||
// const PositiveIntervals = require("../../src/util/typedCrossfilter/positiveIntervals");
|
||||
import PositiveIntervals from "../../../src/util/typedCrossfilter/positiveIntervals";
|
||||
|
||||
@@ -10,15 +8,30 @@ describe("canonicalize", () => {
|
||||
|
||||
test("simple, already correct", () => {
|
||||
expect(PositiveIntervals.canonicalize([[0, 1]])).toEqual([[0, 1]]);
|
||||
expect(PositiveIntervals.canonicalize([[0, 1], [2, 3]])).toEqual([
|
||||
expect(
|
||||
PositiveIntervals.canonicalize([
|
||||
[0, 1],
|
||||
[2, 3],
|
||||
])
|
||||
).toEqual([
|
||||
[0, 1],
|
||||
[2, 3]
|
||||
[2, 3],
|
||||
]);
|
||||
});
|
||||
|
||||
test("non-canonical, need to be canonicalized", () => {
|
||||
expect(PositiveIntervals.canonicalize([[0, 1], [1, 2]])).toEqual([[0, 2]]);
|
||||
expect(PositiveIntervals.canonicalize([[1, 2], [2, 3]])).toEqual([[1, 3]]);
|
||||
expect(
|
||||
PositiveIntervals.canonicalize([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
])
|
||||
).toEqual([[0, 2]]);
|
||||
expect(
|
||||
PositiveIntervals.canonicalize([
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
])
|
||||
).toEqual([[1, 3]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,14 +39,30 @@ describe("union", () => {
|
||||
test("empty range", () => {
|
||||
expect(PositiveIntervals.union([], [])).toEqual([]);
|
||||
expect(PositiveIntervals.union([], [[1, 2]])).toEqual([[1, 2]]);
|
||||
expect(PositiveIntervals.union([], [[1, 2], [3, 4]])).toEqual([
|
||||
expect(
|
||||
PositiveIntervals.union(
|
||||
[],
|
||||
[
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
]
|
||||
)
|
||||
).toEqual([
|
||||
[1, 2],
|
||||
[3, 4]
|
||||
[3, 4],
|
||||
]);
|
||||
expect(PositiveIntervals.union([[3, 4]], [])).toEqual([[3, 4]]);
|
||||
expect(PositiveIntervals.union([[1, 2], [3, 4]], [])).toEqual([
|
||||
expect(
|
||||
PositiveIntervals.union(
|
||||
[
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
[]
|
||||
)
|
||||
).toEqual([
|
||||
[1, 2],
|
||||
[3, 4]
|
||||
[3, 4],
|
||||
]);
|
||||
expect(PositiveIntervals.union([[3, 3]], [])).toEqual([[3, 3]]);
|
||||
expect(PositiveIntervals.union([], [[3, 3]])).toEqual([[3, 3]]);
|
||||
@@ -44,17 +73,37 @@ describe("union", () => {
|
||||
expect(PositiveIntervals.union([[2, 3]], [[1, 2]])).toEqual([[1, 3]]);
|
||||
expect(PositiveIntervals.union([[1, 2]], [[3, 4]])).toEqual([
|
||||
[1, 2],
|
||||
[3, 4]
|
||||
[3, 4],
|
||||
]);
|
||||
expect(
|
||||
PositiveIntervals.union([[1, 2], [3, 4]], [[6, 7], [19, 40]])
|
||||
).toEqual([[1, 2], [3, 4], [6, 7], [19, 40]]);
|
||||
expect(PositiveIntervals.union([[1, 4]], [[1, 1], [3, 4]])).toEqual([
|
||||
[1, 4]
|
||||
PositiveIntervals.union(
|
||||
[
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
[
|
||||
[6, 7],
|
||||
[19, 40],
|
||||
]
|
||||
)
|
||||
).toEqual([
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
[6, 7],
|
||||
[19, 40],
|
||||
]);
|
||||
expect(
|
||||
PositiveIntervals.union(
|
||||
[[1, 4]],
|
||||
[
|
||||
[1, 1],
|
||||
[3, 4],
|
||||
]
|
||||
)
|
||||
).toEqual([[1, 4]]);
|
||||
expect(PositiveIntervals.union([[3, 3]], [[4, 4]])).toEqual([
|
||||
[3, 3],
|
||||
[4, 4]
|
||||
[4, 4],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -70,34 +119,43 @@ describe("intersection", () => {
|
||||
expect(PositiveIntervals.intersection([[1, 2]], [[2, 3]])).toEqual([]);
|
||||
expect(PositiveIntervals.intersection([[2, 3]], [[1, 2]])).toEqual([]);
|
||||
expect(PositiveIntervals.intersection([[1, 10]], [[1, 10]])).toEqual([
|
||||
[1, 10]
|
||||
[1, 10],
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[1, 10]], [[2, 8]])).toEqual([
|
||||
[2, 8]
|
||||
[2, 8],
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[2, 8]], [[1, 10]])).toEqual([
|
||||
[2, 8]
|
||||
[2, 8],
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[1, 10]], [[2, 12]])).toEqual([
|
||||
[2, 10]
|
||||
[2, 10],
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[2, 12]], [[1, 10]])).toEqual([
|
||||
[2, 10]
|
||||
[2, 10],
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[1, 10]], [[1, 8]])).toEqual([
|
||||
[1, 8]
|
||||
[1, 8],
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[1, 8]], [[1, 10]])).toEqual([
|
||||
[1, 8]
|
||||
[1, 8],
|
||||
]);
|
||||
expect(
|
||||
PositiveIntervals.intersection(
|
||||
[[1, 10]],
|
||||
[
|
||||
[1, 2],
|
||||
[6, 9],
|
||||
]
|
||||
)
|
||||
).toEqual([
|
||||
[1, 2],
|
||||
[6, 9],
|
||||
]);
|
||||
expect(PositiveIntervals.intersection([[1, 10]], [[1, 2], [6, 9]])).toEqual(
|
||||
[[1, 2], [6, 9]]
|
||||
);
|
||||
expect(PositiveIntervals.intersection([[0, 2638]], [[1363, 2638]])).toEqual(
|
||||
[[1363, 2638]]
|
||||
);
|
||||
expect(PositiveIntervals.intersection([[1, 2]], [[1, 2]])).toEqual([
|
||||
[1, 2]
|
||||
[1, 2],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -110,32 +168,66 @@ describe("difference", () => {
|
||||
});
|
||||
|
||||
test("simple", () => {
|
||||
expect(PositiveIntervals.difference([[1, 2], [3, 4]], [])).toEqual([
|
||||
expect(
|
||||
PositiveIntervals.difference(
|
||||
[
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
[]
|
||||
)
|
||||
).toEqual([
|
||||
[1, 2],
|
||||
[3, 4]
|
||||
]);
|
||||
expect(PositiveIntervals.difference([[1, 2], [3, 10]], [[5, 10]])).toEqual([
|
||||
[1, 2],
|
||||
[3, 5]
|
||||
]);
|
||||
expect(PositiveIntervals.difference([[1, 2], [3, 10]], [[0, 5]])).toEqual([
|
||||
[5, 10]
|
||||
[3, 4],
|
||||
]);
|
||||
expect(
|
||||
PositiveIntervals.difference([[0, 2638]], [[0, 1363], [2055, 2638]])
|
||||
PositiveIntervals.difference(
|
||||
[
|
||||
[1, 2],
|
||||
[3, 10],
|
||||
],
|
||||
[[5, 10]]
|
||||
)
|
||||
).toEqual([
|
||||
[1, 2],
|
||||
[3, 5],
|
||||
]);
|
||||
expect(
|
||||
PositiveIntervals.difference(
|
||||
[
|
||||
[1, 2],
|
||||
[3, 10],
|
||||
],
|
||||
[[0, 5]]
|
||||
)
|
||||
).toEqual([[5, 10]]);
|
||||
expect(
|
||||
PositiveIntervals.difference(
|
||||
[[0, 2638]],
|
||||
[
|
||||
[0, 1363],
|
||||
[2055, 2638],
|
||||
]
|
||||
)
|
||||
).toEqual([[1363, 2055]]);
|
||||
expect(
|
||||
PositiveIntervals.difference([[0, 1363], [2055, 2638]], [[0, 2638]])
|
||||
PositiveIntervals.difference(
|
||||
[
|
||||
[0, 1363],
|
||||
[2055, 2638],
|
||||
],
|
||||
[[0, 2638]]
|
||||
)
|
||||
).toEqual([]);
|
||||
expect(PositiveIntervals.difference([[0, 10]], [[0, 1]])).toEqual([
|
||||
[1, 10]
|
||||
[1, 10],
|
||||
]);
|
||||
expect(PositiveIntervals.difference([[0, 10]], [[1, 2]])).toEqual([
|
||||
[0, 1],
|
||||
[2, 10]
|
||||
[2, 10],
|
||||
]);
|
||||
expect(PositiveIntervals.difference([[0, 10]], [[9, 10]])).toEqual([
|
||||
[0, 9]
|
||||
[0, 9],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import { sort, sortIndex } from "../../../src/util/typedCrossfilter/sort";
|
||||
import {
|
||||
sortArray,
|
||||
sortIndex,
|
||||
lowerBound,
|
||||
} from "../../../src/util/typedCrossfilter/sort";
|
||||
|
||||
/*
|
||||
Sort tests should keep in mind that there are separate code
|
||||
paths for:
|
||||
- small vs. large arrays (insertionsort only)
|
||||
- float-only typed arrays vs. other array types (non-finite handling)
|
||||
- indexed vs. direct sort
|
||||
*/
|
||||
|
||||
const pInf = Number.POSITIVE_INFINITY;
|
||||
const nInf = Number.NEGATIVE_INFINITY;
|
||||
|
||||
function fillRange(arr, start = 0) {
|
||||
const larr = arr;
|
||||
@@ -15,42 +30,225 @@ function fillRand(arr) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
describe("sort", () => {
|
||||
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
|
||||
test(Type.name, () => {
|
||||
expect(sort(Type.from([6, 5, 4, 3, 2, 1, 0]))).toMatchObject(
|
||||
Type.from([0, 1, 2, 3, 4, 5, 6])
|
||||
);
|
||||
expect(sort(Type.from([6, 5, 4, 3, 2, 1]))).toMatchObject(
|
||||
Type.from([1, 2, 3, 4, 5, 6])
|
||||
);
|
||||
describe("sortArray", () => {
|
||||
describe("JS vals", () => {
|
||||
[
|
||||
[true, false],
|
||||
["a", "b", "0", "1"],
|
||||
[0, "a", true, null, undefined, 3.1415],
|
||||
fillRand(new Array(1000)),
|
||||
["a", NaN, null, pInf],
|
||||
].map((val, idx) =>
|
||||
test(`JS vals ${idx}`, () => {
|
||||
expect(sortArray(val)).toMatchObject(val.sort());
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const source = fillRand(new Type(1000));
|
||||
expect(sort(Type.from(source))).toMatchObject(Type.from(source).sort());
|
||||
})
|
||||
);
|
||||
describe("finite numbers", () => {
|
||||
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map((Type) =>
|
||||
test(Type.name, () => {
|
||||
expect(sortArray(Type.from([6, 5, 4, 3, 2, 1, 0]))).toMatchObject(
|
||||
Type.from([0, 1, 2, 3, 4, 5, 6])
|
||||
);
|
||||
|
||||
expect(sortArray(Type.from([6, 5, 4, 3, 2, 1]))).toMatchObject(
|
||||
Type.from([1, 2, 3, 4, 5, 6])
|
||||
);
|
||||
|
||||
const source = fillRand(new Type(1000));
|
||||
expect(sortArray(Type.from(source))).toMatchObject(
|
||||
Type.from(source).sort()
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe("non-finite numbers", () => {
|
||||
test("infinity", () => {
|
||||
expect(sortArray(new Float32Array([pInf, nInf, 0, 1, 2]))).toMatchObject(
|
||||
new Float32Array([nInf, 0, 1, 2, pInf])
|
||||
);
|
||||
expect(
|
||||
sortArray(new Float32Array([pInf, nInf, pInf, nInf]))
|
||||
).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf]));
|
||||
expect(
|
||||
sortArray(new Float32Array([pInf, nInf, pInf, nInf, pInf]))
|
||||
).toMatchObject(new Float32Array([nInf, nInf, pInf, pInf, pInf]));
|
||||
expect(
|
||||
sortArray(
|
||||
new Float32Array(100).fill(Infinity, 0, 50).fill(-Infinity, 50, 100)
|
||||
)
|
||||
).toMatchObject(
|
||||
new Float32Array(100).fill(-Infinity, 0, 50).fill(Infinity, 50, 100)
|
||||
);
|
||||
});
|
||||
test("NaN", () => {
|
||||
expect(sortArray(new Float64Array([NaN, 2, 1, 0]))).toMatchObject(
|
||||
new Float64Array([0, 1, 2, NaN])
|
||||
);
|
||||
expect(sortArray(new Float32Array([NaN, 2, 1, 0]))).toMatchObject(
|
||||
new Float32Array([0, 1, 2, NaN])
|
||||
);
|
||||
expect(sortArray(new Float32Array([NaN, 2, NaN, 1, 0]))).toMatchObject(
|
||||
new Float32Array([0, 1, 2, NaN, NaN])
|
||||
);
|
||||
expect(sortArray(new Float32Array([NaN, 2, 1, NaN, 0]))).toMatchObject(
|
||||
new Float32Array([0, 1, 2, NaN, NaN])
|
||||
);
|
||||
expect(
|
||||
sortArray(fillRange(new Float32Array(100)).fill(NaN, 0, 10))
|
||||
).toMatchObject(fillRange(new Float32Array(100), 10).fill(NaN, 90, 100));
|
||||
});
|
||||
|
||||
test("mixed numbers", () => {
|
||||
expect(
|
||||
sortArray(new Float32Array([NaN, pInf, nInf, NaN, NaN]))
|
||||
).toMatchObject(new Float32Array([nInf, pInf, NaN, NaN, NaN]));
|
||||
expect(
|
||||
sortArray(new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]))
|
||||
).toMatchObject(new Float32Array([nInf, 1, 2, pInf, NaN, NaN, NaN]));
|
||||
expect(
|
||||
sortArray(new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]))
|
||||
).toMatchObject(new Float32Array([nInf, 0, 1, 2, pInf, NaN, NaN]));
|
||||
expect(
|
||||
sortArray(
|
||||
fillRange(new Float32Array(100))
|
||||
.fill(NaN, 0, 10)
|
||||
.fill(Infinity, 10, 20)
|
||||
)
|
||||
).toMatchObject(
|
||||
fillRange(new Float32Array(100), 20)
|
||||
.fill(Infinity, 80, 90)
|
||||
.fill(NaN, 90, 100)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortIndex", () => {
|
||||
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map(Type =>
|
||||
test(Type.name, () => {
|
||||
const source1 = Type.from([6, 5, 4, 3, 2, 1, 0]);
|
||||
describe("finite numbers", () => {
|
||||
[Array, Float32Array, Uint32Array, Int32Array, Float64Array].map((Type) =>
|
||||
test(Type.name, () => {
|
||||
const source1 = Type.from([6, 5, 4, 3, 2, 1, 0]);
|
||||
const index1 = fillRange(new Uint32Array(source1.length));
|
||||
expect(sortIndex(index1, source1)).toMatchObject(
|
||||
index1.sort((a, b) => source1[a] - source1[b])
|
||||
);
|
||||
|
||||
const source2 = Type.from([6, 5, 4, 3, 2, 1]);
|
||||
const index2 = fillRange(new Uint32Array(source2.length));
|
||||
expect(sortIndex(index2, source2)).toMatchObject(
|
||||
index2.sort((a, b) => source1[a] - source1[b])
|
||||
);
|
||||
|
||||
const source3 = fillRand(new Type(1000));
|
||||
const index3 = fillRange(new Uint32Array(source3.length));
|
||||
expect(sortIndex(index3, source3)).toMatchObject(
|
||||
index3.sort((a, b) => source1[a] - source1[b])
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe("non-finite numbers", () => {
|
||||
test("mixed numbers", () => {
|
||||
const source1 = new Float32Array([NaN, pInf, nInf, NaN, 1, NaN, 2]);
|
||||
const index1 = fillRange(new Uint32Array(source1.length));
|
||||
expect(sortIndex(index1, source1)).toMatchObject(
|
||||
index1.sort((a, b) => source1[a] - source1[b])
|
||||
new Uint32Array([2, 4, 6, 1, 0, 3, 5])
|
||||
);
|
||||
|
||||
const source2 = Type.from([6, 5, 4, 3, 2, 1]);
|
||||
const source2 = new Float32Array([NaN, pInf, nInf, 0, 1, NaN, 2]);
|
||||
const index2 = fillRange(new Uint32Array(source2.length));
|
||||
expect(sortIndex(index2, source2)).toMatchObject(
|
||||
index2.sort((a, b) => source1[a] - source1[b])
|
||||
new Uint32Array([2, 3, 4, 6, 1, 0, 5])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const source3 = fillRand(new Type(1000));
|
||||
const index3 = fillRange(new Uint32Array(source3.length));
|
||||
expect(sortIndex(index3, source3)).toMatchObject(
|
||||
index3.sort((a, b) => source1[a] - source1[b])
|
||||
);
|
||||
})
|
||||
);
|
||||
describe("lowerBound", () => {
|
||||
test("non-float path", () => {
|
||||
expect(lowerBound([], 0, 0, 0)).toEqual(0);
|
||||
|
||||
expect(lowerBound([0, 1, 2, 3], -1, 0, 4)).toEqual(0);
|
||||
expect(lowerBound([0, 1, 2, 3], 0, 0, 4)).toEqual(0);
|
||||
expect(lowerBound([0, 1, 2, 3], 1, 0, 4)).toEqual(1);
|
||||
expect(lowerBound([0, 1, 2, 3], 3, 0, 4)).toEqual(3);
|
||||
expect(lowerBound([0, 1, 2, 3], 4, 0, 4)).toEqual(4);
|
||||
expect(lowerBound([0, 1, 2, 3], 4, 0, 3)).toEqual(3);
|
||||
|
||||
expect(lowerBound([0, 1, 2, 3, 4], -1, 0, 5)).toEqual(0);
|
||||
expect(lowerBound([0, 1, 2, 3, 4], 0, 0, 5)).toEqual(0);
|
||||
expect(lowerBound([0, 1, 2, 3, 4], 2, 0, 5)).toEqual(2);
|
||||
expect(lowerBound([0, 1, 2, 3, 4], 4, 0, 5)).toEqual(4);
|
||||
expect(lowerBound([0, 1, 2, 3, 4], 5, 0, 5)).toEqual(5);
|
||||
|
||||
expect(lowerBound([0, 2, 4, 6, 8], 5, 0, 5)).toEqual(3);
|
||||
expect(lowerBound([0, 2, 2, 2, 8], 5, 0, 5)).toEqual(4);
|
||||
|
||||
expect(lowerBound([0, 1, 2, 3, 4, 5, 6, 7, 8], 3, 2, 4)).toEqual(3);
|
||||
expect(lowerBound([0, 1, 2, 3, 4, 5, 6, 7, 8], 99, 2, 4)).toEqual(4);
|
||||
});
|
||||
|
||||
test("float path, finites", () => {
|
||||
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1);
|
||||
|
||||
expect(lowerBound(new Float32Array([]), 0, 0, 0)).toEqual(0);
|
||||
|
||||
expect(lowerBound(new Float32Array([0, 1, 2, 3]), -1, 0, 4)).toEqual(0);
|
||||
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 0, 0, 4)).toEqual(0);
|
||||
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 1, 0, 4)).toEqual(1);
|
||||
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 3, 0, 4)).toEqual(3);
|
||||
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 4)).toEqual(4);
|
||||
expect(lowerBound(new Float32Array([0, 1, 2, 3]), 4, 0, 3)).toEqual(3);
|
||||
|
||||
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), -1, 0, 5)).toEqual(0);
|
||||
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 0, 0, 5)).toEqual(0);
|
||||
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 2, 0, 5)).toEqual(2);
|
||||
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 4, 0, 5)).toEqual(4);
|
||||
expect(lowerBound(new Float32Array([0, 1, 2, 3, 4]), 5, 0, 5)).toEqual(5);
|
||||
|
||||
expect(lowerBound(new Float32Array([0, 2, 4, 6, 8]), 5, 0, 5)).toEqual(3);
|
||||
expect(lowerBound(new Float32Array([0, 2, 2, 2, 8]), 5, 0, 5)).toEqual(4);
|
||||
|
||||
expect(
|
||||
lowerBound(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]), 3, 2, 4)
|
||||
).toEqual(3);
|
||||
expect(
|
||||
lowerBound(new Float32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]), 99, 2, 4)
|
||||
).toEqual(4);
|
||||
});
|
||||
|
||||
test("float path, non-finite", () => {
|
||||
expect(
|
||||
lowerBound(
|
||||
new Float32Array([-Infinity, 0, 1, Infinity, NaN]),
|
||||
-Infinity,
|
||||
0,
|
||||
5
|
||||
)
|
||||
).toEqual(0);
|
||||
expect(
|
||||
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 0, 0, 5)
|
||||
).toEqual(1);
|
||||
expect(
|
||||
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 1, 0, 5)
|
||||
).toEqual(2);
|
||||
expect(
|
||||
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), 2, 0, 5)
|
||||
).toEqual(3);
|
||||
expect(
|
||||
lowerBound(
|
||||
new Float32Array([-Infinity, 0, 1, Infinity, NaN]),
|
||||
Infinity,
|
||||
0,
|
||||
5
|
||||
)
|
||||
).toEqual(3);
|
||||
expect(
|
||||
lowerBound(new Float32Array([-Infinity, 0, 1, Infinity, NaN]), NaN, 0, 5)
|
||||
).toEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
import crossfilter from "../../../src/util/typedCrossfilter";
|
||||
|
||||
const someData = [
|
||||
{
|
||||
date: "2011-11-14T16:17:54Z",
|
||||
quantity: 2,
|
||||
total: 190,
|
||||
tip: 100,
|
||||
type: "tab",
|
||||
productIDs: ["001"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:20:19Z",
|
||||
quantity: 2,
|
||||
total: 190,
|
||||
tip: 100,
|
||||
type: "tab",
|
||||
productIDs: ["001", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:28:54Z",
|
||||
quantity: 1,
|
||||
total: 300,
|
||||
tip: 200,
|
||||
type: "visa",
|
||||
productIDs: ["004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:30:43Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "002"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:48:46Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:53:41Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:54:06Z",
|
||||
quantity: 1,
|
||||
total: 100,
|
||||
tip: 0,
|
||||
type: "cash",
|
||||
productIDs: ["001", "002", "003", "004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T16:58:03Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:07:21Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:22:59Z",
|
||||
quantity: 2,
|
||||
total: 90,
|
||||
tip: 0,
|
||||
type: "tab",
|
||||
productIDs: ["001", "002", "004", "005"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:25:45Z",
|
||||
quantity: 2,
|
||||
total: 200,
|
||||
tip: 0,
|
||||
type: "cash",
|
||||
productIDs: ["002"]
|
||||
},
|
||||
{
|
||||
date: "2011-11-14T17:29:52Z",
|
||||
quantity: 1,
|
||||
total: 200,
|
||||
tip: 100,
|
||||
type: "visa",
|
||||
productIDs: ["004"]
|
||||
}
|
||||
];
|
||||
|
||||
function groupReduce(data, valueMap, valueReduce, valueInit) {
|
||||
return _
|
||||
.reduce(
|
||||
data,
|
||||
(acc, value) => {
|
||||
const k = valueMap(value);
|
||||
let r = _.find(acc, o => o.key === k);
|
||||
if (!r) {
|
||||
r = { key: k, value: valueInit() };
|
||||
acc.push(r);
|
||||
}
|
||||
r.value = valueReduce(r.value, value);
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
)
|
||||
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
||||
}
|
||||
|
||||
function groupCount(data, map) {
|
||||
return groupReduce(data, map, (p, v) => p + 1, () => 0);
|
||||
}
|
||||
|
||||
function groupSum(data, map) {
|
||||
return groupReduce(data, map, (p, v) => (p += map(v)), () => 0);
|
||||
}
|
||||
|
||||
var payments = null;
|
||||
beforeEach(() => {
|
||||
payments = crossfilter(someData);
|
||||
});
|
||||
|
||||
describe("typedCrossfilter", () => {
|
||||
test("alloc and free", () => {
|
||||
expect(payments).toBeDefined();
|
||||
expect(payments.size()).toEqual(someData.length);
|
||||
expect(payments.all()).toEqual(someData);
|
||||
|
||||
const quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
expect(quantity).toBeDefined();
|
||||
expect(quantity.id()).toBeDefined();
|
||||
|
||||
quantity.dispose();
|
||||
expect(payments.size()).toEqual(someData.length);
|
||||
expect(payments.all()).toEqual(someData);
|
||||
});
|
||||
|
||||
test("filterAll and filterNone", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
const tip = payments.dimension(r => r.tip, Float32Array);
|
||||
const total = payments.dimension(r => r.total, Float32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
expect(quantity).toBeDefined();
|
||||
expect(tip).toBeDefined();
|
||||
expect(total).toBeDefined();
|
||||
expect(type).toBeDefined();
|
||||
|
||||
// initially, all should be filtered
|
||||
expect(payments.allFiltered()).toHaveLength(payments.size());
|
||||
expect(payments.allFiltered()).toEqual(payments.all());
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
// filterAll
|
||||
tip.filterAll(); // should change nothing
|
||||
expect(payments.allFiltered()).toEqual(payments.all());
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
// ditto
|
||||
total.filterAll();
|
||||
expect(payments.allFiltered()).toEqual(payments.all());
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
// filterNone
|
||||
type.filterNone();
|
||||
expect(payments.allFiltered()).toEqual([]);
|
||||
expect(payments.countFiltered()).toEqual(0);
|
||||
|
||||
quantity.filterNone();
|
||||
expect(payments.allFiltered()).toEqual([]);
|
||||
expect(payments.countFiltered()).toEqual(0);
|
||||
|
||||
// invert the first none; should have no effect because type is
|
||||
// still not filtered
|
||||
quantity.filterAll();
|
||||
expect(payments.allFiltered()).toEqual([]);
|
||||
expect(payments.countFiltered()).toEqual(0);
|
||||
|
||||
// filter all of type; should select all
|
||||
type.filterAll();
|
||||
expect(payments.allFiltered()).toEqual(payments.all());
|
||||
expect(payments.countFiltered()).toEqual(payments.size());
|
||||
});
|
||||
|
||||
test("filterExact", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
const tip = payments.dimension(r => r.tip, Float32Array);
|
||||
const total = payments.dimension(r => r.total, Float32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
quantity.filterExact(1);
|
||||
expect(payments.countFiltered()).toEqual(
|
||||
_.countBy(someData, "quantity")[1]
|
||||
);
|
||||
expect(payments.allFiltered()).toEqual(_.filter(someData, { quantity: 1 }));
|
||||
|
||||
tip.filterExact(0);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_.filter(someData, { tip: 0, quantity: 1 })
|
||||
);
|
||||
|
||||
type.filterExact("cash");
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_.filter(someData, { tip: 0, quantity: 1, type: "cash" })
|
||||
);
|
||||
});
|
||||
|
||||
test("filterRange", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
const tip = payments.dimension(r => r.tip, Float32Array);
|
||||
const total = payments.dimension(r => r.total, Float32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
tip.filterRange([0, 91]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.tip >= 0 && r.tip < 91)
|
||||
.value()
|
||||
);
|
||||
|
||||
tip.filterRange([0, 90]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.tip >= 0 && r.tip < 90)
|
||||
.value()
|
||||
);
|
||||
|
||||
tip.filterRange([1, 90]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.tip >= 1 && r.tip < 91)
|
||||
.value()
|
||||
);
|
||||
});
|
||||
|
||||
test("filterEnum", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
const tip = payments.dimension(r => r.tip, Float32Array);
|
||||
const total = payments.dimension(r => r.total, Float32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
type.filterEnum(["tab", "cash"]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.type === "cash" || r.type === "tab")
|
||||
.value()
|
||||
);
|
||||
|
||||
tip.filterEnum([0, 100]);
|
||||
expect(payments.allFiltered()).toEqual(
|
||||
_(someData)
|
||||
.filter(r => r.type === "cash" || r.type === "tab")
|
||||
.filter(r => r.tip === 0 || r.tip === 100)
|
||||
.value()
|
||||
);
|
||||
});
|
||||
|
||||
test("more than 32 dimensions", () => {
|
||||
expect(payments).toBeDefined();
|
||||
const quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
const tip = payments.dimension(r => r.tip, Float32Array);
|
||||
const total = payments.dimension(r => r.total, Float32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
// Create a bunch of fake dimensions to ensure we can handle > 32
|
||||
let dimMap = {};
|
||||
for (let i = 0; i < 65; i++) {
|
||||
dimMap[i] = payments.dimension(r => Math.random(), Float32Array);
|
||||
expect(dimMap[i]).toBeDefined();
|
||||
expect(dimMap[i].id()).toBeDefined();
|
||||
}
|
||||
|
||||
// everything should start as selected/filtered
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
dimMap[0].filterAll();
|
||||
dimMap[64].filterAll();
|
||||
expect(payments.countFiltered()).toEqual(someData.length);
|
||||
|
||||
dimMap[33].filterNone();
|
||||
expect(payments.allFiltered()).toEqual([]);
|
||||
|
||||
dimMap[33].filterAll();
|
||||
expect(payments.allFiltered()).toEqual(someData);
|
||||
});
|
||||
|
||||
test("group, default mapping, default reducer, no filter", () => {
|
||||
expect(payments).toBeDefined();
|
||||
|
||||
var quantity = payments.dimension(r => r.quantity, Int32Array);
|
||||
var tip = payments.dimension(r => r.tip, Int32Array);
|
||||
var type = payments.dimension(r => r.type, "enum");
|
||||
var total = payments.dimension(r => r.total, Int32Array);
|
||||
|
||||
_.each(
|
||||
{
|
||||
tip: tip.group(r => r),
|
||||
type: type.group(),
|
||||
total: total.group(),
|
||||
quantity: quantity.group()
|
||||
},
|
||||
(grp, k) => {
|
||||
const whatWeExpect = groupCount(someData, v => v[k]);
|
||||
expect(grp.all()).toEqual(whatWeExpect);
|
||||
expect(grp.size()).toEqual(whatWeExpect.length);
|
||||
expect(grp.dispose()).toEqual(grp);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("group, custom map, default reducer, no filters", () => {
|
||||
expect(payments).toBeDefined();
|
||||
|
||||
// custom mapping in groups only works for scalar types. Enums do not
|
||||
// currently implement it.
|
||||
|
||||
const tip = payments.dimension(r => r.tip, Int32Array);
|
||||
const totalX10 = payments.dimension(r => r.total * 10, Int32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
const paymentsByTip_A = tip.group();
|
||||
const paymentsByTip_B = tip.group(r => 10 * r);
|
||||
const paymentsByType = type.group(); // identity only
|
||||
const paymentsByTotalX10_A = totalX10.group();
|
||||
const paymentsByTotalX10_B = totalX10.group(r => r / 10);
|
||||
|
||||
expect(paymentsByTip_A.all()).toEqual(groupCount(someData, v => v.tip));
|
||||
expect(paymentsByTip_B.all()).toEqual(
|
||||
groupCount(someData, v => 10 * v.tip)
|
||||
);
|
||||
expect(paymentsByType.all()).toEqual(groupCount(someData, v => v.type));
|
||||
expect(paymentsByTotalX10_A.all()).toEqual(
|
||||
groupCount(someData, v => 10 * v.total)
|
||||
);
|
||||
expect(paymentsByTotalX10_B.all()).toEqual(
|
||||
groupCount(someData, v => (10 * v.total) / 10)
|
||||
);
|
||||
|
||||
for (let i of [
|
||||
paymentsByTip_A,
|
||||
paymentsByTip_B,
|
||||
paymentsByType,
|
||||
paymentsByTotalX10_A,
|
||||
paymentsByTotalX10_B,
|
||||
tip,
|
||||
totalX10,
|
||||
type
|
||||
]) {
|
||||
expect(i.dispose()).toEqual(i);
|
||||
}
|
||||
});
|
||||
|
||||
test("group, default map, custom reducer, no filters", () => {
|
||||
expect(payments).toBeDefined();
|
||||
|
||||
const total = payments.dimension(r => r.total, Float32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
const paymentsByTotal = total.group();
|
||||
const paymentsByType = type.group();
|
||||
|
||||
// reduceCount
|
||||
expect(paymentsByTotal.reduceCount()).toEqual(paymentsByTotal);
|
||||
expect(paymentsByTotal.all()).toEqual(groupCount(someData, v => v.total));
|
||||
|
||||
// reduceSum
|
||||
expect(paymentsByTotal.reduceSum(v => v.total)).toEqual(paymentsByTotal);
|
||||
expect(paymentsByTotal.all()).toEqual(groupSum(someData, v => v.total));
|
||||
|
||||
// use custom reducers (my reducers) - count by three, init 1
|
||||
expect(
|
||||
paymentsByTotal.reduce((p, v) => (p += 3), (p, v) => (p -= 3), () => 1)
|
||||
).toEqual(paymentsByTotal);
|
||||
expect(paymentsByTotal.all()).toEqual(
|
||||
groupReduce(someData, v => v.total, (p, v) => p + 3, () => 1)
|
||||
);
|
||||
|
||||
for (let i of [paymentsByTotal, paymentsByType, type]) {
|
||||
expect(i.dispose()).toEqual(i);
|
||||
}
|
||||
});
|
||||
|
||||
test("group, default map, default reducer, filters", () => {
|
||||
// From the docs:
|
||||
// Note: a grouping intersects the crossfilter's current filters, except for the
|
||||
// associated dimension's filter. Thus, group methods consider only records that
|
||||
// satisfy every filter except this dimension's filter. So, if the crossfilter of
|
||||
// payments is filtered by type and total, then group by total only observes the
|
||||
// filter by type.
|
||||
|
||||
expect(payments).toBeDefined();
|
||||
|
||||
const tip = payments.dimension(r => r.tip, Int32Array);
|
||||
const total = payments.dimension(r => r.total, Int32Array);
|
||||
const type = payments.dimension(r => r.type, "enum");
|
||||
|
||||
const paymentsByTip = tip.group();
|
||||
const paymentsByTotal = total.group();
|
||||
const paymentsByType = type.group();
|
||||
|
||||
// 1. confirm that changing the filter on a dimension does NOT change that
|
||||
// dimensions groups.
|
||||
{
|
||||
tip.filterAll(), total.filterAll(), type.filterAll();
|
||||
let before = _.cloneDeep(paymentsByTip.all());
|
||||
tip.filterExact(0);
|
||||
expect(paymentsByTip.all()).toEqual(before);
|
||||
}
|
||||
|
||||
// 2. confirm that changing a filter on a different dimension DOES change
|
||||
// all other groups.
|
||||
{
|
||||
tip.filterAll(), total.filterAll(), type.filterAll();
|
||||
const before = _.cloneDeep([paymentsByTotal.all(), paymentsByType.all()]);
|
||||
tip.filterExact(0);
|
||||
const after = [paymentsByTotal.all(), paymentsByType.all()];
|
||||
expect(after).not.toEqual(before);
|
||||
expect(after).toEqual([
|
||||
groupReduce(
|
||||
someData,
|
||||
v => v.total,
|
||||
(p, v) => (v.tip !== 0 ? p : p + 1),
|
||||
() => 0
|
||||
),
|
||||
groupReduce(
|
||||
someData,
|
||||
v => v.type,
|
||||
(p, v) => (v.tip !== 0 ? p : p + 1),
|
||||
() => 0
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
for (let i of [
|
||||
paymentsByTip,
|
||||
paymentsByTotal,
|
||||
paymentsByType,
|
||||
tip,
|
||||
total,
|
||||
type
|
||||
]) {
|
||||
expect(i.dispose()).toEqual(i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import {
|
||||
fillRange,
|
||||
sliceByIndex,
|
||||
makeSortIndex,
|
||||
lowerBound,
|
||||
lowerBoundIndirect,
|
||||
upperBoundIndirect
|
||||
} from "../../../src/util/typedCrossfilter/util";
|
||||
import { rangeFill as fillRange } from "../../../src/util/range";
|
||||
|
||||
describe("fillRange", () => {
|
||||
test("Array", () => {
|
||||
|
||||
@@ -2,13 +2,24 @@ module.exports = {
|
||||
babelrc: false,
|
||||
cacheDirectory: true,
|
||||
presets: [
|
||||
["modern-browsers", { loose: true, modules: false }],
|
||||
"@babel/preset-react"
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
useBuiltIns: "entry",
|
||||
corejs: 3,
|
||||
modules: false,
|
||||
},
|
||||
],
|
||||
"@babel/preset-react",
|
||||
],
|
||||
plugins: [
|
||||
"@babel/plugin-proposal-function-bind",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
["@babel/plugin-proposal-decorators", { legacy: true }],
|
||||
"@babel/plugin-proposal-export-namespace-from"
|
||||
]
|
||||
["@babel/plugin-proposal-class-properties", { loose: true }],
|
||||
["@babel/plugin-proposal-private-methods", { loose: true }],
|
||||
["@babel/plugin-proposal-private-property-in-object", { loose: true }],
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
module.exports = {
|
||||
babelrc: false,
|
||||
presets: [
|
||||
["modern-browsers", { loose: true, modules: false }],
|
||||
"@babel/preset-react"
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
useBuiltIns: "entry",
|
||||
corejs: 3,
|
||||
modules: false,
|
||||
},
|
||||
],
|
||||
"@babel/preset-react",
|
||||
],
|
||||
plugins: [
|
||||
"@babel/plugin-proposal-function-bind",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
["@babel/plugin-proposal-decorators", { legacy: true }],
|
||||
["@babel/plugin-proposal-class-properties", { loose: true }],
|
||||
["@babel/plugin-proposal-private-methods", { loose: true }],
|
||||
["@babel/plugin-proposal-private-property-in-object", { loose: true }],
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
"@babel/plugin-transform-react-constant-elements",
|
||||
"@babel/plugin-transform-runtime"
|
||||
]
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,18 +1,48 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: "babel-eslint",
|
||||
extends: ["airbnb", "prettier", "prettier/react"],
|
||||
|
||||
extends: [
|
||||
"airbnb",
|
||||
"plugin:eslint-comments/recommended",
|
||||
"plugin:@blueprintjs/recommended",
|
||||
"plugin:compat/recommended",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
// (thuang) disable eslint formatting rules, so prettier can do its job
|
||||
// Do not use `plugin:prettier/recommended` per doc below:
|
||||
// https://prettier.io/docs/en/integrating-with-linters.html
|
||||
"prettier",
|
||||
],
|
||||
settings: {
|
||||
// AbortController is not supported in iOS Safari 10.3, Chrome 61
|
||||
// Headers is not supported in iOS Safari 10.3
|
||||
polyfills: ["Headers", "AbortController"],
|
||||
},
|
||||
env: { browser: true, commonjs: true, es6: true },
|
||||
globals: { expect: true },
|
||||
globals: {
|
||||
expect: true,
|
||||
jest: true,
|
||||
jestPuppeteer: true,
|
||||
it: true,
|
||||
page: true,
|
||||
browser: true,
|
||||
context: true,
|
||||
beforeEach: true,
|
||||
},
|
||||
parser: "@babel/eslint-parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: 2017,
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
generators: true
|
||||
}
|
||||
generators: true,
|
||||
},
|
||||
babelOptions: {
|
||||
configFile: "./configuration/babel/babel.prod.js",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"react/jsx-no-target-blank": "off",
|
||||
"eslint-comments/require-description": ["error"],
|
||||
"no-magic-numbers": "off",
|
||||
"no-nested-ternary": "off",
|
||||
"func-style": "off",
|
||||
@@ -21,26 +51,34 @@ module.exports = {
|
||||
"react/jsx-filename-extension": "off",
|
||||
"comma-dangle": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
quotes: ["error", "double"],
|
||||
"implicit-arrow-linebreak": "off",
|
||||
"operator-linebreak": [
|
||||
"error",
|
||||
"after",
|
||||
{ overrides: { "?": "before", ":": "before" } }
|
||||
],
|
||||
"no-console": "off",
|
||||
"spaced-comment": ["error", "always", { exceptions: ["*"] }],
|
||||
"no-param-reassign": "off",
|
||||
"object-curly-newline": ["error", { consistent: true }],
|
||||
"react/prop-types": [0],
|
||||
"space-before-function-paren": "off",
|
||||
"function-paren-newline": "off"
|
||||
"function-paren-newline": "off",
|
||||
"prefer-destructuring": ["error", { object: true, array: false }],
|
||||
"import/prefer-default-export": "off",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
"ForInStatement",
|
||||
"LabeledStatement",
|
||||
"WithStatement",
|
||||
],
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
{
|
||||
devDependencies: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/*.test.js"],
|
||||
env: {
|
||||
jest: true // now **/*.test.js files' env has both es6 *and* jest
|
||||
jest: true, // now **/*.test.js files' env has both es6 *and* jest
|
||||
},
|
||||
// Can't extend in overrides: https://github.com/eslint/eslint/issues/8813
|
||||
// "extends": ["plugin:jest/recommended"]
|
||||
@@ -50,8 +88,8 @@ module.exports = {
|
||||
"jest/no-focused-tests": "error",
|
||||
"jest/no-identical-title": "error",
|
||||
"jest/prefer-to-have-length": "warn",
|
||||
"jest/valid-expect": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
"jest/valid-expect": "error",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
"*.js": "eslint --fix",
|
||||
"**/*": "prettier --write --ignore-unknown",
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies -- this file is a devDependency*/
|
||||
const cheerio = require("cheerio");
|
||||
const crypto = require("crypto");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
|
||||
const digest = (str) => {
|
||||
const hash = crypto.createHash("sha256").update(str, "utf8").digest("base64");
|
||||
return `sha256-${hash}`;
|
||||
};
|
||||
class CspHashPlugin {
|
||||
constructor(opts) {
|
||||
this.opts = { ...opts };
|
||||
}
|
||||
|
||||
apply(compiler) {
|
||||
compiler.hooks.compilation.tap("CspHashPlugin", (compilation) => {
|
||||
HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
|
||||
"CspHashPlugin",
|
||||
(data, cb) => {
|
||||
const { filename } = this.opts;
|
||||
|
||||
const $ = cheerio.load(data.html, { decodeEntities: false });
|
||||
|
||||
if (filename) {
|
||||
const results = {};
|
||||
results["script-hashes"] = $("script:not([src]):not([no-csp-hash])")
|
||||
.map((i, elmt) => digest($(elmt).html()))
|
||||
.get();
|
||||
|
||||
const json = JSON.stringify(results);
|
||||
compilation.assets[filename] = {
|
||||
source: () => json,
|
||||
size: () => json.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Remove no-csp-hash attributes. Cheerio does not parse Jinja templates
|
||||
// correctly, so we brute force this with a regular expression.
|
||||
data.html = data.html.replace(
|
||||
/(<script .*)no-csp-hash(.*>)/,
|
||||
(match, p1, p2) => [p1, p2].join("")
|
||||
);
|
||||
|
||||
// Tell webpack to move on
|
||||
cb(null, data);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CspHashPlugin;
|
||||
/* eslint-enable import/no-extraneous-dependencies -- enable*/
|
||||
@@ -0,0 +1,75 @@
|
||||
<script>
|
||||
/** Reflect ANY changes to this script in the script hash in `server/eb/app.py` **/
|
||||
var root = document.getElementById("root");
|
||||
root.remove();
|
||||
var portals = document.getElementsByClassName("bp3-portal");
|
||||
for (var i = 0; i < portals.length; i += 1) {
|
||||
portals[i].remove();
|
||||
}
|
||||
</script>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #8080801a;
|
||||
font-family: 'Roboto Condensed, sans serif';
|
||||
"
|
||||
>
|
||||
<img
|
||||
src="https://cellxgene.cziscience.com/s3/cellxgene/static/images/cellxgene-logo.png"
|
||||
style="width: 320px;"
|
||||
/>
|
||||
<div
|
||||
style="
|
||||
margin-top: 16px;
|
||||
background: white;
|
||||
width: 40vw;
|
||||
border-radius: 4px;
|
||||
padding: 24px 64px;
|
||||
-webkit-box-shadow: 0px 0px 3px 2px rgba(0, 0, 0, 0.38);
|
||||
-moz-box-shadow: 0px 0px 3px 2px rgba(0, 0, 0, 0.38);
|
||||
box-shadow: 0px 0px 3px 2px rgba(0, 0, 0, 0.38);
|
||||
max-width: 550px;
|
||||
"
|
||||
>
|
||||
<div style="margin-bottom: 0; font-weight: bolder; font-size: 1.2em;">
|
||||
Unsupported Browser
|
||||
</div>
|
||||
<div style="margin-top: 0;">
|
||||
cellxgene is currently supported on the following browsers
|
||||
</div>
|
||||
<div
|
||||
style="display: flex; justify-content: space-around; margin-top: 16px;"
|
||||
>
|
||||
<a
|
||||
href="https://www.google.com/chrome/?hl=en%22"
|
||||
aria-label="Download Google Chrome"
|
||||
>
|
||||
<img
|
||||
src="https://cellxgene.cziscience.com/s3/cellxgene/static/images/chrome.png"
|
||||
style="width: 80px; height: 80px;"
|
||||
/>
|
||||
<div>Chrome > 60</div>
|
||||
</a>
|
||||
<a href="https://www.mozilla.com/firefox/" aria-label="Download Firefox">
|
||||
<img
|
||||
src="https://cellxgene.cziscience.com/s3/cellxgene/static/images/firefox.png"
|
||||
style="width: 80px; height: 80px;"
|
||||
/>
|
||||
<div>Firefox ≥ 60</div>
|
||||
</a>
|
||||
<a href="//www.microsoft.com/edge" aria-label="Download Edge">
|
||||
<img
|
||||
src="https://cellxgene.cziscience.com/s3/cellxgene/static/images/edge.png"
|
||||
style="width: 80px; height: 80px;"
|
||||
/>
|
||||
<div>Edge ≥ 79</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,82 +1,66 @@
|
||||
// jshint esversion: 6
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
|
||||
const src = path.resolve("src");
|
||||
const nodeModules = path.resolve("node_modules");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const sharedConfig = require("./webpack.config.shared");
|
||||
const babelOptions = require("../babel/babel.dev");
|
||||
|
||||
module.exports = {
|
||||
const fonts = path.resolve("src/fonts");
|
||||
const images = path.resolve("src/images");
|
||||
const nodeModules = path.resolve("node_modules");
|
||||
|
||||
const devConfig = {
|
||||
mode: "development",
|
||||
devtool: "eval",
|
||||
entry: ["./src/index"],
|
||||
output: {
|
||||
path: path.resolve("build"),
|
||||
pathinfo: true,
|
||||
filename: "static/js/bundle.js",
|
||||
publicPath: "/"
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
include: src,
|
||||
test: /\.jsx?$/,
|
||||
loader: "babel-loader",
|
||||
options: babelOptions
|
||||
options: babelOptions,
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
include: src,
|
||||
exclude: [path.resolve(src, "index.css")],
|
||||
loader: [
|
||||
{
|
||||
loader: "style-loader"
|
||||
},
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
modules: true,
|
||||
localIdentName: "[name]__[local]___[hash:base64:5]"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /index\.css$/,
|
||||
include: [path.resolve(src, "index.css")],
|
||||
loader: [
|
||||
{
|
||||
loader: "style-loader"
|
||||
},
|
||||
{
|
||||
loader: "css-loader"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{ test: /\.json$/, include: [src, nodeModules], loader: "json-loader" },
|
||||
{
|
||||
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
|
||||
include: [src, nodeModules],
|
||||
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2|otf)$/i,
|
||||
loader: "file-loader",
|
||||
query: { name: "static/media/[name].[ext]" }
|
||||
include: [nodeModules, fonts, images],
|
||||
options: {
|
||||
name: "static/assets/[name].[ext]",
|
||||
// (thuang): This is needed to make sure @font url path is '/static/assets/'
|
||||
publicPath: "..",
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(mp4|webm)(\?.*)?$/,
|
||||
include: [src, nodeModules],
|
||||
loader: "url-loader",
|
||||
query: { limit: 10000, name: "static/media/[name].[ext]" }
|
||||
}
|
||||
]
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
inject: true,
|
||||
template: path.resolve("index.html"),
|
||||
favicon: path.resolve("favicon.png")
|
||||
}),
|
||||
new webpack.NoEmitOnErrorsPlugin()
|
||||
]
|
||||
new MiniCssExtractPlugin({
|
||||
filename: "static/[name].css",
|
||||
}),
|
||||
new webpack.NoEmitOnErrorsPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
__REACT_DEVTOOLS_GLOBAL_HOOK__: "({ isDisabled: true })",
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
// webpack 5 no longer polyfills NodeJS modules, so fake the one we need
|
||||
"process.env": JSON.stringify({
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
CXG_SERVER_PORT: process.env.CXG_SERVER_PORT || "5005",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
infrastructureLogging: {
|
||||
level: "warn",
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = merge(sharedConfig, devConfig);
|
||||
|
||||
@@ -1,119 +1,87 @@
|
||||
// jshint esversion: 6
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
|
||||
const TerserJSPlugin = require("terser-webpack-plugin");
|
||||
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
const SWPrecacheWebpackPlugin = require("sw-precache-webpack-plugin");
|
||||
const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin");
|
||||
|
||||
const src = path.resolve("src");
|
||||
const nodeModules = path.resolve("node_modules");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const babelOptions = require("../babel/babel.prod");
|
||||
|
||||
const publicPath = "/";
|
||||
const CspHashPlugin = require("./cspHashPlugin");
|
||||
const sharedConfig = require("./webpack.config.shared");
|
||||
|
||||
module.exports = {
|
||||
const fonts = path.resolve("src/fonts");
|
||||
const images = path.resolve("src/images");
|
||||
const nodeModules = path.resolve("node_modules");
|
||||
|
||||
const prodConfig = {
|
||||
mode: "production",
|
||||
bail: true,
|
||||
cache: false,
|
||||
devtool: "cheap-source-map",
|
||||
entry: ["./src/index.js"],
|
||||
output: {
|
||||
path: path.resolve("build"),
|
||||
filename: "static/js/[name].[chunkhash:8].js",
|
||||
publicPath
|
||||
filename: "static/[name]-[contenthash].js",
|
||||
},
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimizer: [
|
||||
new TerserJSPlugin({}),
|
||||
new CssMinimizerPlugin({
|
||||
minify: CssMinimizerPlugin.cleanCssMinify,
|
||||
}),
|
||||
],
|
||||
},
|
||||
devtool: "source-map",
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
include: src,
|
||||
test: /\.jsx?$/,
|
||||
loader: "babel-loader",
|
||||
options: babelOptions
|
||||
options: babelOptions,
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
include: src,
|
||||
exclude: [path.resolve(src, "index.css")],
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
modules: true,
|
||||
importLoaders: 1,
|
||||
localIdentName: "[name]__[local]___[hash:base64:5]"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /index\.css$/,
|
||||
include: [path.resolve(src, "index.css")],
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
importLoaders: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.json$/,
|
||||
include: [src, nodeModules],
|
||||
loader: "json-loader",
|
||||
exclude: /manifest.json$/
|
||||
},
|
||||
{
|
||||
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2)(\?.*)?$/,
|
||||
include: [src, nodeModules],
|
||||
test: /\.(jpg|png|gif|eot|svg|ttf|woff|woff2|otf)$/i,
|
||||
loader: "file-loader",
|
||||
include: [nodeModules, fonts, images],
|
||||
options: {
|
||||
name: "[name].[hash:8].[ext]",
|
||||
outputPath: "static/media/"
|
||||
}
|
||||
name: "static/assets/[name]-[contenthash].[ext]",
|
||||
// (thuang): This is needed to make sure @font url path is '../static/assets/'
|
||||
publicPath: "..",
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(mp4|webm)(\?.*)?$/,
|
||||
include: [src, nodeModules],
|
||||
loader: "url-loader",
|
||||
query: { limit: 10000, name: "static/media/[name].[hash:8].[ext]" }
|
||||
}
|
||||
]
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
inject: "body",
|
||||
filename: "index.html",
|
||||
template: path.resolve("index_template.html"),
|
||||
favicon: path.resolve("favicon.png"),
|
||||
inlineSource: ".(js|css)$",
|
||||
minify: {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
removeRedundantAttributes: true,
|
||||
useShortDoctype: true,
|
||||
removeEmptyAttributes: true,
|
||||
removeStyleLinkTypeAttributes: true,
|
||||
keepClosingSlash: true,
|
||||
minifyJS: true,
|
||||
minifyCSS: true,
|
||||
minifyURLs: true
|
||||
}
|
||||
decodeEntities: false,
|
||||
minify: false,
|
||||
}),
|
||||
new CleanWebpackPlugin({
|
||||
verbose: true,
|
||||
protectWebpackAssets: false,
|
||||
cleanAfterEveryBuildPatterns: ["main.js", "main.css"],
|
||||
}),
|
||||
new HtmlWebpackInlineSourcePlugin(),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: "static/css/[name].[contenthash:8].css"
|
||||
filename: "static/[name]-[contenthash].css",
|
||||
}),
|
||||
new CspHashPlugin({
|
||||
filename: "csp-hashes.json",
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
// webpack 5 no longer polyfills NodeJS modules, so fake the one we need
|
||||
"process.env": JSON.stringify({
|
||||
NODE_ENV: "production",
|
||||
}),
|
||||
}),
|
||||
new SWPrecacheWebpackPlugin({
|
||||
cacheId: "cellxgene",
|
||||
filename: "service-worker.js"
|
||||
})
|
||||
],
|
||||
performance: {
|
||||
maxEntrypointSize: 2000000,
|
||||
maxAssetSize: 2000000
|
||||
}
|
||||
maxAssetSize: 2000000,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = merge(sharedConfig, prodConfig);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
const ObsoleteWebpackPlugin = require("obsolete-webpack-plugin");
|
||||
|
||||
const src = path.resolve("src");
|
||||
const nodeModules = path.resolve("node_modules");
|
||||
|
||||
const publicPath = "";
|
||||
|
||||
const rawObsoleteHTMLTemplate = fs.readFileSync(
|
||||
`${__dirname}/obsoleteHTMLTemplate.html`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const obsoleteHTMLTemplate = rawObsoleteHTMLTemplate.replace(/'/g, '"');
|
||||
|
||||
module.exports = {
|
||||
entry: [
|
||||
"core-js",
|
||||
"regenerator-runtime/runtime",
|
||||
"whatwg-fetch",
|
||||
"abort-controller/polyfill",
|
||||
"./src/index",
|
||||
],
|
||||
output: {
|
||||
path: path.resolve("build"),
|
||||
publicPath,
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/,
|
||||
include: src,
|
||||
exclude: [path.resolve(src, "index.css")],
|
||||
use: [
|
||||
MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
modules: {
|
||||
localIdentName: "[name]__[local]___[contenthash:base64:5]",
|
||||
},
|
||||
importLoaders: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /index\.css$/,
|
||||
include: [path.resolve(src, "index.css")],
|
||||
use: [MiniCssExtractPlugin.loader, "css-loader"],
|
||||
},
|
||||
{
|
||||
test: /\.json$/,
|
||||
include: [src, nodeModules],
|
||||
loader: "json-loader",
|
||||
exclude: /manifest.json$/,
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new ObsoleteWebpackPlugin({
|
||||
name: "obsolete",
|
||||
template: obsoleteHTMLTemplate,
|
||||
promptOnNonTargetBrowser: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 312 B |
+25
-8
@@ -1,13 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>cellxgene</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CELL×GENE | Annotate</title>
|
||||
<style>
|
||||
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
|
||||
font-family: 'Roboto Condensed','Helvetica Neue','Helvetica','Arial',sans-serif;
|
||||
html,
|
||||
body,
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
span,
|
||||
button,
|
||||
input,
|
||||
label,
|
||||
text,
|
||||
div {
|
||||
font-family: "Roboto Condensed", "Helvetica Neue", "Helvetica", "Arial",
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
body {
|
||||
@@ -21,9 +35,12 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>If you're seeing this message, that means <strong>JavaScript has been disabled on your browser</strong>, please <strong>enable JS</strong> to make this app work.</noscript>
|
||||
<noscript
|
||||
>If you're seeing this message, that means
|
||||
<strong>JavaScript has been disabled on your browser</strong>, please
|
||||
<strong>enable JS</strong> to make this app work.</noscript
|
||||
>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+44
-14
@@ -1,15 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>cellxgene</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700" rel="stylesheet">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CELL×GENE | Annotate</title>
|
||||
<style>
|
||||
html, body, p, h1, h2, h3, h4, h5, h6, span, button, input, label, text, div {
|
||||
font-family: 'Roboto Condensed','Helvetica Neue','Helvetica','Arial',sans-serif;
|
||||
html,
|
||||
body,
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
span,
|
||||
button,
|
||||
input,
|
||||
label,
|
||||
text,
|
||||
div {
|
||||
font-family: "Roboto Condensed", "Helvetica Neue", "Helvetica", "Arial",
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -21,15 +36,30 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
window.CELLXGENE = {};
|
||||
window.CELLXGENE.API = {
|
||||
prefix: "{{ prefix | safe }}",
|
||||
version: "v0.2/"
|
||||
};
|
||||
</script>
|
||||
<noscript>If you're seeing this message, that means <strong>JavaScript has been disabled on your browser</strong>, please <strong>enable JS</strong> to make this app work.</noscript>
|
||||
<script type="text/javascript">
|
||||
window.CELLXGENE = {};
|
||||
window.CELLXGENE.API = {
|
||||
prefix: `${location.origin}${location.pathname}api/`,
|
||||
version: "v0.2/",
|
||||
};
|
||||
</script>
|
||||
<noscript
|
||||
>If you're seeing this message, that means
|
||||
<strong>JavaScript has been disabled on your browser</strong>, please
|
||||
<strong>enable JS</strong> to make this app work.
|
||||
</noscript>
|
||||
|
||||
<div id="root"></div>
|
||||
{% for script in SCRIPTS -%}
|
||||
<script type="text/javascript"
|
||||
{{ ('integrity="%s"' % script.integrity) | safe if script.integrity }}
|
||||
{{ ('crossorigin="%s"' % script.crossorigin) | safe if script.crossorigin }}
|
||||
src="{{script.src | safe}}"
|
||||
></script>
|
||||
{%- endfor %}
|
||||
|
||||
{% for ils in INLINE_SCRIPTS %}
|
||||
<script type="text/javascript" no-csp-hash>{% include ils %}</script>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* `client/jest-puppeteer.config.js` is for configuring Puppeteer's launch config options
|
||||
* `client/__tests__/e2e/puppeteer.setup.js` is for configuring `jest`, `browser`,
|
||||
* and `page` objects
|
||||
*/
|
||||
|
||||
const ENV_DEFAULT = require("../environment.default.json");
|
||||
|
||||
const jestEnv = process.env.JEST_ENV || ENV_DEFAULT.JEST_ENV;
|
||||
const isHeadful =
|
||||
process.env.HEADFUL === "true" || process.env.HEADLESS === "false";
|
||||
|
||||
const DEFAULT_LAUNCH_CONFIG = {
|
||||
headless: !isHeadful,
|
||||
args: ["--ignore-certificate-errors", "--ignore-ssl-errors"],
|
||||
ignoreHTTPSErrors: true,
|
||||
defaultViewport: {
|
||||
width: 1280,
|
||||
height: 960,
|
||||
},
|
||||
};
|
||||
|
||||
const LAUNCH_CONFIG_BY_ENV = {
|
||||
[ENV_DEFAULT.DEBUG]: {
|
||||
...DEFAULT_LAUNCH_CONFIG,
|
||||
headless: false,
|
||||
slowMo: 100,
|
||||
devtools: true,
|
||||
defaultViewport: {
|
||||
width: DEFAULT_LAUNCH_CONFIG.defaultViewport.width,
|
||||
height: DEFAULT_LAUNCH_CONFIG.defaultViewport.height + 560,
|
||||
},
|
||||
},
|
||||
[ENV_DEFAULT.DEV]: {
|
||||
...DEFAULT_LAUNCH_CONFIG,
|
||||
headless: false,
|
||||
slowMo: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const launchConfig = LAUNCH_CONFIG_BY_ENV[jestEnv] || DEFAULT_LAUNCH_CONFIG;
|
||||
|
||||
module.exports = {
|
||||
browserContext: "incognito",
|
||||
launch: launchConfig,
|
||||
};
|
||||
Generated
+26646
-10204
File diff suppressed because it is too large
Load Diff
+147
-86
@@ -1,16 +1,20 @@
|
||||
{
|
||||
"name": "cellxgene",
|
||||
"version": "0.0.3",
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
"description": "cellxgene is a web application for the interactive exploration of single cell sequence data.",
|
||||
"repository": "https://github.com/chanzuckerberg/cellxgene",
|
||||
"scripts": {
|
||||
"build": "npm run clean && webpack --config configuration/webpack/webpack.config.prod.js",
|
||||
"dev": "npm run clean && webpack --config configuration/webpack/webpack.config.dev.js",
|
||||
"build": "npm run clean && webpack --config",
|
||||
"clean": "rimraf build",
|
||||
"start": "node server/development.js",
|
||||
"lint": "eslint src",
|
||||
"test": "jest"
|
||||
"dev": "npm run build -- configuration/webpack/webpack.config.dev.js",
|
||||
"e2e": "jest --config __tests__/e2e/e2eJestConfig.json e2e/e2e.test.js",
|
||||
"e2e-annotations": "jest --config __tests__/e2e/e2eJestConfig.json e2e/e2eAnnotations.test.js",
|
||||
"fmt": "eslint --fix src __tests__",
|
||||
"lint": "eslint --fix src __tests__",
|
||||
"prod": "npm run build -- configuration/webpack/webpack.config.prod.js",
|
||||
"test": "jest --testPathIgnorePatterns e2e",
|
||||
"prepare": "cd .. && husky install client/.husky"
|
||||
},
|
||||
"engineStrict": true,
|
||||
"engines": {
|
||||
@@ -19,97 +23,135 @@
|
||||
"eslintConfig": {
|
||||
"extends": "./configuration/eslint/eslint.js"
|
||||
},
|
||||
"nyc": {
|
||||
"sourceMap": false,
|
||||
"instrument": false
|
||||
},
|
||||
"eslintIgnore": [
|
||||
"src/util/stateManager/matrix_generated.js"
|
||||
],
|
||||
"resolutions": {
|
||||
"eslint-scope": "3.7.1"
|
||||
},
|
||||
"browserslist": [
|
||||
"Chrome > 60",
|
||||
"iOS >= 10.3",
|
||||
"Firefox >= 60",
|
||||
"Edge >= 79",
|
||||
"not Explorer > 0",
|
||||
"not Safari > 0"
|
||||
],
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "^3.8.0",
|
||||
"@blueprintjs/icons": "^3.3.0",
|
||||
"@blueprintjs/select": "^3.2.1",
|
||||
"canvas-fit": "^1.5.0",
|
||||
"@babel/eslint-parser": "^7.15.0",
|
||||
"@blueprintjs/core": "^3.44.0",
|
||||
"@blueprintjs/icons": "^3.19.0",
|
||||
"@blueprintjs/popover2": "^0.11.2",
|
||||
"@blueprintjs/select": "^3.16.0",
|
||||
"abort-controller": "^3.0.0",
|
||||
"core-js": "^3.16.3",
|
||||
"d3": "^4.10.0",
|
||||
"d3-scale-chromatic": "^1.3.0",
|
||||
"font-color-contrast": "^1.0.3",
|
||||
"gl-mat4": "^1.1.4",
|
||||
"gl-matrix": "^2.7.1",
|
||||
"key-pressed": "0.0.1",
|
||||
"lodash": "^4.17.4",
|
||||
"memoize-one": "^4.0.0",
|
||||
"mouse-position": "^2.0.1",
|
||||
"mouse-pressed": "^1.0.0",
|
||||
"normalize.css": "^8.0.0",
|
||||
"orbit-camera": "^1.0.0",
|
||||
"query-string": "^6.1.0",
|
||||
"react": "^16.6.0",
|
||||
"react-autocomplete": "^1.7.2",
|
||||
"react-dom": "^16.6.0",
|
||||
"react-helmet": "^5.2.0",
|
||||
"react-icons": "^3.2.2",
|
||||
"react-redux": "^5.1.0",
|
||||
"redux": "^4.0.1",
|
||||
"redux-devtools-extension": "^2.13.5",
|
||||
"redux-thunk": "^2.2.0",
|
||||
"regl": "^1.3.9",
|
||||
"scroll-speed": "^1.0.0",
|
||||
"urijs": "^1.19.0"
|
||||
"d3-scale-chromatic": "^1.5.0",
|
||||
"flatbuffers": "^1.11.0",
|
||||
"fuzzysort": "^1.1.4",
|
||||
"gl-mat4": "^1.2.0",
|
||||
"gl-matrix": "^3.3.0",
|
||||
"gl-vec3": "^1.1.3",
|
||||
"is-number": "^7.0.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.difference": "^4.5.0",
|
||||
"lodash.foreach": "^4.5.0",
|
||||
"lodash.pull": "^4.1.0",
|
||||
"lodash.sortby": "^4.7.0",
|
||||
"lodash.uniq": "^4.5.0",
|
||||
"memoize-one": "^5.1.1",
|
||||
"pako": "^2.0.3",
|
||||
"react": "^17.0.2",
|
||||
"react-async": "^10.0.1",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-flip-toolkit": "^7.0.12",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-icons": "^4.2.0",
|
||||
"react-redux": "^7.2.0",
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"regl": "^2.1.0",
|
||||
"sha1": "^1.1.1",
|
||||
"tinyqueue": "^2.0.3",
|
||||
"whatwg-fetch": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.1.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.0.0",
|
||||
"@babel/plugin-proposal-decorators": "^7.0.0",
|
||||
"@babel/plugin-proposal-export-namespace-from": "^7.0.0",
|
||||
"@babel/plugin-proposal-function-bind": "^7.0.0",
|
||||
"@babel/plugin-transform-react-constant-elements": "^7.0.0",
|
||||
"@babel/plugin-transform-runtime": "^7.1.0",
|
||||
"@babel/preset-env": "^7.1.5",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@babel/register": "^7.0.0",
|
||||
"@babel/runtime": "^7.1.5",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-jest": "^23.6.0",
|
||||
"babel-loader": "^8.0.0",
|
||||
"babel-plugin-istanbul": "^5.1.0",
|
||||
"babel-preset-modern-browsers": "^12.0.0",
|
||||
"chalk": "^2.4.1",
|
||||
"connect-history-api-fallback": "^1.3.0",
|
||||
"copy-webpack-plugin": "^4.6.0",
|
||||
"css-loader": "^1.0.1",
|
||||
"eslint": "^5.8.0",
|
||||
"eslint-config-airbnb": "^17.1.0",
|
||||
"eslint-config-prettier": "^3.1.0",
|
||||
"eslint-loader": "^2.1.1",
|
||||
"@babel/core": "^7.13.16",
|
||||
"@babel/plugin-proposal-class-properties": "^7.10.4",
|
||||
"@babel/plugin-proposal-decorators": "^7.13.15",
|
||||
"@babel/plugin-proposal-export-namespace-from": "^7.10.4",
|
||||
"@babel/plugin-proposal-function-bind": "^7.10.5",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.10.4",
|
||||
"@babel/plugin-transform-react-constant-elements": "^7.13.13",
|
||||
"@babel/plugin-transform-runtime": "^7.13.15",
|
||||
"@babel/preset-env": "^7.13.15",
|
||||
"@babel/preset-react": "^7.13.13",
|
||||
"@babel/register": "^7.13.16",
|
||||
"@babel/runtime": "^7.13.16",
|
||||
"@blueprintjs/eslint-plugin": "^0.3.0",
|
||||
"@sentry/webpack-plugin": "^1.15.0",
|
||||
"babel-jest": "^26.1.0",
|
||||
"babel-loader": "^8.1.0",
|
||||
"babel-preset-modern-browsers": "^15.0.2",
|
||||
"chalk": "^4.1.0",
|
||||
"cheerio": "^1.0.0-rc.6",
|
||||
"clean-css": "^5.1.2",
|
||||
"clean-webpack-plugin": "^4.0.0-alpha.0",
|
||||
"codecov": "^3.7.1",
|
||||
"css-loader": "^5.2.4",
|
||||
"css-minimizer-webpack-plugin": "^4.0.0",
|
||||
"eslint": "^7.24.0",
|
||||
"eslint-config-airbnb": "^18.2.0",
|
||||
"eslint-config-prettier": "^8.2.0",
|
||||
"eslint-plugin-compat": "^3.8.0",
|
||||
"eslint-plugin-eslint-comments": "^3.2.0",
|
||||
"eslint-plugin-filenames": "^1.3.2",
|
||||
"eslint-plugin-import": "^2.14.0",
|
||||
"eslint-plugin-jest": "^21.27.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.1.1",
|
||||
"eslint-plugin-react": "^7.11.1",
|
||||
"express": "^4.14.0",
|
||||
"file-loader": "^2.0.0",
|
||||
"html-webpack-inline-source-plugin": "0.0.10",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"jest": "^23.5.0",
|
||||
"json-loader": "^0.5.4",
|
||||
"mini-css-extract-plugin": "^0.4.1",
|
||||
"nyc": "^13.0.1",
|
||||
"rimraf": "^2.5.4",
|
||||
"serve-favicon": "^2.3.0",
|
||||
"style-loader": "^0.23.1",
|
||||
"sw-precache-webpack-plugin": "^0.11.5",
|
||||
"url-loader": "^1.1.0",
|
||||
"webpack": "^4.25.1",
|
||||
"webpack-cli": "^3.1.0",
|
||||
"webpack-dev-middleware": "^3.1.3"
|
||||
"eslint-plugin-import": "^2.24.2",
|
||||
"eslint-plugin-jest": "^24.3.5",
|
||||
"eslint-plugin-jsx-a11y": "^6.3.1",
|
||||
"eslint-plugin-react": "^7.23.2",
|
||||
"eslint-plugin-react-hooks": "^4.0.8",
|
||||
"expect-puppeteer": "^5.0.0",
|
||||
"express": "^4.17.1",
|
||||
"file-loader": "^6.0.0",
|
||||
"html-webpack-plugin": "^5.3.1",
|
||||
"husky": "^7.0.2",
|
||||
"jest": "^27.0.6",
|
||||
"jest-circus": "^27.0.6",
|
||||
"jest-environment-puppeteer": "^5.0.1",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jest-puppeteer": "^5.0.1",
|
||||
"json-loader": "^0.5.7",
|
||||
"lint-staged": "^10.2.11",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash.every": "^4.6.0",
|
||||
"lodash.filter": "^4.6.0",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.map": "^4.6.0",
|
||||
"lodash.zip": "^4.2.0",
|
||||
"mini-css-extract-plugin": "^1.5.0",
|
||||
"obsolete-webpack-plugin": "^0.5.6",
|
||||
"prettier": "^2.0.5",
|
||||
"puppeteer": "^8.0.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"serve-favicon": "^2.5.0",
|
||||
"terser-webpack-plugin": "^5.1.1",
|
||||
"webpack": "^5.34.0",
|
||||
"webpack-cli": "^4.6.0",
|
||||
"webpack-dev-middleware": "^4.1.0",
|
||||
"webpack-merge": "^5.0.9"
|
||||
},
|
||||
"jest": {
|
||||
"testMatch": [
|
||||
"**/__tests__/**/?(*.)(spec|test).js?(x)"
|
||||
],
|
||||
"testURL": "http://localhost/"
|
||||
"setupFiles": [
|
||||
"./__tests__/setupMissingGlobals.js"
|
||||
],
|
||||
"coverageDirectory": "./coverage/",
|
||||
"collectCoverage": true
|
||||
},
|
||||
"babel": {
|
||||
"env": {
|
||||
@@ -120,16 +162,35 @@
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-function-bind",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
[
|
||||
"@babel/plugin-proposal-decorators",
|
||||
{
|
||||
"legacy": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
{
|
||||
"loose": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"@babel/plugin-proposal-private-methods",
|
||||
{
|
||||
"loose": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"@babel/plugin-proposal-private-property-in-object",
|
||||
{
|
||||
"loose": true
|
||||
}
|
||||
],
|
||||
"@babel/plugin-proposal-export-namespace-from",
|
||||
"@babel/plugin-transform-react-constant-elements",
|
||||
"@babel/plugin-transform-runtime"
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,53 @@
|
||||
/* eslint-disable */
|
||||
// jshint esversion: 6
|
||||
var path = require("path");
|
||||
var historyApiFallback = require("connect-history-api-fallback");
|
||||
var chalk = require("chalk");
|
||||
var express = require("express");
|
||||
var favicon = require("serve-favicon");
|
||||
var webpack = require("webpack");
|
||||
var config = require("../configuration/webpack/webpack.config.dev");
|
||||
var utils = require("./utils");
|
||||
const chalk = require("chalk");
|
||||
const express = require("express");
|
||||
const favicon = require("serve-favicon");
|
||||
const webpack = require("webpack");
|
||||
const devMiddleware = require("webpack-dev-middleware");
|
||||
const config = require("../configuration/webpack/webpack.config.dev");
|
||||
const utils = require("./utils");
|
||||
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
var PORT = process.env.PORT || 3000;
|
||||
const CLIENT_PORT = process.env.CXG_CLIENT_PORT;
|
||||
const { CXG_SERVER_PORT } = process.env;
|
||||
|
||||
const API = {
|
||||
prefix: `http://localhost:${CXG_SERVER_PORT}/`,
|
||||
};
|
||||
|
||||
// Set up compiler
|
||||
var compiler = webpack(config);
|
||||
const compiler = webpack(config);
|
||||
|
||||
compiler.plugin("invalid", () => {
|
||||
compiler.hooks.invalid.tap("invalid", () => {
|
||||
utils.clearConsole();
|
||||
console.log("Compiling...");
|
||||
});
|
||||
|
||||
compiler.plugin("done", stats => {
|
||||
utils.formatStats(stats, PORT);
|
||||
compiler.hooks.done.tap("done", (stats) => {
|
||||
utils.formatStats(stats, CLIENT_PORT);
|
||||
});
|
||||
|
||||
// Launch server
|
||||
var app = express();
|
||||
|
||||
app.use(historyApiFallback({ verbose: false }));
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
require("webpack-dev-middleware")(compiler, {
|
||||
logLevel: "warn",
|
||||
publicPath: config.output.publicPath
|
||||
devMiddleware(compiler, {
|
||||
publicPath: config.output.publicPath,
|
||||
index: true,
|
||||
})
|
||||
);
|
||||
|
||||
app.use(favicon("./favicon.png"));
|
||||
|
||||
app.get("*", (req, res) => {
|
||||
res.sendFile(path.resolve("index.html"));
|
||||
app.get("/logout", async (req, res) => {
|
||||
try {
|
||||
res.redirect(`${API.prefix}logout?dataset=http://localhost:${CLIENT_PORT}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, err => {
|
||||
app.listen(CLIENT_PORT, (err) => {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
return;
|
||||
|
||||
@@ -8,7 +8,9 @@ function isLikelyASyntaxError(message) {
|
||||
return message.indexOf(friendlySyntaxErrorLabel) !== -1;
|
||||
}
|
||||
|
||||
function formatMessage(message) {
|
||||
function formatMessage(messageObject) {
|
||||
let { message, details } = messageObject;
|
||||
if (details) message = message + ": " + details;
|
||||
return message
|
||||
.replace("Module build failed: SyntaxError:", friendlySyntaxErrorLabel)
|
||||
.replace(
|
||||
@@ -36,10 +38,10 @@ var formatStats = (stats, port) => {
|
||||
|
||||
var json = stats.toJson();
|
||||
var formattedErrors = json.errors.map(
|
||||
message => "Error in " + formatMessage(message)
|
||||
(message) => "Error in " + formatMessage(message)
|
||||
);
|
||||
var formattedWarnings = json.warnings.map(
|
||||
message => "Warning in " + formatMessage(message)
|
||||
(message) => "Warning in " + formatMessage(message)
|
||||
);
|
||||
|
||||
if (hasErrors) {
|
||||
@@ -48,7 +50,7 @@ var formatStats = (stats, port) => {
|
||||
if (formattedErrors.some(isLikelyASyntaxError)) {
|
||||
formattedErrors = formattedErrors.filter(isLikelyASyntaxError);
|
||||
}
|
||||
formattedErrors.forEach(message => {
|
||||
formattedErrors.forEach((message) => {
|
||||
console.log(message);
|
||||
console.log();
|
||||
});
|
||||
@@ -58,7 +60,7 @@ var formatStats = (stats, port) => {
|
||||
if (hasWarnings) {
|
||||
console.log(chalk.yellow("Compiled with warnings."));
|
||||
console.log();
|
||||
formattedWarnings.forEach(message => {
|
||||
formattedWarnings.forEach((message) => {
|
||||
console.log(message);
|
||||
console.log();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
/*
|
||||
Action creators for user annotation
|
||||
*/
|
||||
import difference from "lodash.difference";
|
||||
import pako from "pako";
|
||||
import * as globals from "../globals";
|
||||
import { MatrixFBS, AnnotationsHelpers } from "../util/stateManager";
|
||||
|
||||
const { isUserAnnotation } = AnnotationsHelpers;
|
||||
|
||||
export const annotationCreateCategoryAction =
|
||||
(newCategoryName, categoryToDuplicate) => async (dispatch, getState) => {
|
||||
/*
|
||||
Add a new user-created category to the obs annotations.
|
||||
|
||||
Arguments:
|
||||
newCategoryName - string name for the category.
|
||||
categoryToDuplicate - obs category to use for initial values, or null.
|
||||
*/
|
||||
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
|
||||
getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
const { schema } = prevAnnoMatrix;
|
||||
|
||||
/* name must be a string, non-zero length */
|
||||
if (typeof newCategoryName !== "string" || newCategoryName.length === 0)
|
||||
throw new Error("user annotations require string name");
|
||||
|
||||
/* ensure the name isn't already in use! */
|
||||
if (schema.annotations.obsByName[newCategoryName])
|
||||
throw new Error("name collision on annotation category create");
|
||||
|
||||
let initialValue;
|
||||
let newSchema;
|
||||
let ctor;
|
||||
if (categoryToDuplicate) {
|
||||
/* if we are duplicating a category, retrieve it */
|
||||
const catDupSchema = schema.annotations.obsByName[categoryToDuplicate];
|
||||
const catDupType = catDupSchema?.type;
|
||||
if (catDupType !== "string" && catDupType !== "categorical")
|
||||
throw new Error(
|
||||
"categoryToDuplicate does not exist or has invalid type"
|
||||
);
|
||||
|
||||
const catToDupDf = await prevAnnoMatrix
|
||||
.base()
|
||||
.fetch("obs", categoryToDuplicate);
|
||||
const col = catToDupDf.col(categoryToDuplicate);
|
||||
initialValue = col.asArray();
|
||||
const { categories } = col.summarizeCategorical();
|
||||
// all user-created annotations must have the unassigned category
|
||||
if (!categories.includes(globals.unassignedCategoryLabel)) {
|
||||
categories.push(globals.unassignedCategoryLabel);
|
||||
}
|
||||
ctor = initialValue.constructor;
|
||||
newSchema = {
|
||||
...catDupSchema,
|
||||
name: newCategoryName,
|
||||
categories,
|
||||
writable: true,
|
||||
};
|
||||
} else {
|
||||
/* else assign to the standard default value */
|
||||
initialValue = globals.unassignedCategoryLabel;
|
||||
ctor = Array;
|
||||
newSchema = {
|
||||
name: newCategoryName,
|
||||
type: "categorical",
|
||||
categories: [globals.unassignedCategoryLabel],
|
||||
writable: true,
|
||||
};
|
||||
}
|
||||
|
||||
const obsCrossfilter = prevObsCrossfilter.addObsColumn(
|
||||
newSchema,
|
||||
ctor,
|
||||
initialValue
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: create category",
|
||||
data: newCategoryName,
|
||||
categoryToDuplicate,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationRenameCategoryAction =
|
||||
(oldCategoryName, newCategoryName) => (dispatch, getState) => {
|
||||
/*
|
||||
Rename a user-created annotation category
|
||||
*/
|
||||
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
|
||||
getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, oldCategoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
/* name must be a string, non-zero length */
|
||||
if (typeof newCategoryName !== "string" || newCategoryName.length === 0)
|
||||
throw new Error("user annotations require string name");
|
||||
|
||||
if (oldCategoryName === newCategoryName) return;
|
||||
|
||||
const obsCrossfilter = prevObsCrossfilter.renameObsColumn(
|
||||
oldCategoryName,
|
||||
newCategoryName
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: category edited",
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
metadataField: oldCategoryName,
|
||||
newCategoryText: newCategoryName,
|
||||
data: newCategoryName,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationDeleteCategoryAction =
|
||||
(categoryName) => (dispatch, getState) => {
|
||||
/*
|
||||
Delete a user-created category
|
||||
*/
|
||||
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
|
||||
getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
const obsCrossfilter = prevObsCrossfilter.dropObsColumn(categoryName);
|
||||
dispatch({
|
||||
type: "annotation: delete category",
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
metadataField: categoryName,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationCreateLabelInCategory =
|
||||
(categoryName, labelName, assignSelected) => async (dispatch, getState) => {
|
||||
/*
|
||||
Add a new label to a user-defined category. If assignSelected is true, assign
|
||||
the label to all currently selected cells.
|
||||
*/
|
||||
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
|
||||
getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
let obsCrossfilter = prevObsCrossfilter.addObsAnnoCategory(
|
||||
categoryName,
|
||||
labelName
|
||||
);
|
||||
if (assignSelected) {
|
||||
obsCrossfilter = await obsCrossfilter.setObsColumnValues(
|
||||
categoryName,
|
||||
prevObsCrossfilter.allSelectedLabels(),
|
||||
labelName
|
||||
);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "annotation: add new label to category",
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
metadataField: categoryName,
|
||||
newLabelText: labelName,
|
||||
assignSelectedCells: assignSelected,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationDeleteLabelFromCategory =
|
||||
(categoryName, labelName) => async (dispatch, getState) => {
|
||||
/*
|
||||
delete a label from a user-defined category
|
||||
*/
|
||||
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
|
||||
getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
const obsCrossfilter = await prevObsCrossfilter.removeObsAnnoCategory(
|
||||
categoryName,
|
||||
labelName,
|
||||
globals.unassignedCategoryLabel
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: delete label",
|
||||
metadataField: categoryName,
|
||||
label: labelName,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationRenameLabelInCategory =
|
||||
(categoryName, oldLabelName, newLabelName) => async (dispatch, getState) => {
|
||||
/*
|
||||
label name change
|
||||
*/
|
||||
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
|
||||
getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
let obsCrossfilter = await prevObsCrossfilter.resetObsColumnValues(
|
||||
categoryName,
|
||||
oldLabelName,
|
||||
newLabelName
|
||||
);
|
||||
obsCrossfilter = await obsCrossfilter.removeObsAnnoCategory(
|
||||
categoryName,
|
||||
oldLabelName,
|
||||
globals.unassignedCategoryLabel
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: label edited",
|
||||
editedLabel: newLabelName,
|
||||
metadataField: categoryName,
|
||||
label: oldLabelName,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const annotationLabelCurrentSelection =
|
||||
(categoryName, labelName) => async (dispatch, getState) => {
|
||||
/*
|
||||
set the label on all currently selected
|
||||
*/
|
||||
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
|
||||
getState();
|
||||
if (!prevAnnoMatrix || !prevObsCrossfilter) return;
|
||||
if (!isUserAnnotation(prevAnnoMatrix, categoryName))
|
||||
throw new Error("not a user annotation");
|
||||
|
||||
const obsCrossfilter = await prevObsCrossfilter.setObsColumnValues(
|
||||
categoryName,
|
||||
prevObsCrossfilter.allSelectedLabels(),
|
||||
labelName
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "annotation: label current cell selection",
|
||||
metadataField: categoryName,
|
||||
label: labelName,
|
||||
obsCrossfilter,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
});
|
||||
};
|
||||
|
||||
function writableAnnotations(annoMatrix) {
|
||||
return annoMatrix.schema.annotations.obs.columns
|
||||
.filter((s) => s.writable)
|
||||
.map((s) => s.name);
|
||||
}
|
||||
|
||||
export const needToSaveObsAnnotations = (annoMatrix, lastSavedAnnoMatrix) => {
|
||||
/*
|
||||
Return true if there are LIKELY user-defined annotation modifications between the two
|
||||
annoMatrices. Technically not an action creator, but intimately intertwined
|
||||
with the save process.
|
||||
|
||||
Two conditions will trigger a need to save:
|
||||
* the collection of user-defined columns have changed
|
||||
* the contents of the user-defined columns have change
|
||||
*/
|
||||
|
||||
annoMatrix = annoMatrix.base();
|
||||
|
||||
// if the annoMatrix hasn't changed, we are guaranteed no changes to the matrix schema or contents.
|
||||
if (annoMatrix === lastSavedAnnoMatrix) return false;
|
||||
|
||||
// if the schema has changed, we need to save
|
||||
const currentWritable = writableAnnotations(annoMatrix);
|
||||
if (difference(currentWritable, writableAnnotations(lastSavedAnnoMatrix))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// no schema changes; check for change in contents
|
||||
return currentWritable.some(
|
||||
(col) => annoMatrix.col(col) !== lastSavedAnnoMatrix.col(col)
|
||||
);
|
||||
};
|
||||
|
||||
export const saveObsAnnotationsAction = () => async (dispatch, getState) => {
|
||||
/*
|
||||
Save the user-created obs annotations IF any have changed.
|
||||
*/
|
||||
const state = getState();
|
||||
const { annotations, autosave } = state;
|
||||
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
|
||||
const { lastSavedAnnoMatrix, saveInProgress } = autosave;
|
||||
|
||||
const annoMatrix = state.annoMatrix.base();
|
||||
|
||||
if (saveInProgress || annoMatrix === lastSavedAnnoMatrix) return;
|
||||
if (!needToSaveObsAnnotations(annoMatrix, lastSavedAnnoMatrix)) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save complete",
|
||||
lastSavedAnnoMatrix: annoMatrix,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
Else, we really do need to save
|
||||
*/
|
||||
|
||||
dispatch({
|
||||
type: "writable obs annotations - save started",
|
||||
});
|
||||
|
||||
const df = await annoMatrix.fetch("obs", writableAnnotations(annoMatrix));
|
||||
const matrix = MatrixFBS.encodeMatrixFBS(df);
|
||||
const compressedMatrix = pako.deflate(matrix);
|
||||
try {
|
||||
const queryString =
|
||||
!dataCollectionNameIsReadOnly && !!dataCollectionName
|
||||
? `?annotation-collection-name=${encodeURIComponent(
|
||||
dataCollectionName
|
||||
)}`
|
||||
: "";
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}annotations/obs${queryString}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: compressedMatrix,
|
||||
headers: new Headers({
|
||||
"Content-Type": "application/octet-stream",
|
||||
}),
|
||||
credentials: "include",
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save complete",
|
||||
lastSavedAnnoMatrix: annoMatrix,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save error",
|
||||
message: `HTTP error ${res.status} - ${res.statusText}`,
|
||||
res,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "writable obs annotations - save error",
|
||||
message: error.toString(),
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const saveGenesetsAction = () => async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
// bail if gene sets not available, or in readonly mode.
|
||||
const { config } = state;
|
||||
const { lastTid, genesets } = state.genesets;
|
||||
|
||||
const genesetsAreAvailable =
|
||||
config?.parameters?.annotations_genesets ?? false;
|
||||
const genesetsReadonly =
|
||||
config?.parameters?.annotations_genesets_readonly ?? true;
|
||||
if (!genesetsAreAvailable || genesetsReadonly) {
|
||||
// our non-save was completed!
|
||||
return dispatch({
|
||||
type: "autosave: genesets complete",
|
||||
lastSavedGenesets: genesets,
|
||||
});
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "autosave: genesets started",
|
||||
});
|
||||
|
||||
/* Create the JSON OTA data structure */
|
||||
const tid = (lastTid ?? 0) + 1;
|
||||
const genesetsOTA = [];
|
||||
for (const [name, gs] of genesets) {
|
||||
const genes = [];
|
||||
for (const g of gs.genes.values()) {
|
||||
genes.push({
|
||||
gene_symbol: g.geneSymbol,
|
||||
gene_description: g.geneDescription,
|
||||
});
|
||||
}
|
||||
genesetsOTA.push({
|
||||
geneset_name: name,
|
||||
geneset_description: gs.genesetDescription,
|
||||
genes,
|
||||
});
|
||||
}
|
||||
const ota = {
|
||||
tid,
|
||||
genesets: genesetsOTA,
|
||||
};
|
||||
|
||||
/* Save to server */
|
||||
try {
|
||||
const { dataCollectionNameIsReadOnly, dataCollectionName } =
|
||||
state.annotations;
|
||||
const queryString =
|
||||
!dataCollectionNameIsReadOnly && !!dataCollectionName
|
||||
? `?annotation-collection-name=${encodeURIComponent(
|
||||
dataCollectionName
|
||||
)}`
|
||||
: "";
|
||||
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}genesets${queryString}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify(ota),
|
||||
credentials: "include",
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
return dispatch({
|
||||
type: "autosave: genesets error",
|
||||
message: `HTTP error ${res.status} - ${res.statusText}`,
|
||||
res,
|
||||
});
|
||||
}
|
||||
return Promise.all([
|
||||
dispatch({
|
||||
type: "autosave: genesets complete",
|
||||
lastSavedGenesets: genesets,
|
||||
}),
|
||||
dispatch({
|
||||
type: "geneset: set tid",
|
||||
tid,
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
return dispatch({
|
||||
type: "autosave: genesets error",
|
||||
message: error.toString(),
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
action creators related to embeddings choice
|
||||
*/
|
||||
|
||||
import { AnnoMatrixObsCrossfilter } from "../annoMatrix";
|
||||
import { _setEmbeddingSubset } from "../util/stateManager/viewStackHelpers";
|
||||
|
||||
export async function _switchEmbedding(
|
||||
prevAnnoMatrix,
|
||||
prevCrossfilter,
|
||||
newEmbeddingName
|
||||
) {
|
||||
/*
|
||||
DRY helper used by embedding action creators
|
||||
*/
|
||||
const base = prevAnnoMatrix.base();
|
||||
const embeddingDf = await base.fetch("emb", newEmbeddingName);
|
||||
const annoMatrix = _setEmbeddingSubset(prevAnnoMatrix, embeddingDf);
|
||||
const obsCrossfilter = await new AnnoMatrixObsCrossfilter(
|
||||
annoMatrix,
|
||||
prevCrossfilter.obsCrossfilter
|
||||
).select("emb", newEmbeddingName, {
|
||||
mode: "all",
|
||||
});
|
||||
return [annoMatrix, obsCrossfilter];
|
||||
}
|
||||
|
||||
export const layoutChoiceAction =
|
||||
(newLayoutChoice) => async (dispatch, getState) => {
|
||||
/*
|
||||
On layout choice, make sure we have selected all on the previous layout, AND the new
|
||||
layout.
|
||||
*/
|
||||
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevCrossfilter } =
|
||||
getState();
|
||||
const [annoMatrix, obsCrossfilter] = await _switchEmbedding(
|
||||
prevAnnoMatrix,
|
||||
prevCrossfilter,
|
||||
newLayoutChoice
|
||||
);
|
||||
dispatch({
|
||||
type: "set layout choice",
|
||||
layoutChoice: newLayoutChoice,
|
||||
obsCrossfilter,
|
||||
annoMatrix,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import { postUserErrorToast } from "../components/framework/toasters";
|
||||
/*
|
||||
Action creators for gene sets
|
||||
|
||||
Primarily used to keep the crossfilter and underlying data in sync with the UI.
|
||||
|
||||
The behavior manifest in these action creators:
|
||||
|
||||
Delete a gene set, will
|
||||
* drop index & clear selection state on the gene set summary
|
||||
* drop index & clear selection state of each gene in the geneset
|
||||
|
||||
Delete a gene from a gene set, will:
|
||||
* drop index & clear selection state on the gene set summary
|
||||
* drop index & clear selection state on the gene
|
||||
|
||||
Add a gene to a gene set, will:
|
||||
* drop index & clear selection state on the gene set summary
|
||||
* will NOT touch the selection state for the gene
|
||||
|
||||
Note that crossfilter indices are lazy created, as needed.
|
||||
*/
|
||||
|
||||
export const genesetDelete = (genesetName) => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const { genesets } = state;
|
||||
const gs = genesets?.genesets?.get(genesetName) ?? {};
|
||||
const geneSymbols = Array.from(gs.genes.keys());
|
||||
const obsCrossfilter = dropGeneset(dispatch, state, genesetName, geneSymbols);
|
||||
if (genesetName === state.colors.colorAccessor) {
|
||||
dispatch({
|
||||
type: "reset colorscale",
|
||||
});
|
||||
}
|
||||
dispatch({
|
||||
type: "geneset: delete",
|
||||
genesetName,
|
||||
obsCrossfilter,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
});
|
||||
};
|
||||
|
||||
export const genesetAddGenes =
|
||||
(genesetName, genes) => async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const { obsCrossfilter: prevObsCrossfilter, annoMatrix } = state;
|
||||
const { schema } = annoMatrix;
|
||||
const varIndex = schema.annotations.var.index;
|
||||
const df = await annoMatrix.fetch("var", varIndex);
|
||||
const geneNames = df.col(varIndex).asArray();
|
||||
genes = genes.reduce((acc, gene) => {
|
||||
if (geneNames.indexOf(gene.geneSymbol) === -1) {
|
||||
postUserErrorToast(
|
||||
`${gene.geneSymbol} doesn't appear to be a valid gene name.`
|
||||
);
|
||||
} else acc.push(gene);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const obsCrossfilter = dropGenesetSummaryDimension(
|
||||
prevObsCrossfilter,
|
||||
state,
|
||||
genesetName
|
||||
);
|
||||
dispatch({
|
||||
type: "continuous metadata histogram cancel",
|
||||
continuousNamespace: { isGeneSetSummary: true },
|
||||
selection: genesetName,
|
||||
});
|
||||
return dispatch({
|
||||
type: "geneset: add genes",
|
||||
genesetName,
|
||||
genes,
|
||||
obsCrossfilter,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
});
|
||||
};
|
||||
|
||||
export const genesetDeleteGenes =
|
||||
(genesetName, geneSymbols) => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const obsCrossfilter = dropGeneset(
|
||||
dispatch,
|
||||
state,
|
||||
genesetName,
|
||||
geneSymbols
|
||||
);
|
||||
return dispatch({
|
||||
type: "geneset: delete genes",
|
||||
genesetName,
|
||||
geneSymbols,
|
||||
obsCrossfilter,
|
||||
annoMatrix: obsCrossfilter.annoMatrix,
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Private
|
||||
*/
|
||||
|
||||
function dropGenesetSummaryDimension(obsCrossfilter, state, genesetName) {
|
||||
const { annoMatrix, genesets } = state;
|
||||
const varIndex = annoMatrix.schema.annotations?.var?.index;
|
||||
const gs = genesets?.genesets?.get(genesetName) ?? {};
|
||||
const genes = Array.from(gs.genes.keys());
|
||||
const query = {
|
||||
summarize: {
|
||||
method: "mean",
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
values: genes,
|
||||
},
|
||||
};
|
||||
return obsCrossfilter.dropDimension("X", query);
|
||||
}
|
||||
|
||||
function dropGeneDimension(obsCrossfilter, state, gene) {
|
||||
const { annoMatrix } = state;
|
||||
const varIndex = annoMatrix.schema.annotations?.var?.index;
|
||||
const query = {
|
||||
where: {
|
||||
field: "var",
|
||||
column: varIndex,
|
||||
value: gene,
|
||||
},
|
||||
};
|
||||
return obsCrossfilter.dropDimension("X", query);
|
||||
}
|
||||
|
||||
function dropGeneset(dispatch, state, genesetName, geneSymbols) {
|
||||
const { obsCrossfilter: prevObsCrossfilter } = state;
|
||||
const obsCrossfilter = geneSymbols.reduce(
|
||||
(crossfilter, gene) => dropGeneDimension(crossfilter, state, gene),
|
||||
dropGenesetSummaryDimension(prevObsCrossfilter, state, genesetName)
|
||||
);
|
||||
dispatch({
|
||||
type: "continuous metadata histogram cancel",
|
||||
continuousNamespace: { isGeneSetSummary: true },
|
||||
selection: genesetName,
|
||||
});
|
||||
geneSymbols.forEach((g) =>
|
||||
dispatch({
|
||||
type: "continuous metadata histogram cancel",
|
||||
continuousNamespace: { isUserDefined: true },
|
||||
selection: g,
|
||||
})
|
||||
);
|
||||
return obsCrossfilter;
|
||||
}
|
||||
+215
-254
@@ -1,196 +1,141 @@
|
||||
// jshint esversion: 6
|
||||
import _ from "lodash";
|
||||
import * as globals from "../globals";
|
||||
import { Universe, kvCache } from "../util/stateManager";
|
||||
import { AnnoMatrixLoader, AnnoMatrixObsCrossfilter } from "../annoMatrix";
|
||||
import {
|
||||
catchErrorsWrap,
|
||||
doJsonRequest,
|
||||
rangeEncodeIndices,
|
||||
dispatchNetworkErrorMessageToUser
|
||||
dispatchNetworkErrorMessageToUser,
|
||||
} from "../util/actionHelpers";
|
||||
import { loadUserColorConfig } from "../util/stateManager/colorHelpers";
|
||||
import * as selnActions from "./selection";
|
||||
import * as annoActions from "./annotation";
|
||||
import * as viewActions from "./viewStack";
|
||||
import * as embActions from "./embedding";
|
||||
import * as genesetActions from "./geneset";
|
||||
|
||||
function setGlobalConfig(config) {
|
||||
/**
|
||||
* Set any global run-time config not _exclusively_ managed by the config reducer.
|
||||
* This should only set fields defined in globals.globalConfig.
|
||||
*/
|
||||
globals.globalConfig.maxCategoricalOptionsToDisplay =
|
||||
config?.parameters?.["max-category-items"] ??
|
||||
globals.globalConfig.maxCategoricalOptionsToDisplay;
|
||||
}
|
||||
|
||||
/*
|
||||
Bootstrap application with the initial data loading.
|
||||
* /config - application configuration
|
||||
* /schema - schema of dataframe
|
||||
* /annotations/obs - all metadata annotation
|
||||
return promise fetching user-configured colors
|
||||
*/
|
||||
async function userColorsFetchAndLoad(dispatch) {
|
||||
return fetchJson("colors").then((response) =>
|
||||
dispatch({
|
||||
type: "universe: user color load success",
|
||||
userColors: loadUserColorConfig(response),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function schemaFetch() {
|
||||
return fetchJson("schema");
|
||||
}
|
||||
|
||||
async function configFetch(dispatch) {
|
||||
return fetchJson("config").then((response) => {
|
||||
const config = { ...globals.configDefaults, ...response.config };
|
||||
|
||||
setGlobalConfig(config);
|
||||
|
||||
dispatch({
|
||||
type: "configuration load complete",
|
||||
config,
|
||||
});
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
async function genesetsFetch(dispatch, config) {
|
||||
/* request genesets ONLY if the backend supports the feature */
|
||||
const defaultResponse = {
|
||||
genesets: [],
|
||||
tid: 0,
|
||||
};
|
||||
if (config?.parameters?.annotations_genesets ?? false) {
|
||||
fetchJson("genesets").then((response) => {
|
||||
dispatch({
|
||||
type: "geneset: initial load",
|
||||
data: response ?? defaultResponse,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "geneset: initial load",
|
||||
data: defaultResponse,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function prefetchEmbeddings(annoMatrix) {
|
||||
/*
|
||||
prefetch requests for all embeddings
|
||||
*/
|
||||
const { schema } = annoMatrix;
|
||||
const available = schema.layout.obs.map((v) => v.name);
|
||||
available.forEach((embName) => annoMatrix.prefetch("emb", embName));
|
||||
}
|
||||
|
||||
/*
|
||||
Application bootstrap
|
||||
*/
|
||||
const doInitialDataLoad = () =>
|
||||
catchErrorsWrap(async dispatch => {
|
||||
catchErrorsWrap(async (dispatch) => {
|
||||
dispatch({ type: "initial data load start" });
|
||||
|
||||
try {
|
||||
const requests = _([
|
||||
"config",
|
||||
"schema",
|
||||
"annotations/obs",
|
||||
"annotations/var",
|
||||
"layout/obs"
|
||||
])
|
||||
.map(r => `${globals.API.prefix}${globals.API.version}${r}`)
|
||||
.map(url => doJsonRequest(url))
|
||||
.value();
|
||||
const results = await Promise.all(requests);
|
||||
const [config, schema] = await Promise.all([
|
||||
configFetch(dispatch),
|
||||
schemaFetch(dispatch),
|
||||
userColorsFetchAndLoad(dispatch),
|
||||
]);
|
||||
|
||||
/* set config defaults */
|
||||
const config = { ...globals.configDefaults, ...results[0].config };
|
||||
const [, schema, obsAnno, varAnno, obsLayout] = [...results];
|
||||
const universe = Universe.createUniverseFromRestV02Response(
|
||||
config,
|
||||
schema,
|
||||
obsAnno,
|
||||
varAnno,
|
||||
obsLayout
|
||||
);
|
||||
genesetsFetch(dispatch, config);
|
||||
|
||||
const baseDataUrl = `${globals.API.prefix}${globals.API.version}`;
|
||||
const annoMatrix = new AnnoMatrixLoader(baseDataUrl, schema.schema);
|
||||
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||
prefetchEmbeddings(annoMatrix);
|
||||
|
||||
dispatch({
|
||||
type: "configuration load complete",
|
||||
config
|
||||
});
|
||||
dispatch({
|
||||
type: "initial data load complete (universe exists)",
|
||||
universe
|
||||
type: "annoMatrix: init complete",
|
||||
annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
dispatch({ type: "initial data load complete" });
|
||||
|
||||
const defaultEmbedding = config?.parameters?.default_embedding;
|
||||
const layoutSchema = schema?.schema?.layout?.obs ?? [];
|
||||
if (
|
||||
defaultEmbedding &&
|
||||
layoutSchema.some((s) => s.name === defaultEmbedding)
|
||||
) {
|
||||
dispatch(embActions.layoutChoiceAction(defaultEmbedding));
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch({ type: "initial data load error", error });
|
||||
}
|
||||
}, true);
|
||||
|
||||
/*
|
||||
Set the view (world) to current selection. Placeholder for an async action
|
||||
which also does re-layout.
|
||||
*/
|
||||
const regraph = () => (dispatch, getState) => {
|
||||
const { universe, world, crossfilter } = getState().controls;
|
||||
dispatch({
|
||||
type: "set World to current selection",
|
||||
universe,
|
||||
world,
|
||||
crossfilter
|
||||
});
|
||||
};
|
||||
|
||||
// Throws
|
||||
const dispatchExpressionErrors = (dispatch, res) => {
|
||||
const msg = `Unexpected HTTP response while fetching expression data ${
|
||||
res.status
|
||||
}, ${res.statusText}`;
|
||||
dispatchNetworkErrorMessageToUser(msg);
|
||||
throw new Error(msg);
|
||||
};
|
||||
|
||||
/*
|
||||
Fetch expression vectors for each gene in genes. This is NOT an action
|
||||
function, but rather a helper to be called from an action helper that
|
||||
needs expression data.
|
||||
|
||||
Transparently utilizes cached data if it is already present.
|
||||
*/
|
||||
async function _doRequestExpressionData(dispatch, getState, genes) {
|
||||
const state = getState();
|
||||
const { universe } = state.controls;
|
||||
/* preload data already in cache */
|
||||
let expressionData = _.transform(
|
||||
genes,
|
||||
(expData, g) => {
|
||||
const data = kvCache.get(universe.varDataCache, g);
|
||||
if (data) {
|
||||
expData[g] = data;
|
||||
}
|
||||
},
|
||||
{}
|
||||
); // --> { gene: data }
|
||||
/* make a list of genes for which we do not have data */
|
||||
const genesToFetch = _.filter(genes, g => expressionData[g] === undefined);
|
||||
|
||||
dispatch({ type: "expression load start" });
|
||||
|
||||
/* Fetch data for any genes not in cache */
|
||||
if (genesToFetch.length) {
|
||||
try {
|
||||
// XXX: TODO - this could be using /data/var rather than /data/obs,
|
||||
// as that would simplify the transformation in convertExpressionRESTv02ToObject
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}data/obs`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
filter: {
|
||||
var: {
|
||||
annotation_value: [{ name: "name", values: genesToFetch }]
|
||||
}
|
||||
}
|
||||
}),
|
||||
headers: new Headers({
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok || res.headers.get("Content-Type") !== "application/json") {
|
||||
// WILL throw
|
||||
return dispatchExpressionErrors(dispatch, res);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
expressionData = {
|
||||
...expressionData,
|
||||
...Universe.convertExpressionRESTv02ToObject(universe, data)
|
||||
};
|
||||
} catch (error) {
|
||||
dispatch({ type: "expression load error", error });
|
||||
throw error; // rethrow
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({ type: "expression load success", expressionData });
|
||||
return expressionData;
|
||||
}
|
||||
|
||||
function requestSingleGeneExpressionCountsForColoringPOST(gene) {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch({ type: "get single gene expression for coloring started" });
|
||||
try {
|
||||
await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||
const { world } = getState().controls;
|
||||
dispatch({
|
||||
type: "color by expression",
|
||||
gene,
|
||||
data: {
|
||||
[gene]: kvCache.get(world.varDataCache, gene)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "get single gene expression for coloring error",
|
||||
error
|
||||
});
|
||||
}
|
||||
return {
|
||||
type: "color by expression",
|
||||
gene,
|
||||
};
|
||||
}
|
||||
|
||||
const requestUserDefinedGene = gene => async (dispatch, getState) => {
|
||||
dispatch({ type: "request user defined gene started" });
|
||||
try {
|
||||
await await _doRequestExpressionData(dispatch, getState, [gene]);
|
||||
const { world } = getState().controls;
|
||||
|
||||
/* then send the success case action through */
|
||||
return dispatch({
|
||||
type: "request user defined gene success",
|
||||
data: {
|
||||
genes: [gene],
|
||||
expression: kvCache.get(world.varDataCache, gene)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return dispatch({
|
||||
type: "request user defined gene error",
|
||||
error
|
||||
});
|
||||
}
|
||||
};
|
||||
const requestUserDefinedGene = (gene) => ({
|
||||
type: "request user defined gene success",
|
||||
data: {
|
||||
genes: [gene],
|
||||
},
|
||||
});
|
||||
|
||||
const dispatchDiffExpErrors = (dispatch, response) => {
|
||||
switch (response.status) {
|
||||
@@ -205,110 +150,126 @@ const dispatchDiffExpErrors = (dispatch, response) => {
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
const msg = `Unexpected differential expression HTTP response ${
|
||||
response.status
|
||||
}, ${response.statusText}`;
|
||||
const msg = `Unexpected differential expression HTTP response ${response.status}, ${response.statusText}`;
|
||||
dispatchNetworkErrorMessageToUser(msg);
|
||||
dispatch({
|
||||
type: "request differential expression error",
|
||||
error: new Error(msg)
|
||||
error: new Error(msg),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
|
||||
dispatch,
|
||||
getState
|
||||
) => {
|
||||
dispatch({ type: "request differential expression started" });
|
||||
try {
|
||||
/*
|
||||
const requestDifferentialExpression =
|
||||
(set1, set2, num_genes = 50) =>
|
||||
async (dispatch, getState) => {
|
||||
dispatch({ type: "request differential expression started" });
|
||||
try {
|
||||
/*
|
||||
Steps:
|
||||
1. get the most differentially expressed genes
|
||||
2. get expression data for each
|
||||
*/
|
||||
const state = getState();
|
||||
const { universe } = state.controls;
|
||||
const set1ByIndex = rangeEncodeIndices(
|
||||
_.map(set1, s => universe.obsNameToIndexMap[s])
|
||||
);
|
||||
const set2ByIndex = rangeEncodeIndices(
|
||||
_.map(set2, s => universe.obsNameToIndexMap[s])
|
||||
);
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}diffexp/obs`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
mode: "topN",
|
||||
count: num_genes,
|
||||
set1: { filter: { obs: { index: set1ByIndex } } },
|
||||
set2: { filter: { obs: { index: set2ByIndex } } }
|
||||
})
|
||||
const { annoMatrix } = getState();
|
||||
const varIndexName = annoMatrix.schema.annotations.var.index;
|
||||
|
||||
// Legal values are null, Array or TypedArray. Null is initial state.
|
||||
if (!set1) set1 = [];
|
||||
if (!set2) set2 = [];
|
||||
|
||||
// These lines ensure that we convert any TypedArray to an Array.
|
||||
// This is necessary because JSON.stringify() does some very strange
|
||||
// things with TypedArrays (they are marshalled to JSON objects, rather
|
||||
// than being marshalled as a JSON array).
|
||||
set1 = Array.isArray(set1) ? set1 : Array.from(set1);
|
||||
set2 = Array.isArray(set2) ? set2 : Array.from(set2);
|
||||
|
||||
const res = await fetch(
|
||||
`${globals.API.prefix}${globals.API.version}diffexp/obs`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
mode: "topN",
|
||||
count: num_genes,
|
||||
set1: { filter: { obs: { index: set1 } } },
|
||||
set2: { filter: { obs: { index: set2 } } },
|
||||
}),
|
||||
credentials: "include",
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok || res.headers.get("Content-Type") !== "application/json") {
|
||||
return dispatchDiffExpErrors(dispatch, res);
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok || res.headers.get("Content-Type") !== "application/json") {
|
||||
return dispatchDiffExpErrors(dispatch, res);
|
||||
const response = await res.json();
|
||||
const varIndex = await annoMatrix.fetch("var", varIndexName);
|
||||
const diffexpLists = { negative: [], positive: [] };
|
||||
for (const polarity of Object.keys(diffexpLists)) {
|
||||
diffexpLists[polarity] = response[polarity].map((v) => [
|
||||
varIndex.at(v[0], varIndexName),
|
||||
...v.slice(1),
|
||||
]);
|
||||
}
|
||||
|
||||
/* then send the success case action through */
|
||||
return dispatch({
|
||||
type: "request differential expression success",
|
||||
data: diffexpLists,
|
||||
});
|
||||
} catch (error) {
|
||||
return dispatch({
|
||||
type: "request differential expression error",
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const data = await res.json();
|
||||
// result is [ [varIdx, ...], ... ]
|
||||
const topNGenes = _.map(data, r => universe.varAnnotations[r[0]].name);
|
||||
|
||||
/*
|
||||
Kick off secondary action to fetch all of the expression data for the
|
||||
topN expressed genes.
|
||||
*/
|
||||
await _doRequestExpressionData(dispatch, getState, topNGenes);
|
||||
|
||||
/* then send the success case action through */
|
||||
return dispatch({
|
||||
type: "request differential expression success",
|
||||
data
|
||||
});
|
||||
} catch (error) {
|
||||
return dispatch({
|
||||
type: "request differential expression error",
|
||||
error
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resetInterface = () => (dispatch, getState) => {
|
||||
const { universe } = getState().controls;
|
||||
|
||||
dispatch({
|
||||
type: "clear all user defined genes"
|
||||
});
|
||||
dispatch({
|
||||
type: "clear differential expression"
|
||||
});
|
||||
dispatch({
|
||||
type: "reset colorscale"
|
||||
});
|
||||
dispatch({
|
||||
type: "clear scatterplot"
|
||||
});
|
||||
dispatch({
|
||||
type: "reset World to eq Universe",
|
||||
universe
|
||||
});
|
||||
dispatch({
|
||||
type: "increment graph render counter"
|
||||
});
|
||||
};
|
||||
function fetchJson(pathAndQuery) {
|
||||
return doJsonRequest(
|
||||
`${globals.API.prefix}${globals.API.version}${pathAndQuery}`
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
regraph,
|
||||
resetInterface,
|
||||
requestSingleGeneExpressionCountsForColoringPOST,
|
||||
doInitialDataLoad,
|
||||
requestDifferentialExpression,
|
||||
requestSingleGeneExpressionCountsForColoringPOST,
|
||||
requestUserDefinedGene,
|
||||
doInitialDataLoad
|
||||
selectContinuousMetadataAction: selnActions.selectContinuousMetadataAction,
|
||||
selectCategoricalMetadataAction: selnActions.selectCategoricalMetadataAction,
|
||||
selectCategoricalAllMetadataAction:
|
||||
selnActions.selectCategoricalAllMetadataAction,
|
||||
graphBrushStartAction: selnActions.graphBrushStartAction,
|
||||
graphBrushChangeAction: selnActions.graphBrushChangeAction,
|
||||
graphBrushDeselectAction: selnActions.graphBrushDeselectAction,
|
||||
graphBrushCancelAction: selnActions.graphBrushCancelAction,
|
||||
graphBrushEndAction: selnActions.graphBrushEndAction,
|
||||
graphLassoStartAction: selnActions.graphLassoStartAction,
|
||||
graphLassoEndAction: selnActions.graphLassoEndAction,
|
||||
graphLassoCancelAction: selnActions.graphLassoCancelAction,
|
||||
graphLassoDeselectAction: selnActions.graphLassoDeselectAction,
|
||||
clipAction: viewActions.clipAction,
|
||||
subsetAction: viewActions.subsetAction,
|
||||
resetSubsetAction: viewActions.resetSubsetAction,
|
||||
annotationCreateCategoryAction: annoActions.annotationCreateCategoryAction,
|
||||
annotationRenameCategoryAction: annoActions.annotationRenameCategoryAction,
|
||||
annotationDeleteCategoryAction: annoActions.annotationDeleteCategoryAction,
|
||||
annotationCreateLabelInCategory: annoActions.annotationCreateLabelInCategory,
|
||||
annotationDeleteLabelFromCategory:
|
||||
annoActions.annotationDeleteLabelFromCategory,
|
||||
annotationRenameLabelInCategory: annoActions.annotationRenameLabelInCategory,
|
||||
annotationLabelCurrentSelection: annoActions.annotationLabelCurrentSelection,
|
||||
saveObsAnnotationsAction: annoActions.saveObsAnnotationsAction,
|
||||
saveGenesetsAction: annoActions.saveGenesetsAction,
|
||||
needToSaveObsAnnotations: annoActions.needToSaveObsAnnotations,
|
||||
layoutChoiceAction: embActions.layoutChoiceAction,
|
||||
setCellSetFromSelection: selnActions.setCellSetFromSelection,
|
||||
genesetDelete: genesetActions.genesetDelete,
|
||||
genesetAddGenes: genesetActions.genesetAddGenes,
|
||||
genesetDeleteGenes: genesetActions.genesetDeleteGenes,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
Action creators for selection
|
||||
*/
|
||||
export const selectContinuousMetadataAction =
|
||||
(type, query, range, oldProps = {}) =>
|
||||
async (dispatch, getState) => {
|
||||
const { obsCrossfilter: prevObsCrossfilter } = getState();
|
||||
|
||||
const selection = range
|
||||
? {
|
||||
mode: "range",
|
||||
lo: range[0],
|
||||
hi: range[1],
|
||||
inclusive: true, // [lo, hi] incluisve selection
|
||||
}
|
||||
: { mode: "all" };
|
||||
|
||||
const obsCrossfilter = await prevObsCrossfilter.select(...query, selection);
|
||||
|
||||
dispatch({
|
||||
type,
|
||||
obsCrossfilter,
|
||||
range,
|
||||
...oldProps,
|
||||
});
|
||||
};
|
||||
|
||||
export const selectCategoricalMetadataAction =
|
||||
(
|
||||
type, // action type
|
||||
metadataField, // annotation category name
|
||||
labels,
|
||||
label, // the label being selected/deselected
|
||||
isSelected, // bool
|
||||
oldProps = {}
|
||||
) =>
|
||||
async (dispatch, getState) => {
|
||||
const { obsCrossfilter: prevObsCrossfilter, categoricalSelection } =
|
||||
getState();
|
||||
|
||||
const labelSelectionState = new Map(categoricalSelection[metadataField]);
|
||||
labels.forEach(
|
||||
(l) => labelSelectionState.has(l) || labelSelectionState.set(l, true)
|
||||
);
|
||||
labelSelectionState.set(label, isSelected);
|
||||
|
||||
const values = Array.from(labelSelectionState.keys()).filter((k) =>
|
||||
labelSelectionState.get(k)
|
||||
);
|
||||
const selection = {
|
||||
mode: "exact",
|
||||
values,
|
||||
};
|
||||
const obsCrossfilter = await prevObsCrossfilter.select(
|
||||
"obs",
|
||||
metadataField,
|
||||
selection
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type,
|
||||
obsCrossfilter,
|
||||
metadataField,
|
||||
labelSelectionState,
|
||||
...oldProps,
|
||||
});
|
||||
};
|
||||
|
||||
export const selectCategoricalAllMetadataAction =
|
||||
(
|
||||
type, // action type
|
||||
metadataField, // annotation category name
|
||||
labels,
|
||||
isSelected, // bool, select all or none
|
||||
oldProps = {}
|
||||
) =>
|
||||
async (dispatch, getState) => {
|
||||
const { obsCrossfilter: prevObsCrossfilter, categoricalSelection } =
|
||||
getState();
|
||||
|
||||
const labelSelectionState = new Map(categoricalSelection[metadataField]);
|
||||
labels.forEach((label) => labelSelectionState.set(label, isSelected));
|
||||
|
||||
const selection = { mode: isSelected ? "all" : "none" };
|
||||
const obsCrossfilter = await prevObsCrossfilter.select(
|
||||
"obs",
|
||||
metadataField,
|
||||
selection
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type,
|
||||
obsCrossfilter,
|
||||
metadataField,
|
||||
labelSelectionState,
|
||||
...oldProps,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
** Graph selection-related actions
|
||||
**/
|
||||
|
||||
export const graphBrushStartAction = () =>
|
||||
/* no change to crossfilter until a change fires */
|
||||
({ type: "graph brush start" });
|
||||
|
||||
const _graphBrushWithinRectAction =
|
||||
(type, embName, brushCoords) => async (dispatch, getState) => {
|
||||
const { obsCrossfilter: prevObsCrossfilter } = getState();
|
||||
|
||||
const selection = { mode: "within-rect", ...brushCoords };
|
||||
const obsCrossfilter = await prevObsCrossfilter.select(
|
||||
"emb",
|
||||
embName,
|
||||
selection
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type,
|
||||
obsCrossfilter,
|
||||
brushCoords,
|
||||
});
|
||||
};
|
||||
|
||||
const _graphAllAction = (type, embName) => async (dispatch, getState) => {
|
||||
const { obsCrossfilter: prevObsCrossfilter } = getState();
|
||||
|
||||
const obsCrossfilter = await prevObsCrossfilter.select("emb", embName, {
|
||||
mode: "all",
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const graphBrushChangeAction = (embName, brushCoords) =>
|
||||
_graphBrushWithinRectAction("graph brush change", embName, brushCoords);
|
||||
|
||||
export const graphBrushEndAction = (embName, brushCoords) =>
|
||||
_graphBrushWithinRectAction("graph brush end", embName, brushCoords);
|
||||
|
||||
export const graphBrushCancelAction = (embName) =>
|
||||
_graphAllAction("graph brush cancel", embName);
|
||||
export const graphBrushDeselectAction = (embName) =>
|
||||
_graphAllAction("graph brush deselect", embName);
|
||||
|
||||
export const graphLassoStartAction = () =>
|
||||
/* no change to crossfilter until a change fires */
|
||||
({ type: "graph lasso start" });
|
||||
|
||||
export const graphLassoCancelAction = (embName) =>
|
||||
_graphAllAction("graph lasso cancel", embName);
|
||||
|
||||
export const graphLassoDeselectAction = (embName) =>
|
||||
_graphAllAction("graph lasso cancel", embName);
|
||||
|
||||
export const graphLassoEndAction =
|
||||
(embName, polygon) => async (dispatch, getState) => {
|
||||
const { obsCrossfilter: prevObsCrossfilter } = getState();
|
||||
|
||||
const selection = {
|
||||
mode: "within-polygon",
|
||||
polygon,
|
||||
};
|
||||
const obsCrossfilter = await prevObsCrossfilter.select(
|
||||
"emb",
|
||||
embName,
|
||||
selection
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: "graph lasso end",
|
||||
obsCrossfilter,
|
||||
polygon,
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
Differential expression set selection
|
||||
*/
|
||||
export const setCellSetFromSelection = (cellSetId) => (dispatch, getState) => {
|
||||
const { obsCrossfilter } = getState();
|
||||
const selected = obsCrossfilter.allSelectedLabels();
|
||||
|
||||
dispatch({
|
||||
type: `store current cell selection as differential set ${cellSetId}`,
|
||||
data: selected.length > 0 ? selected : null,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
The following actions manage the view stack for annoMatrix.
|
||||
|
||||
Conventions used and assumed elsewhere in the code base:
|
||||
* there will be zero or one clip view, and it will be the TOP view always.
|
||||
* there will be zero or more subset views
|
||||
|
||||
In other words, in our current use, we do not stack multiple clip views but we do
|
||||
stack multiple subsets.
|
||||
|
||||
If these conventions change, code elsewhere (eg. menubar/clip.js) will need to
|
||||
change as well.
|
||||
*/
|
||||
import { AnnoMatrixObsCrossfilter } from "../annoMatrix";
|
||||
import {
|
||||
_clipAnnoMatrix,
|
||||
_userSubsetAnnoMatrix,
|
||||
_userResetSubsetAnnoMatrix,
|
||||
} from "../util/stateManager/viewStackHelpers";
|
||||
|
||||
export const clipAction = (min, max) => (dispatch, getState) => {
|
||||
/*
|
||||
apply a clip to the current annoMatrix. By convention, the clip
|
||||
view is ALWAYS the top view.
|
||||
*/
|
||||
const { annoMatrix: prevAnnoMatrix } = getState();
|
||||
const annoMatrix = _clipAnnoMatrix(prevAnnoMatrix, min, max);
|
||||
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||
dispatch({
|
||||
type: "set clip quantiles",
|
||||
clipQuantiles: { min, max },
|
||||
annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const subsetAction = () => (dispatch, getState) => {
|
||||
/*
|
||||
Subset the annoMatrix to the current crossfilter selection by pushing a
|
||||
subset view.
|
||||
|
||||
By convention, a clip view is ALWAYS the top view, so if present, pop
|
||||
off and re-apply
|
||||
*/
|
||||
const { annoMatrix: prevAnnoMatrix, obsCrossfilter: prevObsCrossfilter } =
|
||||
getState();
|
||||
const annoMatrix = _userSubsetAnnoMatrix(
|
||||
prevAnnoMatrix,
|
||||
prevObsCrossfilter.allSelectedMask()
|
||||
);
|
||||
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||
dispatch({
|
||||
type: "subset to selection",
|
||||
annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
|
||||
export const resetSubsetAction = () => (dispatch, getState) => {
|
||||
/*
|
||||
Reset the annoMatrix to all data. Because we may have multiple views
|
||||
stacked, we pop them all. By convention, any clip transformation will
|
||||
be the top of the stack, and must be preserved.
|
||||
*/
|
||||
|
||||
const { annoMatrix: prevAnnoMatrix } = getState();
|
||||
const annoMatrix = _userResetSubsetAnnoMatrix(prevAnnoMatrix);
|
||||
const obsCrossfilter = new AnnoMatrixObsCrossfilter(annoMatrix);
|
||||
dispatch({
|
||||
type: "reset subset",
|
||||
annoMatrix,
|
||||
obsCrossfilter,
|
||||
});
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user