Skip to content

Commit

Permalink
feat(@captn/react): add additional hooks
Browse files Browse the repository at this point in the history
- use-resettable-state
- use-unload
- use-text-to-image
  • Loading branch information
pixelass committed May 6, 2024
1 parent 76fd764 commit 97cf946
Show file tree
Hide file tree
Showing 7 changed files with 430 additions and 0 deletions.
15 changes: 15 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@
"require": "./dist/cjs/use-captain-action/index.js",
"import": "./dist/esm/use-captain-action/index.js"
},
"./use-resettable-state": {
"types": "./dist/types/use-resettable-state/index.d.ts",
"require": "./dist/cjs/use-resettable-state/index.js",
"import": "./dist/esm/use-resettable-state/index.js"
},
"./use-unload": {
"types": "./dist/types/use-unload/index.d.ts",
"require": "./dist/cjs/use-unload/index.js",
"import": "./dist/esm/use-unload/index.js"
},
"./use-text-to-image": {
"types": "./dist/types/use-text-to-image/index.d.ts",
"require": "./dist/cjs/use-text-to-image/index.js",
"import": "./dist/esm/use-text-to-image/index.js"
},
"./use-object": {
"types": "./dist/types/use-object/index.d.ts",
"require": "./dist/cjs/use-object/index.js",
Expand Down
52 changes: 52 additions & 0 deletions packages/react/src/__tests__/use-resettable-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { renderHook, act } from "@testing-library/react";

import { useResettableState } from "../use-resettable-state";

describe("useResettableState", () => {
// Enable fake timers
beforeAll(() => {
jest.useFakeTimers();
});

// Clean up after tests run
afterAll(() => {
jest.useRealTimers();
});

it("should reset state to initial value after specified delay", () => {
// Initial values for the test
const initialValue = 0;
const temporaryValue = 5;
const delay = 1000;

// Render the hook
const { result } = renderHook(() => useResettableState(initialValue, delay));

// Check initial state
expect(result.current[0]).toBe(initialValue);

// Set temporary state
act(() => {
result.current[1](temporaryValue);
});

// Check if the state has changed to the temporary value
expect(result.current[0]).toBe(temporaryValue);

// Fast-forward time to just before the delay time
act(() => {
jest.advanceTimersByTime(delay - 1);
});

// Verify that the state has not yet reset
expect(result.current[0]).toBe(temporaryValue);

// Fast-forward to complete the delay
act(() => {
jest.advanceTimersByTime(1);
});

// Check if the state has reset to initial value
expect(result.current[0]).toBe(initialValue);
});
});
104 changes: 104 additions & 0 deletions packages/react/src/__tests__/use-text-to-image.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { renderHook, act } from "@testing-library/react";

import { useSDK } from "../use-sdk";
import { useTextToImage } from "../use-text-to-image";

jest.mock("../use-sdk", () => ({
useSDK: jest.fn(),
}));

jest.mock("../use-unload", () => ({
useUnload: jest.fn(),
}));

describe("useTextToImage", () => {
let sendMock;
let onMessageHandler;

beforeEach(() => {
sendMock = jest.fn();
onMessageHandler = jest.fn();

// Mock the useSDK to simulate receiving messages
(useSDK as jest.Mock).mockImplementation((appId, { onMessage }) => {
onMessageHandler = onMessage; // Capture the onMessage callback for simulation
return { send: sendMock };
});
});

it("starts the image generation process correctly", () => {
const { result } = renderHook(() => useTextToImage("myAppId"));

act(() => {
result.current.start({ model: "testModel", model_type: "stable-diffusion" });
});

expect(sendMock).toHaveBeenCalledWith({
action: "text-to-image:start",
payload: { model: "testModel", model_type: "stable-diffusion" },
});
expect(result.current.isLoading).toBeTruthy();
});

it("handles the image generation started message", () => {
const { result } = renderHook(() => useTextToImage("myAppId"));

// Simulate receiving an IPC message
act(() => {
onMessageHandler({
action: "text-to-image:started",
payload: "",
});
});

expect(result.current.isRunning).toBeTruthy();
expect(result.current.isLoading).toBeFalsy();
});

it("stops the image generation process correctly", () => {
const { result } = renderHook(() => useTextToImage("myAppId"));

act(() => {
result.current.stop();
});

expect(sendMock).toHaveBeenCalledWith({
action: "text-to-image:stop",
payload: true,
});
expect(result.current.isLoading).toBeTruthy();
});

it("generates an image with specified parameters", async () => {
const { result } = renderHook(() => useTextToImage("myAppId"));

// Simulate the condition that sets isRunning to true
act(() => {
onMessageHandler({
action: "text-to-image:started",
});
});

// Now, call the generate function
act(() => {
result.current.generate({
prompt: "A beautiful landscape",
negative_prompt: "buildings",
seed: 42,
});
});

// Check if the send function was called with the correct parameters
expect(sendMock).toHaveBeenCalledWith({
action: "text-to-image:settings",
payload: {
prompt: "A beautiful landscape",
negative_prompt: "buildings",
seed: 42,
},
});

// Check if the state reflects an ongoing generation process
expect(result.current.isGenerating).toBeTruthy();
});
});
45 changes: 45 additions & 0 deletions packages/react/src/__tests__/use-unload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { renderHook } from "@testing-library/react";

import { useSDK } from "../use-sdk";
import { useUnload } from "../use-unload";

// Mock the useSDK hook
jest.mock("../use-sdk", () => ({
useSDK: jest.fn(),
}));

describe("useUnload", () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
});

