Skip to content

Commit 30e9741

Browse files
authored
Offline Comic Caching (#1)
1 parent cca72fd commit 30e9741

27 files changed

+3539
-139
lines changed

.github/workflows/unit-tests.yml

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: 'Unit Tests'
2+
3+
on:
4+
push:
5+
paths:
6+
- '.github/workflows/unit-tests.yml'
7+
- '**/*.js'
8+
9+
jobs:
10+
unit-tests:
11+
name: 'Unit tests'
12+
runs-on: 'ubuntu-20.04'
13+
steps:
14+
- name: 'Clone the repository'
15+
uses: 'actions/checkout@v2'
16+
17+
- name: 'Install dependencies'
18+
run: 'yarn'
19+
20+
- name: 'Run unit tests'
21+
run: 'yarn test'

.prettierrc.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
module.exports = {
1111
arrowParens: 'avoid',
1212
bracketSpacing: true,
13-
printWidth: 100,
13+
printWidth: 119,
1414
quoteProps: 'as-needed',
1515
semi: true,
1616
singleQuote: true,

README.md

+130-12
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,136 @@
11
# iOS Scriptable Scripts
22

3-
- Scriptable app: https://apps.apple.com/app/scriptable/id1405459188
4-
- Scriptable website: https://scriptable.app
3+
- Scriptable app: <https://apps.apple.com/app/scriptable/id1405459188>
4+
- Scriptable website: <https://scriptable.app>
55

66
## Importing a script through iCloud Drive
77

88
1. Save the script as a `.js` file.
9-
- Remember about the necessary comments near the top, similar to the following.
10-
```JavaScript
11-
// Variables used by Scriptable.
12-
// These must be at the very top of the file. Do not edit.
13-
// icon-color: deep-green; icon-glyph: magic;
14-
```
15-
- The file name is the title of the script in the app.
16-
2. Put the file in the `Scriptable` directory in iCloud Drive.
17-
18-
The script should be automatically imported inside of the Scriptable app.
9+
10+
- Optionally, add Scriptable-specific directives at the top, similar to the
11+
following. Scriptable will do it automatically when the appearance will be
12+
adjusted from within the app.
13+
14+
```JavaScript
15+
// Variables used by Scriptable.
16+
// These must be at the very top of the file. Do not edit.
17+
// icon-color: deep-green; icon-glyph: magic;
18+
```
19+
20+
- The file name is the title of the script in visible the app.
21+
22+
1. Put the file in the `Scriptable` directory in iCloud Drive.
23+
24+
The script should be visible inside the Scriptable app immediately after
25+
the directory has been synced.
26+
27+
## Importing modules
28+
29+
[There's a module importing functionality](https://docs.scriptable.app/module)
30+
in the app.
31+
32+
Simply create an `index.js` file in a directory, where the directory name is
33+
the module name. Take this directory structure for instance:
34+
35+
```text
36+
SomeScriptableScript.js
37+
lib/
38+
├─ service/
39+
│ ├─ FooBarService/
40+
│ ├─ index.js
41+
│ ├─ DifferentService/
42+
│ ├─ index.js
43+
```
44+
45+
Then, you import the module like so:
46+
47+
```javascript
48+
// SomeScriptableScript.js
49+
const FooBarService = importModule('lib/service/FooBarService');
50+
```
51+
52+
Relative imports also work:
53+
54+
```javascript
55+
// lib/service/DifferentService/index.js
56+
const FooBarService = importModule('../FooBarService');
57+
```
58+
59+
## Known issues
60+
61+
### Class fields
62+
63+
This doesn't work:
64+
65+
```javascript
66+
// lib/const/FeatureFlag/index.js
67+
class FeatureFlag {
68+
static LOG_MODULE_IMPORTS = true;
69+
}
70+
module.exports = FeatureFlag;
71+
72+
// Other file
73+
const FeatureFlag = importModule('lib/const/FeatureFlag');
74+
console.log(FeatureFlag.LOG_MODULE_IMPORTS); // undefined
75+
```
76+
77+
According to [this website](https://javascript.info/class#class-fields), it's
78+
unsupported in old browsers, which is what we might be dealing with here.
79+
80+
Instead, place 'class constants' in `module.exports` directly:
81+
82+
```javascript
83+
// lib/const/FeatureFlag/index.js
84+
module.exports = {
85+
LOG_MODULE_IMPORTS: true,
86+
};
87+
88+
// Other file
89+
const FeatureFlag = importModule('lib/const/FeatureFlag');
90+
console.log(FeatureFlag.LOG_MODULE_IMPORTS); // true
91+
92+
// Or, more concisely
93+
const { LOG_MODULE_IMPORTS } = importModule('lib/const/FeatureFlag');
94+
console.log(LOG_MODULE_IMPORTS); // true
95+
```
96+
97+
## Unit-testing
98+
99+
```console
100+
yarn install
101+
yarn test
102+
```
103+
104+
### Scriptable's propriety library
105+
106+
Given the nature of Scriptable's propriety library, it's hard to unit test code
107+
that has references to static objects from inside the library.
108+
109+
A workaround is to define global objects around the tests:
110+
111+
```javascript
112+
const Data = (global.Data = jest.fn());
113+
```
114+
115+
Then, used in tests:
116+
117+
```javascript
118+
const Data = (global.Data = jest.fn());
119+
120+
describe('when base64-encoding an image', () => {
121+
it.each([JPG, PNG, 'unsupported'])('should return an empty string when base64 encoding fails', (type) => {
122+
Data.fromJPEG = jest.fn().mockReturnValueOnce(null);
123+
Data.fromPNG = jest.fn().mockReturnValueOnce(null);
124+
125+
expect(ImageUtil.base64EncodeImage(null, type)).toBe('');
126+
127+
expect(Data.fromJPEG).toBeCalledTimes('jpg' === type ? 1 : 0);
128+
expect(Data.fromPNG).toBeCalledTimes('png' === type ? 1 : 0);
129+
});
130+
});
131+
```
132+
133+
Resources on global variables in JavaScript:
134+
135+
- <https://stackoverflow.com/q/6888570/10620237>
136+
- <https://stackoverflow.com/q/500431/10620237>

lib/const/FeatureFlag/index.js

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module.exports = {
2+
3+
LOG_MODULE_IMPORTS: true,
4+
5+
/** Whether to cache the downloaded comics locally for offline reuse. */
6+
XKCD_WIDGET_ENABLE_LOCAL_CACHE: true,
7+
};

lib/const/LocalPath/index.js

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
3+
LOCAL_CACHE_DIRNAME: 'cache',
4+
5+
XKCD_CACHE_FILENAME: 'random-xkcd-widget-cache.json',
6+
};

lib/dto/XkcdComic/index.js

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Info here: https://xkcd.com/json.html
2+
const URL_PREFIX = 'https://xkcd.com/';
3+
const URL_POSTFIX = 'info.0.json';
4+
5+
/**
6+
* POJO for holding comic details.
7+
*/
8+
class XkcdComic {
9+
10+
constructor(image, imageURL, title, comicNumber) {
11+
this.image = image;
12+
this.imageURL = imageURL;
13+
this.title = title;
14+
this.comicNumber = comicNumber;
15+
}
16+
17+
/**
18+
* Builds a xkcd REST API URL for the latest comic.
19+
* @returns {string} REST API URL for the latest comic.
20+
* @public
21+
*/
22+
static getLatestComicRESTURL() {
23+
return URL_PREFIX + URL_POSTFIX;
24+
}
25+
26+
/**
27+
* Builds a xkcd REST API URL for the given comic number.
28+
* @param {number} comicNumber Number (ID) of the xkcd comic.
29+
* @returns {string} REST API URL for the given comic.
30+
* @public
31+
*/
32+
static getComicRESTURL(comicNumber) {
33+
return URL_PREFIX + comicNumber + '/' + URL_POSTFIX;
34+
}
35+
36+
get xkcdURL() {
37+
return URL_PREFIX + this.comicNumber;
38+
}
39+
}
40+
41+
module.exports = {
42+
XkcdComic,
43+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
const { XkcdComic } = require('../../dto/XkcdComic');
2+
const { XkcdComicService } = require('../XkcdComicService');
3+
const LocalPath = require('../../const/LocalPath');
4+
5+
jest.mock('../../dto/XkcdComic');
6+
7+
describe('XkcdComicService', () => {
8+
it('should get random comic', async () => {
9+
const Request = (global.Request = jest.fn(() => Request));
10+
11+
const fileUtil = jest.fn();
12+
const imageUtil = jest.fn();
13+
const numberUtil = jest.fn();
14+
const service = new XkcdComicService(fileUtil, imageUtil, numberUtil);
15+
16+
const randomComicNumber = 1234;
17+
const comicRESTURL = 'https://xkcd.com/1234/info.0.json';
18+
const imageURL = 'https://imgs.xkcd.com/comics/ios_keyboard_2x.png';
19+
const title = 'abcdef';
20+
const image = new Object();
21+
22+
jest.spyOn(service, 'getRandomComicNumber').mockReturnValueOnce(randomComicNumber);
23+
jest.spyOn(XkcdComic, 'getComicRESTURL').mockReturnValueOnce(comicRESTURL);
24+
25+
Request.loadJSON = jest.fn();
26+
Request.loadJSON.mockImplementation(() => {
27+
return new Promise((resolve, reject) => {
28+
resolve({ img: imageURL, title });
29+
});
30+
});
31+
32+
Request.loadImage = jest.fn();
33+
Request.loadImage.mockImplementation(() => {
34+
return new Promise((resolve, reject) => {
35+
resolve(image);
36+
});
37+
});
38+
39+
const result = await service.getRandomComic();
40+
41+
expect(result).toBeInstanceOf(XkcdComic);
42+
expect(Request.loadJSON).toHaveBeenCalled();
43+
expect(Request.loadImage).toHaveBeenCalled();
44+
expect(XkcdComic).toHaveBeenCalledWith(image, imageURL, title, randomComicNumber);
45+
46+
expect(fileUtil).not.toHaveBeenCalled();
47+
expect(imageUtil).not.toHaveBeenCalled();
48+
expect(numberUtil).not.toHaveBeenCalled();
49+
});
50+
51+
it.each([
52+
[true, true],
53+
[true, false],
54+
[false, true],
55+
[false, false],
56+
])('should cache comic', (isDirectory, fileExists) => {
57+
const fileUtil = jest.fn();
58+
const imageUtil = jest.fn();
59+
const numberUtil = jest.fn();
60+
const service = new XkcdComicService(fileUtil, imageUtil, numberUtil);
61+
62+
const documentsDir = 'abcd/efgh';
63+
const cacheDir = documentsDir + '/' + LocalPath.LOCAL_CACHE_DIRNAME;
64+
const cacheFilePath = cacheDir + '/' + LocalPath.XKCD_CACHE_FILENAME;
65+
66+
const FileManager = (global.FileManager = jest.fn(() => FileManager));
67+
68+
const iCloudMock = {
69+
documentsDirectory: jest.fn().mockReturnValueOnce(documentsDir),
70+
joinPath: jest.fn((left, right) => left + '/' + right),
71+
isDirectory: jest.fn().mockReturnValueOnce(isDirectory),
72+
createDirectory: jest.fn(),
73+
fileExists: jest.fn().mockReturnValueOnce(fileExists),
74+
remove: jest.fn(),
75+
writeString: jest.fn(),
76+
};
77+
FileManager.iCloud = jest.fn(() => iCloudMock);
78+
79+
fileUtil.getFileExtension = jest.fn();
80+
fileUtil.getFileExtension.mockReturnValue('jpg');
81+
const base64EncodedImage = 'very-long-base64-string';
82+
imageUtil.base64EncodeImage = jest.fn();
83+
imageUtil.base64EncodeImage.mockReturnValue(base64EncodedImage);
84+
85+
const title = 'foo';
86+
const xkcdURL = 'https://aksjdnkasd.com';
87+
const imageURL = 'https://abc.com/foo.jpg';
88+
const comic = {
89+
title,
90+
xkcdURL,
91+
imageURL,
92+
};
93+
service.cacheComic(comic);
94+
95+
if (isDirectory) {
96+
expect(iCloudMock.createDirectory).not.toHaveBeenCalled();
97+
} else {
98+
expect(iCloudMock.createDirectory).toHaveBeenCalledWith(cacheDir, true);
99+
}
100+
if (fileExists) {
101+
expect(iCloudMock.remove).toHaveBeenCalledWith(cacheFilePath);
102+
} else {
103+
expect(iCloudMock.remove).not.toHaveBeenCalled();
104+
}
105+
106+
expect(iCloudMock.writeString).toHaveBeenCalledWith(
107+
cacheFilePath,
108+
JSON.stringify(
109+
{
110+
title,
111+
xkcdURL,
112+
image: {
113+
url: imageURL,
114+
base64: base64EncodedImage,
115+
},
116+
},
117+
null,
118+
4
119+
)
120+
);
121+
expect(numberUtil).not.toHaveBeenCalled();
122+
});
123+
});

0 commit comments

Comments
 (0)