testing moving the functionality into small modules #5

Open
xananax wants to merge 10 commits from split-into-modules into main
10 changed files with 212 additions and 26 deletions
Showing only changes of commit cde1e58a47 - Show all commits

View File

@ -69,8 +69,15 @@ export const createTrackedResponse = (
const start = readerPromise.resolve;
return Object.assign(successPromise, {
/** @type {Promise<Response>} */
return {
start,
/** @type {Promise<Response>["then"]} */
then: successPromise.then.bind(successPromise),
/** @type {Promise["catch"]} */
catch: successPromise.catch.bind(successPromise),
/** @type {Promise["finally"]} */
finally: successPromise.finally.bind(successPromise),
get response() {
return response;
},
@ -89,5 +96,7 @@ export const createTrackedResponse = (
get isUnknown() {
return success === false && failed === false;
},
});
};
};
export default createTrackedResponse;

View File

@ -24,4 +24,4 @@ export const deferredPromise = () => {
return Object.assign(promise, {resolve, reject});
}
export default DeferredPromise
export default deferredPromise

View File

@ -0,0 +1,25 @@
//@ts-check
import {getReasonableUuid} from "./getReasonableUuid.mjs"
/**
* Returns an id that is guaranteed to not exist in the current loaded page.
* Only usable in the browser, after the page has loaded.
* Using it outside the browser or prior to loading will yield a pseudo-unique id,
* but not guaranteed unique.
* Ids are deterministic, so calling this function in the same order will always return
* the same set of ids.
* @see {uuid}
* @param {string} prefix any prefix you like, it helps discriminate ids
*/
export const getPageUniqueId = (prefix = "") => {
let id = prefix;
let limit = 99999;
while (
typeof document !== "undefined" &&
document.getElementById((id = `${prefix}${getReasonableUuid()}`)) &&
limit-- > 0
);
return id;
};
export default getPageUniqueId

View File

@ -0,0 +1,12 @@
//@ts-check
/**
* A reasonably unique id.
* Deterministic, it will always return the same id for the same call order
*/
export const getReasonableUuid = (() => {
let start = 100000000;
return () => (start++).toString(36);
})();
export default getReasonableUuid

View File

@ -20,11 +20,14 @@ import {
import { getElementByCSSSelector } from "./getElementByCSSSelector.mjs";
import { getElementById } from "./getElementById.mjs";
import { getFirstTitleContent } from "./getFirstTitleContent.mjs";
import { getPageUniqueId } from "./getPageUniqueId.mjs";
import { getReasonableUuid } from "./getReasonableUuid.mjs";
import { identity, awaitedIdentity } from "./identity.mjs";
import { html } from "./html.mjs";
import { isExternalUrl } from "./isExternalUrl.mjs";
import { isLocalHost } from "./isLocalHost.mjs";
import { isNotNull } from "./isNotNull.mjs";
import { makeBoundConsole } from "./makeBoundConsole.mjs";
import { makeEventEmitter } from "./makeEventEmitter.mjs";
import { makeFileLoader, makeFileLoadersTracker } from "./makeFileLoader.mjs";
import { makeFileSizeFetcher } from "./makeFileSizeFetcher.mjs";
@ -39,6 +42,14 @@ import {
onDocumentKeyDown,
onDocumentKey,
} from "./onDocumentKey.mjs";
import {
basename,
filename,
stripExtension,
dirName,
extension,
metadata,
} from "./path.mjs";
import { percentFromProgress } from "./percentFromProgress.mjs";
import { print, makeTemplate } from "./print.mjs";
import {
@ -71,12 +82,15 @@ export {
getElementByCSSSelector,
getElementById,
getFirstTitleContent,
getPageUniqueId,
getReasonableUuid,
html,
identity,
awaitedIdentity,
isExternalUrl,
isLocalHost,
isNotNull,
makeBoundConsole,
makeEventEmitter,
makeFileLoader,
makeFileLoadersTracker,
@ -90,6 +104,12 @@ export {
onDocumentKeyUp,
onDocumentKeyDown,
onDocumentKey,
basename,
filename,
stripExtension,
dirName,
extension,
metadata,
percentFromProgress,
print,
makeTemplate,

View File

@ -0,0 +1,19 @@
//@ts-check
const methods = /** @type {('warn' & keyof typeof console)[]} */(["log", "warn", "error"])
/**
* Returns console methods that can be used standalone (without requiring `console`).
* Optional prefix will prepend every call with the provided prefix
* @param {any} prefix
*/
export const makeBoundConsole = (prefix = "") => {
const [log, warn, error] = methods.map(
(fn) =>
(/** @type {any} */ msg) =>
console[fn](prefix, msg)
);
return { log, warn, error };
};
export default makeBoundConsole

View File

@ -3,8 +3,9 @@
import { createTrackedResponse } from "./createTrackedResponse.mjs";
import { decodeContentLength } from "./decodeContentLength.mjs";
import { retryPromise } from "./retryPromise.mjs";
import { makeFileSizeFetcher } from "./makeFileSizeFetcher.mjs";
import { makeSignal } from "./makeSignal.mjs";
import { fetchContentLength } from "./fetchContentLength.mjs";
import { metadata } from "./path.mjs";
/**
* @typedef {{
@ -14,6 +15,11 @@ import { makeSignal } from "./makeSignal.mjs";
* }} FileLoaderOptions
*/
/**
* @template T
* @typedef {import("./makeSignal.mjs").Signal<T>} Signal<T>
*/
/**
* @typedef {ReturnType<typeof makeFileLoader>} FileLoader
*/
@ -36,24 +42,46 @@ export const makeFileLoader = (
path,
{ total = 0, onProgress, signal } = {}
) => {
const progress = makeSignal({ received: 0, total: 0 });
const progress = makeSignal({ path, received: 0, total: 0 });
/** @type {Signal<Response>} */
const done = makeSignal();
/** @type {Signal<Error>} */
const failed = makeSignal();
if (onProgress) {
progress.connect(onProgress, { signal });
}
const createResponse = () =>
createTrackedResponse(
(received) => progress.emit({ received, total }),
const createResponse = () => {
const response = createTrackedResponse(
(received) => progress.emit({ path, received, total }),
signal
);
response.then(done.emit);
response.catch(failed.emit);
return response;
};
let responsePromise = createResponse();
/**
* Retrieves the file size if `total` was not provided.
*/
const fetchSize = makeFileSizeFetcher(path, total);
const fetchSize = (() => {
/**
* @type {Promise<number> | null}
*/
let totalPromise;
const fetchSize = () =>
(totalPromise = total
? Promise.resolve(total)
: fetchContentLength(path).then(
(fetchedTotal) => (total = fetchedTotal)
));
return fetchSize;
})();
/**
* Fetches a file, dispatching progress events while doing so.
@ -102,9 +130,12 @@ export const makeFileLoader = (
};
return {
...metadata(path),
fetchSize,
load,
unload,
done,
failed,
progress,
get path() {
return path;
@ -112,6 +143,9 @@ export const makeFileLoader = (
get total() {
return total;
},
set total(newTotal) {
total = newTotal;
},
get received() {
return responsePromise.received;
},
@ -145,14 +179,12 @@ export const makeFileLoadersTracker = (files) => {
* Called every time any file's status changes.
* Updates the total and received amounts.
*/
const update = () => {
const update = (props) => {
let _total = 0;
let _received = 0;
files.forEach((thing) => {
_total += thing.total;
_received += thing.received;
console.log(thing.path, thing.received, thing.total);
files.forEach((fileLoader) => {
_total += fileLoader.total;
_received += fileLoader.received;
});
if (total != _total || received != _received) {
total = _total;
@ -161,15 +193,30 @@ export const makeFileLoadersTracker = (files) => {
}
};
files.forEach((loader) => {
loader.then(decrease);
loader.progress.connect(update);
files.forEach((fileLoader) => {
fileLoader.done.connect(decrease);
fileLoader.progress.connect(update);
});
/**
* Runs `fetchSize` on all files to ensure all have a size
*/
const ensureSize = () =>
Promise.all(files.map(({ fetchSize }) => fetchSize()));
const loadAll = () => Promise.all(files.map(({ load }) => load()));
/**
* Loads all files. Loading happens once per file, so if it was called before, this is a
* no-op (for each particular file).
* If a file does not have a total set, then a small header fetch can optionally happen
* to fetch the initial size.
* @param {boolean} [andEnsureSize] set it to `false` to skip fetching sizes for files
* without a specified total
* @returns
*/
const loadAll = (andEnsureSize = true) =>
(andEnsureSize ? ensureSize() : Promise.resolve()).then(() =>
Promise.all(files.map(({ load }) => load()))
);
return { done, progress, ensureSize, loadAll };
};

View File

@ -7,6 +7,16 @@
/**
* @typedef {{signal?: AbortSignal, once?: boolean}} ListenerOptions
*/
/**
* @template T
* @typedef {{
* connect(listener: Listener<T>, options?: ListenerOptions): () => boolean,
* disconnect(listener: Listener<T>): boolean,
* emit(args: T): void
* disable(): void
* }} Signal<T>
*/
/**
* Returns an event emitter for a specific signal
@ -14,7 +24,7 @@
* automatic typing where applicable.
* @template T
* @param {T} [_initial]
* @returns
* @returns {Signal<T>}
*/
export const makeSignal = (_initial) => {
/** @type {Set<Listener<T>>} */

View File

@ -1,14 +1,19 @@
//@ts-check
const pickFirstArg = (fst, ..._none) => JSON.stringify(fst)
/**
* Caches the result of a function.
* The cache is available as `.cache` in case there's a need to clear anything.
* Uses the first parameter as a key by default, you can change this behavior by passing a custom
* hash function.
* @template {(...args: any[]) => any} T
* @param {T} functionToMemoize
* @param {(...args: Parameters<T>) => string|number} [hashFunction]
* @returns
*/
export const memoize = (functionToMemoize) => {
/** @type {Map<Parameters<T>[0], ReturnType<T>>} */
export const memoize = (functionToMemoize, hashFunction = pickFirstArg) => {
/** @type {Map<string|number, ReturnType<T>>} */
const cache = new Map()
/**
*
@ -16,15 +21,14 @@ export const memoize = (functionToMemoize) => {
* @returns {ReturnType<T>}
*/
const memoized = (...args) => {
const key = args[0]
const key = hashFunction(...args)
if(!cache.has(key)){
cache.set(key, functionToMemoize(...args))
}
//@ts-ignore
return cache.get(key)
}
memoized.map = cache
return memoized
return Object.assign(memoized, { cache })
}
export default memoize

40
modules/utils/path.mjs Normal file
View File

@ -0,0 +1,40 @@
/**
*
* @param {string} str
* @param {string} [sep] defaults to `/`
*/
export const filename = (str, sep = '/') => str.slice(str.lastIndexOf(sep) + 1)
/**
* @param {string} str
*/
export const stripExtension = (str) => str.slice(0,str.lastIndexOf('.'))
/**
*
* @param {string} str
* @param {string} [sep] defaults to `/`
*/
export const basename = (str, sep = '/') => stripExtension(filename(str, sep))
/**
* @param {string} str
*/
export const extension = (str) => str.slice(str.lastIndexOf('.') + 1)
/**
* @param {string} str
* @param {string} [sep] defaults to `/`
*/
export const dirName = (str, sep = '/') => str.slice(0, str.lastIndexOf(sep) + 1)
export const metadata = (str, sep = '/') => ({
basename: basename(str, sep),
filename: filename(str, sep),
extension: extension(str),
dirName: dirName(str),
fullPath: str
})
export default { basename, filename, stripExtension, dirName, extension, metadata }