diff --git a/lib/BasePipelineSolver.ts b/lib/BasePipelineSolver.ts index e868f3f..1592cd2 100644 --- a/lib/BasePipelineSolver.ts +++ b/lib/BasePipelineSolver.ts @@ -1,10 +1,13 @@ import type { GraphicsObject } from "graphics-debug" import { BaseSolver } from "./BaseSolver" -export interface PipelineStep { +export interface PipelineStep< + T extends BaseSolver = BaseSolver, + P = any, +> { solverName: string - solverClass: new (...args: any[]) => T - getConstructorParams: (pipelineInstance: any) => any[] + solverClass: new (params: P) => T + getConstructorParams: (pipelineInstance: any) => P onSolved?: (pipelineInstance: any) => void } @@ -15,11 +18,11 @@ export function definePipelineStep< >( solverName: string, solverClass: new (params: P) => T, - getConstructorParams: (instance: Instance) => [P], + getConstructorParams: (instance: Instance) => P, opts: { onSolved?: (instance: Instance) => void } = {}, -): PipelineStep { +): PipelineStep { return { solverName, solverClass, @@ -40,7 +43,7 @@ export abstract class BasePipelineSolver extends BaseSolver { /** Stores the outputs from each completed pipeline step */ pipelineOutputs: Record = {} - abstract pipelineDef: PipelineStep[] + abstract pipelineDef: PipelineStep[] constructor(inputProblem: TInput) { super() @@ -81,7 +84,7 @@ export abstract class BasePipelineSolver extends BaseSolver { } const constructorParams = pipelineStepDef.getConstructorParams(this) - this.activeSubSolver = new pipelineStepDef.solverClass(...constructorParams) + this.activeSubSolver = new pipelineStepDef.solverClass(constructorParams) ;(this as any)[pipelineStepDef.solverName] = this.activeSubSolver this.timeSpentOnPhase[pipelineStepDef.solverName] = 0 this.startTimeOfPhase[pipelineStepDef.solverName] = performance.now() diff --git a/lib/BaseSolver.ts b/lib/BaseSolver.ts index 52dfed7..ba332b6 100644 --- a/lib/BaseSolver.ts +++ b/lib/BaseSolver.ts @@ -8,8 +8,9 @@ import type { GraphicsObject } from "graphics-debug" * Solvers should override visualize() to return a GraphicsObject representing * the current state of the solver. * - * Solvers should override getConstructorParams() to return the parameters - * needed to construct the solver. + * Solvers should override getConstructorParams() to return the object passed as + * the first (and only) constructor parameter. This keeps constructor + * signatures serializable for tooling like the download dropdowns. */ export class BaseSolver { MAX_ITERATIONS = 100e3 diff --git a/lib/react/DownloadDropdown.tsx b/lib/react/DownloadDropdown.tsx new file mode 100644 index 0000000..6fd9aca --- /dev/null +++ b/lib/react/DownloadDropdown.tsx @@ -0,0 +1,254 @@ +import React, { useEffect, useRef, useState } from "react" +import type { BaseSolver } from "../BaseSolver" +import { BasePipelineSolver } from "../BasePipelineSolver" + +export interface DownloadTemplateContext { + solver: BaseSolver + solverName: string + params: any + isPipelineSolver: boolean +} + +export interface DownloadTemplateOverrides { + generatePageTsxContent?: (context: DownloadTemplateContext) => string + generateTestTsContent?: (context: DownloadTemplateContext) => string +} + +export interface DownloadDropdownProps { + solver: BaseSolver + className?: string + overrides?: DownloadTemplateOverrides +} + +export const deepRemoveUnderscoreProperties = (obj: any): any => { + if (obj === null || typeof obj !== "object") { + return obj + } + + if (Array.isArray(obj)) { + return obj.map(deepRemoveUnderscoreProperties) + } + + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + if (!key.startsWith("_")) { + result[key] = deepRemoveUnderscoreProperties(value) + } + } + return result +} + +const defaultPageTsxContent = ({ + solverName, + params, + isPipelineSolver, +}: DownloadTemplateContext) => { + const serializedParams = JSON.stringify(params, null, 2) + + if (isPipelineSolver) { + return `import { useMemo } from "react" +import { PipelineDebugger } from "solver-utils/lib/react" +import { ${solverName} } from "lib/solvers/${solverName}/${solverName}" + +export const inputProblem = ${serializedParams} + +export default () => { + const solver = useMemo(() => new ${solverName}(inputProblem as any), []) + return +} +` + } + + return `import { useMemo } from "react" +import { GenericSolverDebugger } from "solver-utils/lib/react" +import { ${solverName} } from "lib/solvers/${solverName}/${solverName}" + +export const inputProblem = ${serializedParams} + +export default () => { + const solver = useMemo(() => new ${solverName}(inputProblem as any), []) + return +} +` +} + +const defaultTestTsContent = ({ solverName, params }: DownloadTemplateContext) => { + const serializedParams = JSON.stringify(params, null, 2) + + return `import { ${solverName} } from "lib/solvers/${solverName}/${solverName}" +import { test, expect } from "bun:test" + +test("${solverName} should solve problem correctly", () => { + const input = ${serializedParams} + + const solver = new ${solverName}(input as any) + solver.solve() + + expect(solver).toMatchSolverSnapshot(import.meta.path) + + // Add more specific assertions based on expected output + // expect(solver.getOutput()).toMatchInlineSnapshot() +}) +` +} + +export const DownloadDropdown: React.FC = ({ + solver, + className = "", + overrides, +}) => { + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false) + } + } + + if (typeof document !== "undefined") { + document.addEventListener("mousedown", handleClickOutside) + } + return () => { + if (typeof document !== "undefined") { + document.removeEventListener("mousedown", handleClickOutside) + } + } + }, []) + + const runWithParams = ( + action: (context: DownloadTemplateContext) => void, + ) => { + if (typeof solver.getConstructorParams !== "function") { + const message = `getConstructorParams() is not implemented for ${solver.constructor.name}` + if (typeof window !== "undefined") { + window.alert?.(message) + } else { + console.error(message) + } + return + } + + try { + const rawParams = solver.getConstructorParams() + const params = deepRemoveUnderscoreProperties(rawParams) + const solverName = solver.constructor.name + const context: DownloadTemplateContext = { + solver, + solverName, + params, + isPipelineSolver: solver instanceof BasePipelineSolver, + } + action(context) + } catch (error) { + const message = `Error gathering constructor params for ${solver.constructor.name}: ${error instanceof Error ? error.message : String(error)}` + if (typeof window !== "undefined") { + window.alert?.(message) + } else { + console.error(message) + } + } + } + + const downloadJSON = () => { + runWithParams(({ solverName, params }) => { + if (typeof document === "undefined") { + console.error("Document is not available to trigger downloads") + return + } + const blob = new Blob([JSON.stringify(params, null, 2)], { + type: "application/json", + }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${solverName}_params.json` + a.click() + URL.revokeObjectURL(url) + }) + setIsOpen(false) + } + + const downloadPageTsx = () => { + runWithParams((context) => { + if (typeof document === "undefined") { + console.error("Document is not available to trigger downloads") + return + } + const content = + overrides?.generatePageTsxContent?.(context) ?? + defaultPageTsxContent(context) + const blob = new Blob([content], { type: "text/plain" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${context.solverName}.page.tsx` + a.click() + URL.revokeObjectURL(url) + }) + setIsOpen(false) + } + + const downloadTestTs = () => { + runWithParams((context) => { + if (typeof document === "undefined") { + console.error("Document is not available to trigger downloads") + return + } + const content = + overrides?.generateTestTsContent?.(context) ?? + defaultTestTsContent(context) + const blob = new Blob([content], { type: "text/plain" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `${context.solverName}.test.ts` + a.click() + URL.revokeObjectURL(url) + }) + setIsOpen(false) + } + + return ( +
+ + + {isOpen && ( +
+ + + +
+ )} +
+ ) +} diff --git a/lib/react/GenericSolverDebugger.tsx b/lib/react/GenericSolverDebugger.tsx index a8e4d20..ce34146 100644 --- a/lib/react/GenericSolverDebugger.tsx +++ b/lib/react/GenericSolverDebugger.tsx @@ -2,12 +2,14 @@ import React, { useEffect, useMemo, useReducer } from "react" import type { BaseSolver } from "../BaseSolver" import { InteractiveGraphics } from "graphics-debug/react" import { GenericSolverToolbar } from "./GenericSolverToolbar" +import type { DownloadTemplateOverrides } from "./DownloadDropdown" export interface GenericSolverDebuggerProps { solver: BaseSolver animationSpeed?: number onSolverStarted?: (solver: BaseSolver) => void onSolverCompleted?: (solver: BaseSolver) => void + downloadOverrides?: DownloadTemplateOverrides } export const GenericSolverDebugger = ({ @@ -15,6 +17,7 @@ export const GenericSolverDebugger = ({ animationSpeed = 25, onSolverStarted, onSolverCompleted, + downloadOverrides, }: GenericSolverDebuggerProps) => { const [renderCount, incRenderCount] = useReducer((x) => x + 1, 0) @@ -59,6 +62,7 @@ export const GenericSolverDebugger = ({ animationSpeed={animationSpeed} onSolverStarted={onSolverStarted} onSolverCompleted={onSolverCompleted} + downloadOverrides={downloadOverrides} /> {graphicsAreEmpty ? (
No Graphics Yet
diff --git a/lib/react/GenericSolverToolbar.tsx b/lib/react/GenericSolverToolbar.tsx index 5640490..90711b0 100644 --- a/lib/react/GenericSolverToolbar.tsx +++ b/lib/react/GenericSolverToolbar.tsx @@ -1,6 +1,8 @@ import React, { useReducer, useRef, useEffect } from "react" import type { BaseSolver } from "../BaseSolver" import type { BasePipelineSolver } from "../BasePipelineSolver" +import { SolverBreadcrumbInputDownloader } from "./SolverBreadcrumbInputDownloader" +import type { DownloadTemplateOverrides } from "./DownloadDropdown" export interface GenericSolverToolbarProps { solver: BaseSolver @@ -8,6 +10,7 @@ export interface GenericSolverToolbarProps { animationSpeed?: number onSolverStarted?: (solver: BaseSolver) => void onSolverCompleted?: (solver: BaseSolver) => void + downloadOverrides?: DownloadTemplateOverrides } export const GenericSolverToolbar = ({ @@ -16,6 +19,7 @@ export const GenericSolverToolbar = ({ animationSpeed = 25, onSolverStarted, onSolverCompleted, + downloadOverrides, }: GenericSolverToolbarProps) => { const [isAnimating, setIsAnimating] = useReducer((x) => !x, false) const animationRef = useRef(undefined) @@ -111,6 +115,11 @@ export const GenericSolverToolbar = ({ return (
+ +