it("adds and removes the beforeunload event listener correctly", () => {
const mockSend = jest.fn();
(useSDK as jest.Mock).mockImplementation(() => ({ send: mockSend }));

// Mock addEventListener and removeEventListener
const addEventListenerSpy = jest.spyOn(window, "addEventListener");
const removeEventListenerSpy = jest.spyOn(window, "removeEventListener");

const { unmount } = renderHook(() => useUnload("myAppId", "testAction"));

// Check if the 'beforeunload' event listener was added
expect(addEventListenerSpy).toHaveBeenCalledWith("beforeunload", expect.any(Function));

// Trigger the beforeunload event
const event = new Event("beforeunload");
window.dispatchEvent(event);

// Check if the send function was called correctly
expect(mockSend).toHaveBeenCalledWith({ action: "testAction", payload: "myAppId" });

// Unmount the hook and check if the event listener was removed
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith("beforeunload", expect.any(Function));

// Cleanup spies to avoid memory leaks
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});
});
35 changes: 35 additions & 0 deletions packages/react/src/use-resettable-state/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useCallback, useRef, useState } from "react";

/**
* A custom React hook that provides a state variable along with a function to temporarily set its value.
* After a specified delay, the state automatically resets to its initial value.
*
* @template T The type of the state variable.
* @param initialState The initial value of the state. This is also the value to which the state will reset.
* @param delay The duration in milliseconds after which the state will reset to the initialState.
* @returns A tuple containing the current state and a function to set the state temporarily.
* The function accepts a new value and after `delay` milliseconds, it resets the state to `initialState`.
*
* @example
* ```ts
* const [count, setCountTemp] = useResettableState(0, 1000);
* setCountTemp(5); // Sets count to 5, then resets to 0 after 1000 milliseconds
* ```
*/
export function useResettableState<T>(initialState: T, delay: number): [T, (value: T) => void] {
const [state, setState] = useState<T>(initialState);
const timer = useRef(-1);

const setTemporaryState = useCallback(
(value: T) => {
setState(value);

timer.current = window.setTimeout(() => {
setState(initialState);
}, delay);
},
[initialState, delay]
);

return [state, setTemporaryState];
}
132 changes: 132 additions & 0 deletions packages/react/src/use-text-to-image/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useCallback, useState } from "react";

import { useSDK } from "../use-sdk";
import { useUnload } from "../use-unload";

/**
* A custom React hook for managing the lifecycle and state of a text-to-image generation process
* via Inter-Process Communication (IPC). This hook facilitates starting, stopping, and managing
* the image generation process based on text input, utilizing a specific machine learning model.
*
* @param {string} appId - A unique identifier for the application or SDK instance, used to
* configure the underlying `useSDK` and `useUnload` hooks for IPC
* communication.
*
* @returns {object} An object containing:
* - `generate`: A function to trigger the generation of an image from text prompts. Requires
* a payload including a 'prompt', 'negative_prompt', and 'seed'.
* - `start`: A function to start the image generation process, requiring a payload that specifies
* the model and model type.
* - `stop`: A function to stop the image generation process.
* - `isRunning`: A boolean state indicating if the image generation process is currently active.
* - `isLoading`: A boolean state indicating if an operation (start or stop) is in progress.
* - `isGenerating`: A boolean state indicating if the image is currently being generated.
* - `image`: A state holding the last generated image as a string (e.g., a URL or a base64 encoded string).
*
* @remarks
* - The hook uses `useSDK` for sending and receiving IPC messages specific to image generation tasks.
* - It listens for specific IPC messages (`text-to-image:started`, `text-to-image:stopped`,
* `text-to-image:generated`) to update the state accordingly.
* - `useUnload` is utilized to ensure that the image generation process is stopped when the component
* unmounts or the page is unloaded, preventing unfinished processes from lingering.
* - Properly handling the `isRunning`, `isLoading`, and `isGenerating` states is crucial for providing
* feedback to the user and managing UI components related to the process.
*
* @example
* ```tsx
* function App () {
* const { generate, start, stop, isRunning, isLoading, isGenerating, image } = useTextToImage("myAppId");
*
* return (
* <div>
* <button onClick={() => start({ model: "modelIdentifier", model_type: "stable-diffusion" })}>
* Start Image Generation
* </button>
* <button onClick={stop} disabled={!isRunning}>
* Stop Image Generation
* </button>
* <button onClick={() => generate({
* prompt: "A futuristic cityscape",
* negative_prompt: "No people",
* seed: 1234
* })} disabled={!isRunning || isGenerating}>
* Generate Image
* </button>
* {isLoading && <p>Loading...</p>}
* {image && <img src={image} alt="Generated from text" />}
* </div>
* );
* };
* ```
*/
export function useTextToImage(appId: string) {
const [isRunning, setIsRunning] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [image, setImage] = useState<string>("");

const { send } = useSDK<unknown, string>(appId, {
onMessage(message) {
switch (message.action) {
case "text-to-image:started": {
setIsRunning(true);
setIsLoading(false);
break;
}

case "text-to-image:stopped": {
setIsRunning(false);
setIsLoading(false);
break;
}

case "text-to-image:generated": {
setIsGenerating(false);
setImage(message.payload);
break;
}

default: {
break;
}
}
},
});

const start = useCallback(
(payload: {
// Example: "stabilityai/stable-diffusion-xl-base-1.0/sd_xl_base_1.0_0.9vae.safetensors"
model: string;
model_type: "stable-diffusion-xl" | "stable-diffusion";
}) => {
setIsLoading(true);
send({
action: "text-to-image:start",
payload,
});
},
[send]
);

const stop = useCallback(() => {
setIsLoading(true);
send({ action: "text-to-image:stop", payload: true });
}, [send]);

const generate = useCallback(
(payload: { prompt: string; negative_prompt: string; seed: number }) => {
if (isRunning) {
setIsGenerating(true);
send({
action: "text-to-image:settings",
payload,
});
}
},
[send, isRunning]
);

useUnload(appId, "text-to-image:stop");

return { generate, start, stop, isRunning, isLoading, isGenerating, image };
}

0 comments on commit 97cf946

Please sign in to comment.