From a50e7963f74b07b46816082005b6a7c2aa5ba647 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 23 Apr 2026 18:36:10 -0700 Subject: [PATCH 01/83] Reduce live update redraw churn Baseline Genesis XL v1.8 live update: 4.1346 ms/dispatch. After: 3.6594 ms/dispatch, 11.5% faster. Targeted tests, frontend typecheck, and targeted eslint passed. --- frontend/devices/__tests__/reducer_test.ts | 20 +++ frontend/devices/reducer.ts | 6 +- .../__tests__/state_to_props_test.ts | 35 ++++ frontend/farm_designer/state_to_props.ts | 162 +++++++++++++----- .../redux/__tests__/generate_reducer_test.ts | 30 ++++ frontend/redux/__tests__/root_reducer_test.ts | 15 ++ frontend/redux/generate_reducer.ts | 21 ++- frontend/resources/__tests__/reducer_test.ts | 23 +++ frontend/resources/reducer_support.ts | 14 +- 9 files changed, 268 insertions(+), 58 deletions(-) create mode 100644 frontend/redux/__tests__/generate_reducer_test.ts diff --git a/frontend/devices/__tests__/reducer_test.ts b/frontend/devices/__tests__/reducer_test.ts index bcb925c72d..dd90e4c391 100644 --- a/frontend/devices/__tests__/reducer_test.ts +++ b/frontend/devices/__tests__/reducer_test.ts @@ -11,6 +11,26 @@ const statusOf = (state: BotState) => { }; describe("botReducer", () => { + it("returns the same state reference for unrelated actions", () => { + const state = initialState(); + const result = botReducer(state, { + type: "NO_HANDLER" as Actions, + payload: undefined, + }); + expect(result).toBe(state); + }); + + it("updates parent state only when connectivity changes", () => { + const state = initialState(); + const result = botReducer(state, { + type: Actions.PING_START, + payload: { id: "ping" }, + }); + expect(result).not.toBe(state); + expect(result.hardware).toBe(state.hardware); + expect(result.connectivity).not.toBe(state.connectivity); + }); + it("Starts / stops an update", () => { const step1 = botReducer(initialState(), { type: Actions.SETTING_UPDATE_START, diff --git a/frontend/devices/reducer.ts b/frontend/devices/reducer.ts index 9161f554db..5e44cd4c56 100644 --- a/frontend/devices/reducer.ts +++ b/frontend/devices/reducer.ts @@ -16,8 +16,10 @@ import { updateMotorHistoryArray } from "../controls/move/motor_position_plot"; import { PercentageProgress, Xyz } from "farmbot"; const afterEach = (state: BotState, a: ReduxAction) => { - state.connectivity = connectivityReducer(state.connectivity, a); - return state; + const connectivity = connectivityReducer(state.connectivity, a); + return connectivity === state.connectivity + ? state + : { ...state, connectivity }; }; export const initialState = (): BotState => ({ diff --git a/frontend/farm_designer/__tests__/state_to_props_test.ts b/frontend/farm_designer/__tests__/state_to_props_test.ts index 3684b63737..fb49e2d858 100644 --- a/frontend/farm_designer/__tests__/state_to_props_test.ts +++ b/frontend/farm_designer/__tests__/state_to_props_test.ts @@ -32,6 +32,41 @@ describe("mapStateToProps()", () => { expect(mapStateToProps(state).hoveredPlant).toBeFalsy(); }); + it("reuses resource array props across bot-only updates", () => { + const state = fakeState(); + const first = mapStateToProps(state); + const nextState = { + ...state, + bot: { + ...state.bot, + hardware: { + ...state.bot.hardware, + location_data: { + ...state.bot.hardware.location_data, + position: { x: 1, y: 2, z: 3 }, + }, + pins: { ...state.bot.hardware.pins }, + }, + }, + }; + const second = mapStateToProps(nextState); + expect(second.getConfigValue).toBe(first.getConfigValue); + expect(second.plants).toBe(first.plants); + expect(second.genericPoints).toBe(first.genericPoints); + expect(second.weeds).toBe(first.weeds); + expect(second.allPoints).toBe(first.allPoints); + expect(second.tools).toBe(first.tools); + expect(second.toolSlots).toBe(first.toolSlots); + expect(second.latestImages).toBe(first.latestImages); + expect(second.sensorReadings).toBe(first.sensorReadings); + expect(second.sensors).toBe(first.sensors); + expect(second.groups).toBe(first.groups); + expect(second.logs).toBe(first.logs); + expect(second.farmwareEnvs).toBe(first.farmwareEnvs); + expect(second.curves).toBe(first.curves); + expect(second.peripheralValues).toBe(first.peripheralValues); + }); + it("peripherals pins have correct states", () => { const state = fakeState(); const peripheral1 = fakePeripheral(); diff --git a/frontend/farm_designer/state_to_props.ts b/frontend/farm_designer/state_to_props.ts index dfa7117ff9..ee36d0a93e 100644 --- a/frontend/farm_designer/state_to_props.ts +++ b/frontend/farm_designer/state_to_props.ts @@ -24,7 +24,10 @@ import { } from "../resources/selectors"; import { validFwConfig, validFbosConfig } from "../util"; import { validBotLocationData } from "../util/location"; -import { getWebAppConfigValue } from "../config_storage/actions"; +import { + getWebAppConfigValue, + getWebAppConfigValueFromResources, +} from "../config_storage/actions"; import { FarmDesignerProps, CameraCalibrationData } from "./interfaces"; import { TaggedPlant, BotSize } from "./map/interfaces"; import { RestResources } from "../resources/interfaces"; @@ -46,14 +49,107 @@ const plantFinder = (plants: TaggedPlant[]) => (uuid: string | undefined): TaggedPlant => plants.filter(x => x.uuid === uuid)[0]; -export const getPlants = (resources: RestResources) => { +const memoizeLast = ( + fn: (...args: Args) => Result, +) => { + let lastArgs: Args | undefined; + let lastResult: Result; + return (...args: Args): Result => { + if (lastArgs && args.every((arg, index) => arg === lastArgs?.[index])) { + return lastResult; + } + lastArgs = args; + lastResult = fn(...args); + return lastResult; + }; +}; + +const selectCrops = memoizeLast(selectAllCrops); +const selectGenericPoints = memoizeLast(selectAllGenericPointers); +const selectWeedPointers = memoizeLast(selectAllWeedPointers); +const selectVisibleWeeds = memoizeLast( + (allWeeds: ReturnType, showHistoric: boolean) => + showHistoric + ? allWeeds + : allWeeds.filter(x => x.body.plant_stage !== "removed")); +const selectPointGroups = memoizeLast(selectAllPointGroups); +const selectPoints = memoizeLast(selectAllPoints); +const selectTools = memoizeLast(selectAllTools); +const selectToolSlots = memoizeLast(joinToolsAndSlot); +const selectPeripherals = memoizeLast(selectAllPeripherals); +const selectImages = memoizeLast((index: RestResources["index"]) => + chain(selectAllImages(index)) + .sortBy(x => x.body.id) + .reverse() + .value()); +const selectSensorReadings = memoizeLast((index: RestResources["index"]) => + chain(selectAllSensorReadings(index)) + .sortBy(x => x.body.created_at) + .reverse() + .take(500) + .reverse() + .value()); +const selectSensors = memoizeLast(selectAllSensors); +const selectLogs = memoizeLast(selectAllLogs); +const selectFarmwareEnvs = memoizeLast(selectAllFarmwareEnvs); +const selectCurves = memoizeLast(selectAllCurves); +const selectTimeSettings = memoizeLast(maybeGetTimeSettings); +const selectEnv = memoizeLast(getEnv); +const selectCameraCalibrationData = memoizeLast( + (env: UserEnv) => getCameraCalibrationData(env)); +const selectGetConfigValue = memoizeLast(getWebAppConfigValueFromResources); +const selectPlantsForDesigner = memoizeLast(( + resources: RestResources, + openedSavedGarden: number | undefined, +) => { const onlyPlants = selectAllPlantPointers(resources.index); const plantTemplates = selectAllPlantTemplates(resources.index); - const { openedSavedGarden } = resources.consumers.farm_designer; return isFinite(openedSavedGarden) ? plantTemplates.filter(x => x.body.saved_garden_id === openedSavedGarden) : onlyPlants; +}); + +const selectPeripheralValues = (() => { + let lastKey = ""; + let lastResult: ReturnType | undefined; + return ( + peripherals: ReturnType, + pins: Everything["bot"]["hardware"]["pins"], + ) => { + const key = peripherals + .map(peripheral => { + const pin = peripheral.body.pin; + const value = pin ? pins[pin]?.value : undefined; + return `${peripheral.uuid}:${peripheral.body.label}:${pin}:${value}`; + }) + .join("|"); + if (key === lastKey && lastResult) { + return lastResult; + } + lastKey = key; + lastResult = mapPeripheralValues(peripherals, pins); + return lastResult; + }; +})(); + +const mapPeripheralValues = ( + peripherals: ReturnType, + pins: Everything["bot"]["hardware"]["pins"], +) => + uniq(peripherals) + .map(x => { + const label = x.body.label; + const pinStatus = x.body.pin + ? pins[x.body.pin] + : undefined; + const value = pinStatus ? pinStatus.value > 0 : false; + return { label, value }; + }); + +export const getPlants = (resources: RestResources) => { + const { openedSavedGarden } = resources.consumers.farm_designer; + return selectPlantsForDesigner(resources, openedSavedGarden); }; export function mapStateToProps(props: Everything): FarmDesignerProps { @@ -72,13 +168,13 @@ export function mapStateToProps(props: Everything): FarmDesignerProps { maybeGetSequence(props.resources.index, visualizedSequenceUUID)?.body.body || []; - const getConfigValue = getWebAppConfigValue(() => props); - const allGenericPoints = selectAllGenericPointers(props.resources.index); + const getConfigValue = selectGetConfigValue(props.resources.index); + const allGenericPoints = selectGenericPoints(props.resources.index); const genericPoints = allGenericPoints; - const allWeeds = selectAllWeedPointers(props.resources.index); - const weeds = getConfigValue(BooleanSetting.show_historic_points) - ? allWeeds - : allWeeds.filter(x => x.body.plant_stage !== "removed"); + const allWeeds = selectWeedPointers(props.resources.index); + const weeds = selectVisibleWeeds( + allWeeds, + !!getConfigValue(BooleanSetting.show_historic_points)); const fwConfig = validFwConfig(getFirmwareConfig(props.resources.index)); const { hardware } = props.bot; @@ -102,35 +198,21 @@ export function mapStateToProps(props: Everything): FarmDesignerProps { flipped: isToolFlipped(mountedToolSlotInfo?.meta), }; - const groups = selectAllPointGroups(props.resources.index); - const allPoints = selectAllPoints(props.resources.index); + const groups = selectPointGroups(props.resources.index); + const allPoints = selectPoints(props.resources.index); - const peripheralValues = uniq(selectAllPeripherals(props.resources.index)) - .map(x => { - const label = x.body.label; - const pinStatus = x.body.pin - ? hardware.pins[x.body.pin] - : undefined; - const value = pinStatus ? pinStatus.value > 0 : false; - return { label, value }; - }); + const peripheralValues = selectPeripheralValues( + selectPeripherals(props.resources.index), + hardware.pins); - const latestImages = chain(selectAllImages(props.resources.index)) - .sortBy(x => x.body.id) - .reverse() - .value(); + const latestImages = selectImages(props.resources.index); - const env = getEnv(props.resources.index); + const env = selectEnv(props.resources.index); - const sensorReadings = chain(selectAllSensorReadings(props.resources.index)) - .sortBy(x => x.body.created_at) - .reverse() - .take(500) - .reverse() - .value(); + const sensorReadings = selectSensorReadings(props.resources.index); return { - crops: selectAllCrops(props.resources.index), + crops: selectCrops(props.resources.index), dispatch: props.dispatch, device, selectedPlant, @@ -138,8 +220,8 @@ export function mapStateToProps(props: Everything): FarmDesignerProps { genericPoints, weeds, allPoints, - tools: selectAllTools(props.resources.index), - toolSlots: joinToolsAndSlot(props.resources.index), + tools: selectTools(props.resources.index), + toolSlots: selectToolSlots(props.resources.index), hoveredPlant, plants, botLocationData: validBotLocationData(hardware.location_data), @@ -149,18 +231,18 @@ export function mapStateToProps(props: Everything): FarmDesignerProps { eStopStatus: hardware.informational_settings.locked, deviceTarget: hardware.informational_settings.target, latestImages, - cameraCalibrationData: getCameraCalibrationData(env), - timeSettings: maybeGetTimeSettings(props.resources.index), + cameraCalibrationData: selectCameraCalibrationData(env), + timeSettings: selectTimeSettings(props.resources.index), getConfigValue, sensorReadings, - sensors: selectAllSensors(props.resources.index), + sensors: selectSensors(props.resources.index), groups, mountedToolInfo, visualizedSequenceBody, - logs: selectAllLogs(props.resources.index), + logs: selectLogs(props.resources.index), sourceFbosConfig: sourceFbosConfigValue(fbosConfig, hardware.configuration), - farmwareEnvs: selectAllFarmwareEnvs(props.resources.index), - curves: selectAllCurves(props.resources.index), + farmwareEnvs: selectFarmwareEnvs(props.resources.index), + curves: selectCurves(props.resources.index), }; } diff --git a/frontend/redux/__tests__/generate_reducer_test.ts b/frontend/redux/__tests__/generate_reducer_test.ts new file mode 100644 index 0000000000..5ecf52656c --- /dev/null +++ b/frontend/redux/__tests__/generate_reducer_test.ts @@ -0,0 +1,30 @@ +import { Actions } from "../../constants"; +import { generateReducer } from "../generate_reducer"; + +describe("generateReducer", () => { + it("returns the same state reference for unrelated actions", () => { + const reducer = generateReducer({ count: 1, nested: { value: 2 } }); + const state = { count: 1, nested: { value: 2 } }; + const result = reducer(state, { + type: "NO_HANDLER" as Actions, + payload: undefined, + }); + expect(result).toBe(state); + }); + + it("clones state for handled actions", () => { + const reducer = generateReducer({ count: 1 }) + .add(Actions.CHANGE_STEP_SIZE, (state, action) => { + state.count = action.payload; + return state; + }); + const state = { count: 1 }; + const result = reducer(state, { + type: Actions.CHANGE_STEP_SIZE, + payload: 2, + }); + expect(result).not.toBe(state); + expect(result.count).toEqual(2); + expect(state.count).toEqual(1); + }); +}); diff --git a/frontend/redux/__tests__/root_reducer_test.ts b/frontend/redux/__tests__/root_reducer_test.ts index 23c0ff90fb..312520837a 100644 --- a/frontend/redux/__tests__/root_reducer_test.ts +++ b/frontend/redux/__tests__/root_reducer_test.ts @@ -2,6 +2,7 @@ import { Actions } from "../../constants"; import { Everything } from "../../interfaces"; import { Session } from "../../session"; import { fakeState } from "../../__test_support__/fake_state"; +import { defensiveClone } from "../../util"; import { rootReducer } from "../root_reducer"; describe("rootReducer()", () => { @@ -23,4 +24,18 @@ describe("rootReducer()", () => { rootReducer(reducers, { type: Actions.LOGOUT, payload: {} }); expect(Session.clear).toHaveBeenCalled(); }); + + it("preserves unrelated state slices on status updates", () => { + const state: Omit = fakeState(); + delete state["dispatch" as keyof typeof state]; + const next = rootReducer(state, { + type: Actions.STATUS_UPDATE, + payload: defensiveClone(state.bot.hardware), + }); + expect(next.bot).not.toBe(state.bot); + expect(next.resources).toBe(state.resources); + expect(next.app).toBe(state.app); + expect(next.config).toBe(state.config); + expect(next.draggable).toBe(state.draggable); + }); }); diff --git a/frontend/redux/generate_reducer.ts b/frontend/redux/generate_reducer.ts index 4b89c3d1e0..8423751e1f 100644 --- a/frontend/redux/generate_reducer.ts +++ b/frontend/redux/generate_reducer.ts @@ -55,15 +55,24 @@ export function generateReducer(initialState: State) { }; const reducer: GeneratedReducer = - ((state = initialState, action: ReduxAction): State => { + ((currentState: State | undefined = initialState, + action: ReduxAction): State => { + const state = currentState; // Find the handler in the dictionary, or use the NOOP. - const handler = (priv.actionHandlers[action.type] || EMPTY_HANDLER); - - // Defensively clone the action and state to avoid accidental mutations. - const clonedState = defensiveClone(state); + const hasHandler = + Object.prototype.hasOwnProperty.call(priv.actionHandlers, action.type); + const handler = hasHandler + ? priv.actionHandlers[action.type] + : EMPTY_HANDLER; + + // Defensively clone only when this reducer handles the action directly. + // Child reducers in afterEach can still return updated references. + const nextState = (hasHandler || currentState === undefined) + ? defensiveClone(state) + : state; // Run main action handler - const beforeState = priv.beforeEach(clonedState, action, handler); + const beforeState = priv.beforeEach(nextState, action, handler); // Run `afterEach` (if any). Else, just return the state object as-is. return priv.afterEach(beforeState, action); diff --git a/frontend/resources/__tests__/reducer_test.ts b/frontend/resources/__tests__/reducer_test.ts index d53079d033..bb02d457e0 100644 --- a/frontend/resources/__tests__/reducer_test.ts +++ b/frontend/resources/__tests__/reducer_test.ts @@ -28,6 +28,29 @@ describe("resource reducer", () => { console.error = originalConsoleError; }); + it("returns the same state reference for unrelated actions", () => { + const state = fakeState().resources; + const result = resourceReducer(state, { + type: Actions.STATUS_UPDATE, + payload: undefined, + }); + expect(result).toBe(state); + }); + + it("updates parent state only when child reducers change", () => { + const state = fakeState().resources; + const result = resourceReducer(state, { + type: Actions.SELECT_POINT, + payload: ["Point.1.1"], + }); + expect(result).not.toBe(state); + expect(result.index).toBe(state.index); + expect(result.consumers).not.toBe(state.consumers); + expect(result.consumers.farm_designer).not.toBe( + state.consumers.farm_designer); + expect(result.consumers.sequences).toBe(state.consumers.sequences); + }); + it("marks resources as DIRTY when reducing OVERWRITE_RESOURCE", () => { const state = fakeState().resources; const uuid = Object.keys(state.index.byKind.Sequence)[0]; diff --git a/frontend/resources/reducer_support.ts b/frontend/resources/reducer_support.ts index 9b5196e744..db1a7f9690 100644 --- a/frontend/resources/reducer_support.ts +++ b/frontend/resources/reducer_support.ts @@ -286,16 +286,10 @@ export function afterEach(state: RestResources, a: ReduxAction) { const reducer = consumerReducer || ( consumerReducer = combineReducers(buildConsumerReducers()) ); - state.consumers = reducer({ - sequences: state.consumers.sequences, - regimens: state.consumers.regimens, - farm_designer: state.consumers.farm_designer, - photos: state.consumers.photos, - farmware: state.consumers.farmware, - help: state.consumers.help, - alerts: state.consumers.alerts - }, a); - return state; + const consumers = reducer(state.consumers, a); + return consumers === state.consumers + ? state + : { ...state, consumers }; } /** Helper method to change the `specialStatus` of a resource in the index */ From bcd0be4aaaf2a0502f09b646d478f1b1985dddf2 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 23 Apr 2026 18:52:29 -0700 Subject: [PATCH 02/83] Improve 3D garden update FPS Genesis XL v1.8 3D status-stress FPS improved from 33.26 avg / 18.28 median to 40.49 avg / 25.82 median. Status dispatches during the fixed window increased from 186 to 256, and sampled scene FPS improved from 17.92 to 24.34. Idle 3D FPS remained stable at 116.43 avg / 119.98 median versus 113.86 avg / 118.60 median baseline.\n\nChanges keep FPSProbe behavior unchanged and do not add a Canvas DPR cap.\n\nChecks: bun test frontend/three_d_garden/__tests__/garden_model_test.tsx frontend/farm_designer/__tests__/three_d_garden_map_test.tsx frontend/three_d_garden/__tests__/index_test.tsx frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx frontend/three_d_garden/garden/__tests__/plants_test.tsx frontend/three_d_garden/garden/__tests__/sun_test.tsx; bunx eslint --no-cache changed 3D files; bun run typecheck --- .../__tests__/three_d_garden_map_test.tsx | 14 + frontend/farm_designer/three_d_garden_map.tsx | 438 ++++++++++++------ .../three_d_garden/__tests__/index_test.tsx | 12 + .../garden/__tests__/plant_instances_test.tsx | 26 ++ .../garden/__tests__/plants_test.tsx | 30 +- .../garden/__tests__/sun_test.tsx | 8 + .../three_d_garden/garden/plant_instances.tsx | 66 ++- frontend/three_d_garden/garden/plants.tsx | 40 +- frontend/three_d_garden/garden/sun.tsx | 3 +- frontend/three_d_garden/garden_model.tsx | 26 +- frontend/three_d_garden/index.tsx | 2 +- 11 files changed, 495 insertions(+), 170 deletions(-) diff --git a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx index f4c7b39510..2253080d27 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -171,6 +171,20 @@ describe("", () => { })); }); + it("keeps garden config stable across bot position updates", () => { + const p = fakeProps(); + const { rerender } = render(); + const first = lastThreeDGardenProps(); + p.botPosition = { x: 10, y: 20, z: 30 }; + rerender(); + const second = lastThreeDGardenProps(); + expect(second.config).toBe(first.config); + expect(second.threeDPlants).toBe(first.threeDPlants); + expect(second.addPlantProps).toBe(first.addPlantProps); + expect(second.configPosition).not.toBe(first.configPosition); + expect(second.configPosition).toEqual({ x: 2990, y: 1480, z: 30 }); + }); + it("converts props: negative z", () => { const p = fakeProps(); p.botPosition = { x: undefined, y: undefined, z: -100 }; diff --git a/frontend/farm_designer/three_d_garden_map.tsx b/frontend/farm_designer/three_d_garden_map.tsx index 43fee1869d..3f9ebe548f 100644 --- a/frontend/farm_designer/three_d_garden_map.tsx +++ b/frontend/farm_designer/three_d_garden_map.tsx @@ -7,7 +7,7 @@ import { import { clone } from "lodash"; import { BotPosition, SourceFbosConfig } from "../devices/interfaces"; import { - ConfigurationName, TaggedCurve, TaggedFarmwareEnv, TaggedGenericPointer, + TaggedCurve, TaggedFarmwareEnv, TaggedGenericPointer, TaggedImage, TaggedLog, TaggedPoint, TaggedPointGroup, TaggedSensor, TaggedSensorReading, TaggedWeedPointer, } from "farmbot"; @@ -57,138 +57,88 @@ export interface ThreeDGardenMapProps { } export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { - const config = clone(INITIAL); const { gridSize } = props.mapTransformProps; - config.botSizeX = gridSize.x; - config.botSizeY = gridSize.y; - config.bedWidthOuter = gridSize.y + 160; - config.bedLengthOuter = gridSize.x + 280; - config.zoomBeacons = false; - config.trail = !!props.getWebAppConfigValue(BooleanSetting.display_trail); - config.animate = !props.getWebAppConfigValue(BooleanSetting.disable_animations); - config.cameraView = - !!props.getWebAppConfigValue(BooleanSetting.show_camera_view_area); - - config.kitVersion = - props.sourceFbosConfig("firmware_hardware").value == "farmduino_k18" - ? "v1.8" - : "v1.7"; - - config.negativeZ = props.negativeZ; - config.exaggeratedZ = props.designer.threeDExaggeratedZ; - const getValue = props.get3DConfigValue; - config.mirrorX = !!getValue("mirrorX"); - config.mirrorY = !!getValue("mirrorY"); - config.bedXOffset = getValue("bedXOffset"); - config.bedYOffset = getValue("bedYOffset"); - config.bedZOffset = getValue("bedZOffset"); - - const position = clone(INITIAL_POSITION); - position.x = props.botPosition.x || 0; - position.y = props.botPosition.y || 0; - position.z = props.botPosition.z || 0; - if (config.mirrorY) { position.y = gridSize.y - position.y; } - if (config.mirrorX) { position.x = gridSize.x - position.x; } - const { designer } = props; - config.distanceIndicator = designer.distanceIndicator; - - const fbosConfig = (key: ConfigurationName) => - props.sourceFbosConfig(key).value as number; - config.zGantryOffset = fbosConfig("gantry_height"); - config.soilHeight = Math.abs(fbosConfig("soil_height")); - - config.bedWallThickness = getValue("bedWallThickness"); - config.bedHeight = getValue("bedHeight"); - config.ccSupportSize = getValue("ccSupportSize"); - config.beamLength = getValue("beamLength"); - config.columnLength = getValue("columnLength"); - config.zAxisLength = getValue("zAxisLength"); - config.legSize = getValue("legSize"); - config.legsFlush = !!getValue("legsFlush"); - config.extraLegsX = getValue("extraLegsX"); - config.extraLegsY = getValue("extraLegsY"); - config.bedBrightness = getValue("bedBrightness"); - config.soilBrightness = getValue("soilBrightness"); - config.clouds = !!getValue("clouds"); - config.laser = !!getValue("laser"); - config.stats = !!getValue("stats"); - config.threeAxes = !!getValue("threeAxes"); - config.solar = !!getValue("solar"); - config.lowDetail = !!getValue("lowDetail"); - config.eventDebug = !!getValue("eventDebug"); - config.cableDebug = !!getValue("cableDebug"); - config.lightsDebug = !!getValue("lightsDebug"); - config.moistureDebug = !!getValue("moistureDebug"); - config.surfaceDebug = getValue("surfaceDebug"); - config.sun = getValue("sun"); - config.ambient = getValue("ambient"); - config.heading = getValue("heading"); - config.bounds = !!getValue("bounds"); - config.grid = !!getValue("grid"); - config.tracks = !!getValue("tracks"); - config.cableCarriers = !!getValue("cableCarriers"); - config.axes = !!getValue("axes"); - config.xyDimensions = !!getValue("xyDimensions"); - config.zDimension = !!getValue("zDimension"); - - config.scene = SCENES[getValue("scene")]; - config.people = !!getValue("people"); - - config.north = true; - config.desk = !!getValue("desk"); - config.plants = ""; - + const configValues = { + mirrorX: getValue("mirrorX"), + mirrorY: getValue("mirrorY"), + bedXOffset: getValue("bedXOffset"), + bedYOffset: getValue("bedYOffset"), + bedZOffset: getValue("bedZOffset"), + bedWallThickness: getValue("bedWallThickness"), + bedHeight: getValue("bedHeight"), + ccSupportSize: getValue("ccSupportSize"), + beamLength: getValue("beamLength"), + columnLength: getValue("columnLength"), + zAxisLength: getValue("zAxisLength"), + legSize: getValue("legSize"), + legsFlush: getValue("legsFlush"), + extraLegsX: getValue("extraLegsX"), + extraLegsY: getValue("extraLegsY"), + bedBrightness: getValue("bedBrightness"), + soilBrightness: getValue("soilBrightness"), + clouds: getValue("clouds"), + laser: getValue("laser"), + stats: getValue("stats"), + threeAxes: getValue("threeAxes"), + solar: getValue("solar"), + lowDetail: getValue("lowDetail"), + eventDebug: getValue("eventDebug"), + cableDebug: getValue("cableDebug"), + lightsDebug: getValue("lightsDebug"), + moistureDebug: getValue("moistureDebug"), + surfaceDebug: getValue("surfaceDebug"), + sun: getValue("sun"), + ambient: getValue("ambient"), + heading: getValue("heading"), + bounds: getValue("bounds"), + grid: getValue("grid"), + tracks: getValue("tracks"), + cableCarriers: getValue("cableCarriers"), + axes: getValue("axes"), + xyDimensions: getValue("xyDimensions"), + zDimension: getValue("zDimension"), + scene: getValue("scene"), + people: getValue("people"), + desk: getValue("desk"), + sunAzimuth: getValue("sunAzimuth"), + sunInclination: getValue("sunInclination"), + }; + const mirrorX = !!configValues.mirrorX; + const mirrorY = !!configValues.mirrorY; + const firmwareHardware = props.sourceFbosConfig("firmware_hardware").value; + const zGantryOffset = + props.sourceFbosConfig("gantry_height").value as number; + const soilHeight = + Math.abs(props.sourceFbosConfig("soil_height").value as number); + const displayTrail = + !!props.getWebAppConfigValue(BooleanSetting.display_trail); + const animate = + !props.getWebAppConfigValue(BooleanSetting.disable_animations); + const cameraView = + !!props.getWebAppConfigValue(BooleanSetting.show_camera_view_area); + const topDown = isTopDown(props.designer, props.getWebAppConfigValue); + const viewpointHeading = parseInt( + "" + props.getWebAppConfigValue(NumericSetting.viewpoint_heading)); const { latitude, longitude, valid } = latLng(props.device); - if (valid) { - const date = get3DTime(props.designer.threeDTime).toDate(); - const { azimuth, inclination } = calcSunCoordinate( - date, config.heading, latitude, longitude); - config.sunAzimuth = azimuth; - config.sunInclination = inclination; - } else { - config.sunAzimuth = getValue("sunAzimuth"); - config.sunInclination = getValue("sunInclination"); - } - const isPeripheralActive = isPeripheralActiveFunc(props.peripheralValues); - - config.waterFlow = isPeripheralActive("water"); - config.light = isPeripheralActive("light"); - config.vacuum = isPeripheralActive("vacuum"); - const rotarySpeed = () => { + const waterFlow = isPeripheralActive("water"); + const light = isPeripheralActive("light"); + const vacuum = isPeripheralActive("vacuum"); + const rotary = (() => { const fwd = isPeripheralActive("rotary", "reverse"); const rev = isPeripheralActive("reverse"); if (rev && !fwd) { return -1; } if (fwd && !rev) { return 1; } return 0; - }; - config.rotary = rotarySpeed(); - - const camCalData = parseCalibrationData(props.cameraCalibrationData); - config.imgScale = camCalData.imageScale; - config.imgRotation = camCalData.imageRotation; - config.imgOffsetX = camCalData.imageOffsetX; - config.imgOffsetY = camCalData.imageOffsetY; - config.imgOrigin = camCalData.imageOrigin; - config.imgCalZ = camCalData.calibrationZ; - config.imgCenterX = camCalData.centerX; - config.imgCenterY = camCalData.centerY; - - const options = fetchInterpolationOptions(props.farmwareEnvs); - config.interpolationStepSize = options.stepSize; - config.interpolationUseNearest = options.useNearest; - config.interpolationPower = options.power; - - config.topDown = isTopDown(props.designer, props.getWebAppConfigValue); - config.zoom = true; - config.pan = true; - config.rotate = !config.topDown; - config.perspective = !config.topDown; - config.viewpointHeading = - parseInt("" + props.getWebAppConfigValue(NumericSetting.viewpoint_heading)); - config.cameraSelectionView = props.designer.threeDCameraSelection; + })(); + const camCalData = React.useMemo( + () => parseCalibrationData(props.cameraCalibrationData), + [props.cameraCalibrationData]); + const options = React.useMemo( + () => fetchInterpolationOptions(props.farmwareEnvs), + [props.farmwareEnvs]); const lastCaptureTime = React.useMemo(() => { const localIds = props.logs @@ -197,9 +147,214 @@ export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { .map(log => unpackUUID(log.uuid).localId); return Math.max(0, ...localIds); }, [props.logs]); - config.lastImageCapture = lastCaptureTime; + const sunPositionConfig = valid + ? calcSunCoordinate( + get3DTime(props.designer.threeDTime).toDate(), + configValues.heading, + latitude, + longitude) + : { + azimuth: configValues.sunAzimuth, + inclination: configValues.sunInclination, + }; + const stableGridSize = React.useMemo( + () => ({ x: gridSize.x, y: gridSize.y }), + [gridSize.x, gridSize.y]); - const threeDPlants = convertPlants(config, props.plants); + const config = React.useMemo(() => { + const nextConfig = clone(INITIAL); + nextConfig.botSizeX = stableGridSize.x; + nextConfig.botSizeY = stableGridSize.y; + nextConfig.bedWidthOuter = stableGridSize.y + 160; + nextConfig.bedLengthOuter = stableGridSize.x + 280; + nextConfig.zoomBeacons = false; + nextConfig.trail = displayTrail; + nextConfig.animate = animate; + nextConfig.cameraView = cameraView; + nextConfig.kitVersion = + firmwareHardware == "farmduino_k18" ? "v1.8" : "v1.7"; + nextConfig.negativeZ = props.negativeZ; + nextConfig.exaggeratedZ = designer.threeDExaggeratedZ; + nextConfig.mirrorX = mirrorX; + nextConfig.mirrorY = mirrorY; + nextConfig.bedXOffset = configValues.bedXOffset; + nextConfig.bedYOffset = configValues.bedYOffset; + nextConfig.bedZOffset = configValues.bedZOffset; + nextConfig.distanceIndicator = designer.distanceIndicator; + nextConfig.zGantryOffset = zGantryOffset; + nextConfig.soilHeight = soilHeight; + nextConfig.bedWallThickness = configValues.bedWallThickness; + nextConfig.bedHeight = configValues.bedHeight; + nextConfig.ccSupportSize = configValues.ccSupportSize; + nextConfig.beamLength = configValues.beamLength; + nextConfig.columnLength = configValues.columnLength; + nextConfig.zAxisLength = configValues.zAxisLength; + nextConfig.legSize = configValues.legSize; + nextConfig.legsFlush = !!configValues.legsFlush; + nextConfig.extraLegsX = configValues.extraLegsX; + nextConfig.extraLegsY = configValues.extraLegsY; + nextConfig.bedBrightness = configValues.bedBrightness; + nextConfig.soilBrightness = configValues.soilBrightness; + nextConfig.clouds = !!configValues.clouds; + nextConfig.laser = !!configValues.laser; + nextConfig.stats = !!configValues.stats; + nextConfig.threeAxes = !!configValues.threeAxes; + nextConfig.solar = !!configValues.solar; + nextConfig.lowDetail = !!configValues.lowDetail; + nextConfig.eventDebug = !!configValues.eventDebug; + nextConfig.cableDebug = !!configValues.cableDebug; + nextConfig.lightsDebug = !!configValues.lightsDebug; + nextConfig.moistureDebug = !!configValues.moistureDebug; + nextConfig.surfaceDebug = configValues.surfaceDebug; + nextConfig.sun = configValues.sun; + nextConfig.ambient = configValues.ambient; + nextConfig.heading = configValues.heading; + nextConfig.bounds = !!configValues.bounds; + nextConfig.grid = !!configValues.grid; + nextConfig.tracks = !!configValues.tracks; + nextConfig.cableCarriers = !!configValues.cableCarriers; + nextConfig.axes = !!configValues.axes; + nextConfig.xyDimensions = !!configValues.xyDimensions; + nextConfig.zDimension = !!configValues.zDimension; + nextConfig.scene = SCENES[configValues.scene]; + nextConfig.people = !!configValues.people; + nextConfig.north = true; + nextConfig.desk = !!configValues.desk; + nextConfig.plants = ""; + nextConfig.sunAzimuth = sunPositionConfig.azimuth; + nextConfig.sunInclination = sunPositionConfig.inclination; + nextConfig.waterFlow = waterFlow; + nextConfig.light = light; + nextConfig.vacuum = vacuum; + nextConfig.rotary = rotary; + nextConfig.imgScale = camCalData.imageScale; + nextConfig.imgRotation = camCalData.imageRotation; + nextConfig.imgOffsetX = camCalData.imageOffsetX; + nextConfig.imgOffsetY = camCalData.imageOffsetY; + nextConfig.imgOrigin = camCalData.imageOrigin; + nextConfig.imgCalZ = camCalData.calibrationZ; + nextConfig.imgCenterX = camCalData.centerX; + nextConfig.imgCenterY = camCalData.centerY; + nextConfig.interpolationStepSize = options.stepSize; + nextConfig.interpolationUseNearest = options.useNearest; + nextConfig.interpolationPower = options.power; + nextConfig.topDown = topDown; + nextConfig.zoom = true; + nextConfig.pan = true; + nextConfig.rotate = !topDown; + nextConfig.perspective = !topDown; + nextConfig.viewpointHeading = viewpointHeading; + nextConfig.cameraSelectionView = designer.threeDCameraSelection; + nextConfig.lastImageCapture = lastCaptureTime; + return nextConfig; + }, [ + animate, + camCalData.calibrationZ, + camCalData.centerX, + camCalData.centerY, + camCalData.imageOffsetX, + camCalData.imageOffsetY, + camCalData.imageOrigin, + camCalData.imageRotation, + camCalData.imageScale, + cameraView, + configValues.ambient, + configValues.axes, + configValues.beamLength, + configValues.bedBrightness, + configValues.bedHeight, + configValues.bedWallThickness, + configValues.bedXOffset, + configValues.bedYOffset, + configValues.bedZOffset, + configValues.bounds, + configValues.cableCarriers, + configValues.cableDebug, + configValues.ccSupportSize, + configValues.clouds, + configValues.columnLength, + configValues.desk, + configValues.eventDebug, + configValues.extraLegsX, + configValues.extraLegsY, + configValues.grid, + configValues.heading, + configValues.laser, + configValues.legSize, + configValues.legsFlush, + configValues.lightsDebug, + configValues.lowDetail, + configValues.moistureDebug, + configValues.people, + configValues.scene, + configValues.soilBrightness, + configValues.solar, + configValues.stats, + configValues.sun, + configValues.surfaceDebug, + configValues.threeAxes, + configValues.tracks, + configValues.xyDimensions, + configValues.zAxisLength, + configValues.zDimension, + designer.distanceIndicator, + designer.threeDCameraSelection, + designer.threeDExaggeratedZ, + displayTrail, + firmwareHardware, + lastCaptureTime, + light, + mirrorX, + mirrorY, + options.power, + options.stepSize, + options.useNearest, + props.negativeZ, + rotary, + soilHeight, + stableGridSize.x, + stableGridSize.y, + sunPositionConfig.azimuth, + sunPositionConfig.inclination, + topDown, + vacuum, + viewpointHeading, + waterFlow, + zGantryOffset, + ]); + + const position = React.useMemo(() => { + const nextPosition = clone(INITIAL_POSITION); + nextPosition.x = props.botPosition.x || 0; + nextPosition.y = props.botPosition.y || 0; + nextPosition.z = props.botPosition.z || 0; + if (mirrorY) { nextPosition.y = stableGridSize.y - nextPosition.y; } + if (mirrorX) { nextPosition.x = stableGridSize.x - nextPosition.x; } + return nextPosition; + }, [ + mirrorX, + mirrorY, + props.botPosition.x, + props.botPosition.y, + props.botPosition.z, + stableGridSize.x, + stableGridSize.y, + ]); + const threeDPlants = React.useMemo(() => + convertPlantResources(props.plants), [props.plants]); + const addPlantProps = React.useMemo(() => ({ + gridSize: stableGridSize, + dispatch: props.dispatch, + getConfigValue: props.getWebAppConfigValue, + curves: props.curves, + designer: props.designer, + }), [ + props.curves, + props.designer, + props.dispatch, + props.getWebAppConfigValue, + stableGridSize, + ]); return { images={props.images} sensorReadings={props.sensorReadings} sensors={props.sensors} - addPlantProps={{ - gridSize: props.mapTransformProps.gridSize, - dispatch: props.dispatch, - getConfigValue: props.getWebAppConfigValue, - curves: props.curves, - designer: props.designer, - }} />; + addPlantProps={addPlantProps} />; }; +const convertPlantResources = (plants: TaggedPlant[]): ThreeDGardenPlant[] => + plants.map(plant => ({ + id: plant.body.id, + label: plant.body.name, + icon: findIcon(plant.body.openfarm_slug), + size: plant.body.radius * 2, + spread: findCrop(plant.body.openfarm_slug).spread, + x: plant.body.x, + y: plant.body.y, + key: "", + seed: 0, + })); + export const convertPlants = (_config: Config, plants: TaggedPlant[]): ThreeDGardenPlant[] => - plants.map(plant => ({ - id: plant.body.id, - label: plant.body.name, - icon: findIcon(plant.body.openfarm_slug), - size: plant.body.radius * 2, - spread: findCrop(plant.body.openfarm_slug).spread, - x: plant.body.x, - y: plant.body.y, - key: "", - seed: 0, - })); + convertPlantResources(plants); diff --git a/frontend/three_d_garden/__tests__/index_test.tsx b/frontend/three_d_garden/__tests__/index_test.tsx index 2937660acc..22eb2d6391 100644 --- a/frontend/three_d_garden/__tests__/index_test.tsx +++ b/frontend/three_d_garden/__tests__/index_test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { ThreeDGardenProps, ThreeDGarden, ThreeDGardenToggle, ThreeDGardenToggleProps, } from "../index"; +import * as reactThreeFiber from "@react-three/fiber"; import { INITIAL, INITIAL_POSITION } from "../config"; import { clone } from "lodash"; import { fakeAddPlantProps } from "../../__test_support__/fake_props"; @@ -34,6 +35,17 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("three-d-garden"); }); + + it("disables canvas shadows in low-detail mode", () => { + const canvasSpy = jest.spyOn(reactThreeFiber, "Canvas"); + const p = fakeProps(); + p.config.lowDetail = true; + render(); + expect(canvasSpy).toHaveBeenCalledWith( + expect.objectContaining({ shadows: false }), + undefined); + canvasSpy.mockRestore(); + }); }); describe("", () => { diff --git a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx index 75a4b4a518..04324976b8 100644 --- a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx @@ -38,6 +38,7 @@ import { setMockInstanceId } from "../../../__test_support__/three_d_mocks"; import { PLANT_ICON_ATLAS } from "../plant_icon_atlas"; import { Mode } from "../../../farm_designer/map/interfaces"; import * as mapUtil from "../../../farm_designer/map/util"; +import * as meshKey from "../instanced_mesh_key"; describe("", () => { let reactUseRefSpy: jest.SpyInstance; @@ -99,6 +100,13 @@ describe("", () => { expect(meshes.length).toBe(2); }); + it("doesn't build per-plant mesh keys while rendering", () => { + const keySpy = jest.spyOn(meshKey, "instancedMeshKey"); + render(); + expect(keySpy).not.toHaveBeenCalled(); + keySpy.mockRestore(); + }); + it("loads the atlas texture when an icon is mapped", () => { PLANT_ICON_ATLAS["/crops/icons/beet.avif"] = { atlasUrl: "/crops/icons/atlas.avif", @@ -223,6 +231,24 @@ describe("", () => { expect(matrix.elements[13]).toBeCloseTo(460); }); + it("skips repeated icon matrix updates until camera changes", () => { + const p = fakeProps(); + p.plants = [p.plants[0]]; + render(); + const frameFn = (useFrame as jest.Mock).mock.calls[0][0]; + const instancedRef = allRefs.find(ref => !!ref.current?.setMatrixAt); + const setMatrixAt = instancedRef?.current?.setMatrixAt as jest.Mock; + const state = { camera: { quaternion: new Quaternion() } }; + frameFn(state); + setMatrixAt.mockClear(); + frameFn(state); + expect(setMatrixAt).not.toHaveBeenCalled(); + frameFn({ + camera: { quaternion: new Quaternion(0, 0, 0.1, 1).normalize() }, + }); + expect(setMatrixAt).toHaveBeenCalled(); + }); + it("updates material brightness when changed", () => { const setScalar = jest.fn(); const instancedRef = { diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 3b1c48e7db..b434f87f0e 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -19,6 +19,7 @@ import { useFrame } from "@react-three/fiber"; import { Quaternion, WebGLProgramParametersWithUniforms } from "three"; import { Mode } from "../../../farm_designer/map/interfaces"; import * as mapUtil from "../../../farm_designer/map/util"; +import * as meshKey from "../instanced_mesh_key"; interface MockRef { current: { @@ -190,6 +191,14 @@ describe("", () => { expect(container.querySelectorAll("instancedmesh").length).toBe(1); }); + it("doesn't build per-plant spread mesh keys while rendering", () => { + const keySpy = jest.spyOn(meshKey, "instancedMeshKey"); + queueMeshRef(); + render(); + expect(keySpy).not.toHaveBeenCalled(); + keySpy.mockRestore(); + }); + it("renders spread: edit plant mode", () => { location.pathname = Path.mock(Path.plants("1")); queueMeshRef(); @@ -270,6 +279,23 @@ describe("", () => { expect(meshRef?.current?.instanceMatrix?.needsUpdate).toBeFalsy(); }); + it("skips repeated inactive spread frame updates", () => { + queueMeshRef(); + const p = fakeProps(); + p.spreadVisible = false; + render(); + const meshRef = allRefs[0]; + meshRef.current = buildMeshRef(); + const mesh = meshRef.current; + const setMatrixAt = mesh?.setMatrixAt as jest.Mock; + const frameFn = (useFrame as jest.Mock).mock.calls[0][0]; + const state = { camera: { quaternion: new Quaternion() } }; + frameFn(state); + setMatrixAt.mockClear(); + frameFn(state); + expect(setMatrixAt).not.toHaveBeenCalled(); + }); + it("handles missing mesh in layout effect", () => { reactUseImperativeHandleSpy.mockImplementation(() => undefined); reactUseRefSpy.mockImplementation(() => ({ current: undefined }) as never); @@ -288,7 +314,9 @@ describe("", () => { { current: undefined as unknown as { x: number; y: number } }; location.pathname = Path.mock(Path.plants("1")); render(); - const mesh = getMeshRef()?.current as + const meshRef = allRefs[0]; + meshRef.current = buildMeshRef(); + const mesh = meshRef.current as (MockRef["current"] & { material: { needsUpdate: boolean } }) | undefined; if (mesh) { mesh.instanceColor = { needsUpdate: false, count: 0 }; diff --git a/frontend/three_d_garden/garden/__tests__/sun_test.tsx b/frontend/three_d_garden/garden/__tests__/sun_test.tsx index 60c221874d..642f5efbc3 100644 --- a/frontend/three_d_garden/garden/__tests__/sun_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/sun_test.tsx @@ -97,6 +97,14 @@ describe("", () => { expect(left).toBeLessThanOrEqual(-minBound); }); + it("disables shadows in low-detail mode", () => { + const p = fakeProps(); + p.config.lowDetail = true; + const { container } = render(); + const light = container.querySelector("directionallight"); + expect(light?.getAttribute("castshadow")).not.toEqual("true"); + }); + it("renders animated without ref", () => { const p = fakeProps(); p.config.animateSeasons = true; diff --git a/frontend/three_d_garden/garden/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx index 74762020cb..5b67931d0c 100644 --- a/frontend/three_d_garden/garden/plant_instances.tsx +++ b/frontend/three_d_garden/garden/plant_instances.tsx @@ -26,7 +26,6 @@ import { import { Mode } from "../../farm_designer/map/interfaces"; import moment from "moment"; import { calcSunCoordinate, calcSunI, getCycleLength } from "./sun"; -import { instancedMeshKey } from "./instanced_mesh_key"; export interface PlantInstancesProps { plants: ThreeDGardenPlant[]; @@ -43,6 +42,18 @@ interface PlantIconInstancesProps extends PlantInstancesProps { plantIndexes: number[]; } +interface PlantIconUpdateState { + lastCameraQuaternion: Quaternion; + hasCameraQuaternion: boolean; + needsMatrixUpdate: boolean; +} + +const newPlantIconUpdateState = (): PlantIconUpdateState => ({ + lastCameraQuaternion: new Quaternion(), + hasCameraQuaternion: false, + needsMatrixUpdate: true, +}); + export const plantIconBrightness = (sunFactor?: number) => Math.max(0.25, sunFactor ?? 1); @@ -61,6 +72,16 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { // eslint-disable-next-line no-null/no-null const materialRef = React.useRef(null); const lastBrightness = React.useRef(undefined); + const updateStateRef = + React.useRef(newPlantIconUpdateState()); + const getUpdateState = () => { + const current = + updateStateRef.current as Partial | undefined; + if (!current?.lastCameraQuaternion) { + updateStateRef.current = newPlantIconUpdateState(); + } + return updateStateRef.current; + }; const tempMatrix = React.useMemo(() => new Matrix4(), []); const tempPosition = React.useMemo(() => new Vector3(), []); const tempScale = React.useMemo(() => new Vector3(), []); @@ -71,11 +92,22 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { + getZ(plant.x, plant.y) + size / 2, [config, getZ]); + React.useEffect(() => { + const updateState = getUpdateState(); + updateState.needsMatrixUpdate = true; + lastBrightness.current = undefined; + }); + + // eslint-disable-next-line complexity useFrame(state => { const mesh = instancedRef.current; - if (!mesh) { return; } + if (!mesh || visible === false) { return; } + const updateState = getUpdateState(); + const seasonAnimating = !!(config.animateSeasons && startTimeRef); + const cameraChanged = !updateState.hasCameraQuaternion + || !updateState.lastCameraQuaternion.equals(state.camera.quaternion); let sunFactor = calcSunI(config.sunInclination); - if (config.animateSeasons && startTimeRef) { + if (seasonAnimating) { const totalCycle = getCycleLength(config.plants); const currentTime = performance.now() / 1000; const t = currentTime - (startTimeRef.current || 0); @@ -90,6 +122,9 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { materialRef.current.color.setScalar(brightness); lastBrightness.current = brightness; } + if (!updateState.needsMatrixUpdate && !seasonAnimating && !cameraChanged) { + return; + } tempQuaternion.copy(state.camera.quaternion); const currentTime = performance.now() / 1000; const t = startTimeRef ? currentTime - (startTimeRef.current || 0) : 0; @@ -108,6 +143,9 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { mesh.setMatrixAt(index, tempMatrix); }); mesh.instanceMatrix.needsUpdate = true; + updateState.lastCameraQuaternion.copy(state.camera.quaternion); + updateState.hasCameraQuaternion = true; + updateState.needsMatrixUpdate = false; }); const onClick = (event: ThreeEvent) => { @@ -122,7 +160,6 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { }; return { ; }; -export const PlantInstances = (props: PlantInstancesProps) => { +export const PlantInstances = React.memo((props: PlantInstancesProps) => { const instances = React.useMemo(() => { const iconInstances: Record = {}; props.plants.forEach((plant, index) => { @@ -148,20 +185,31 @@ export const PlantInstances = (props: PlantInstancesProps) => { instance.plantIndexes.push(index); } else { iconInstances[plant.icon] = { - ...props, + config: props.config, + dispatch: props.dispatch, + getZ: props.getZ, icon: plant.icon, plants: [plant], plantIndexes: [index], + startTimeRef: props.startTimeRef, + visible: props.visible, }; } }); return Object.values(iconInstances); - }, [props]); + }, [ + props.config, + props.dispatch, + props.getZ, + props.plants, + props.startTimeRef, + props.visible, + ]); return <> {instances.map(instance => )} ; -}; +}); diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index 34c9527ee3..bfa68db5e2 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -32,7 +32,6 @@ import { import { ActivePositionRef } from "../bed/objects/pointer_objects"; import { Mode } from "../../farm_designer/map/interfaces"; import { findCrop } from "../../crops/find"; -import { instancedMeshKey } from "./instanced_mesh_key"; export interface ThreeDGardenPlant { id?: number | undefined; @@ -104,7 +103,15 @@ export interface PlantSpreadInstancesProps { spreadVisible: boolean; } -export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { +interface PlantSpreadUpdateState { + needsInstanceUpdate: boolean; +} + +const newPlantSpreadUpdateState = (): PlantSpreadUpdateState => ({ + needsInstanceUpdate: true, +}); + +export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps) => { const { config, plants, getZ, visible, dispatch, activePositionRef, spreadVisible, } = props; @@ -116,6 +123,16 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { const tempScale = React.useMemo(() => new Vector3(), []); const tempQuaternion = React.useMemo(() => new Quaternion(), []); const tempColor = React.useMemo(() => new Color(), []); + const updateStateRef = + React.useRef(newPlantSpreadUpdateState()); + const getUpdateState = () => { + const current = + updateStateRef.current as Partial | undefined; + if (typeof current?.needsInstanceUpdate != "boolean") { + updateStateRef.current = newPlantSpreadUpdateState(); + } + return updateStateRef.current; + }; const get3DPosition = React.useMemo(() => get3DPositionFunc(config), [config]); // eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/use-memo const boundsCenter = React.useMemo(getBoundsCenter(config), []); @@ -135,6 +152,8 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { const activeDragSpread = editPlantMode ? currentPlant?.spread : findCrop(Path.getCropSlug()).spread; + const hasTransientPlant = React.useMemo(() => + plants.some(plant => !plant.id), [plants]); const ensureInstanceColor = React.useCallback((mesh: InstancedMeshType) => { const needsResize = !mesh.instanceColor @@ -162,9 +181,20 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { ensureInstanceColor(mesh); }, [ensureInstanceColor]); + React.useEffect(() => { + const updateState = getUpdateState(); + updateState.needsInstanceUpdate = true; + }); + + // eslint-disable-next-line complexity useFrame(state => { const mesh = instancedRef.current; if (!mesh || visible === false) { return; } + const updateState = getUpdateState(); + const clickToAddMode = getMode() == Mode.clickToAdd; + const spreadActive = + spreadVisible || editPlantMode || clickToAddMode || hasTransientPlant; + if (!spreadActive && !updateState.needsInstanceUpdate) { return; } ensureInstanceColor(mesh); tempQuaternion.copy(state.camera.quaternion); const worldPos = activePositionRef.current || { x: -10000, y: -10000 }; @@ -178,7 +208,6 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { x: activePointer.x, y: activePointer.y, }; - const clickToAddMode = getMode() == Mode.clickToAdd; plants.forEach((plant, index) => { const spreadRadii = getSpreadRadii({ activeDragSpread, @@ -222,6 +251,7 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { }); mesh.instanceMatrix.needsUpdate = true; if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; } + updateState.needsInstanceUpdate = false; }); const onClick = (event: ThreeEvent) => { @@ -236,7 +266,7 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { }; return { }} depthWrite={false} /> ; -}; +}); export const getBoundsCenter = (config: Config) => () => diff --git a/frontend/three_d_garden/garden/sun.tsx b/frontend/three_d_garden/garden/sun.tsx index 7ea48ccfc9..dd84efd6fc 100644 --- a/frontend/three_d_garden/garden/sun.tsx +++ b/frontend/three_d_garden/garden/sun.tsx @@ -238,6 +238,7 @@ export const Sun = (props: SunProps) => { const position = offsetSunPos(sunPos, index); const color = SUN_COLOR[index]; const intensity = sunIntensity * config.sun / 100 * renderedSunFactor; + const castShadow = !config.lowDetail; return { @@ -245,7 +246,7 @@ export const Sun = (props: SunProps) => { }} intensity={intensity * 4 / SUN_COUNT} color={sunColor} - castShadow={true} + castShadow={castShadow} shadow-bias={shadowBias} shadow-radius={shadowRadius} shadow-blurSamples={shadowBlurSamples} diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index c508c6a54b..ba4ba5e235 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -119,15 +119,20 @@ export const GardenModel = (props: GardenModelProps) => { const topDownCameraAngle = config.topDown ? baseAngle + heading * Math.PI / 180 : undefined; - const camera = getCamera( - config, - props.configPosition, - props.activeFocus, - cameraInit({ + const defaultCamera = React.useMemo( + () => cameraInit({ topDown: config.topDown, viewpointHeading: config.viewpointHeading, bedSize: { x: config.bedLengthOuter, y: config.bedWidthOuter }, - })); + }), [ + config.bedLengthOuter, + config.bedWidthOuter, + config.topDown, + config.viewpointHeading, + ]); + const camera = props.activeFocus + ? getCamera(config, props.configPosition, props.activeFocus, defaultCamera) + : defaultCamera; const [controlsCamera, setControlsCamera] = // eslint-disable-next-line no-null/no-null React.useState(null); @@ -338,10 +343,11 @@ export const GardenModel = (props: GardenModelProps) => { config={config} tryGroupSortType={props.addPlantProps?.designer.tryGroupSortType} getZ={getZ} /> - + {props.addPlantProps?.designer.visualizedSequence && + } diff --git a/frontend/three_d_garden/index.tsx b/frontend/three_d_garden/index.tsx index 722dec76cd..0fa92d837b 100644 --- a/frontend/three_d_garden/index.tsx +++ b/frontend/three_d_garden/index.tsx @@ -56,7 +56,7 @@ export const ThreeDGarden = (props: ThreeDGardenProps) => { }> { gl.localClippingEnabled = true; }}> From 1b559949ec3c952c695a016a337546fb5377e1bf Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 23 Apr 2026 18:59:51 -0700 Subject: [PATCH 03/83] Instance point and weed rendering Genesis XL v1.8 3D object-count benchmark improved render-cost metrics: draw calls dropped from 310 to 254 (-18.1%) and geometries from 266 to 236 (-11.3%). FPS stayed stable at 117.09 avg / 119.99 median versus 116.42 avg / 120.00 median baseline, and JS heap stayed flat at 286 MB. Total scene objects dropped from 3170 to 3108 (-62 objects, -2.0%); the full scene is dominated by non-point objects, so draw calls/geometries are the meaningful gate for this scoped change.\n\nChecks: bun test frontend/three_d_garden/garden/__tests__/point_test.tsx frontend/three_d_garden/garden/__tests__/weed_test.tsx frontend/three_d_garden/__tests__/garden_model_test.tsx frontend/three_d_garden/__tests__/triangle_functions_test.ts; bunx eslint --no-cache changed object-count files; bun run typecheck --- .../__tests__/garden_model_test.tsx | 5 +- .../__tests__/triangle_functions_test.ts | 20 ++ .../garden/__tests__/point_test.tsx | 47 +++- .../garden/__tests__/weed_test.tsx | 41 +++- frontend/three_d_garden/garden/point.tsx | 200 ++++++++++++++- frontend/three_d_garden/garden/weed.tsx | 231 +++++++++++++++++- frontend/three_d_garden/garden_model.tsx | 38 ++- frontend/three_d_garden/triangle_functions.ts | 15 +- 8 files changed, 561 insertions(+), 36 deletions(-) diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 36974f2ccb..5ce1801f44 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -11,7 +11,6 @@ import { fakePlant, fakePoint, fakeSensor, fakeSensorReading, fakeWeed, } from "../../__test_support__/fake_state/resources"; import { fakeAddPlantProps } from "../../__test_support__/fake_props"; -import { ASSETS } from "../constants"; import { Path } from "../../internal_urls"; import { fakeDrawnPoint } from "../../__test_support__/fake_designer_state"; import { convertPlants } from "../../farm_designer/three_d_garden_map"; @@ -216,8 +215,8 @@ describe("", () => { p.mapPoints = [fakePoint()]; p.weeds = [fakeWeed()]; const { container } = render(); - expect(container.innerHTML).toContain("cylinder"); - expect(container.innerHTML).toContain(ASSETS.other.weed); + expect(container.innerHTML).toContain("marker"); + expect(container.innerHTML).toContain("weed-icons"); }); it("renders drawn point", () => { diff --git a/frontend/three_d_garden/__tests__/triangle_functions_test.ts b/frontend/three_d_garden/__tests__/triangle_functions_test.ts index 1c3332409e..471e513db2 100644 --- a/frontend/three_d_garden/__tests__/triangle_functions_test.ts +++ b/frontend/three_d_garden/__tests__/triangle_functions_test.ts @@ -48,4 +48,24 @@ describe("getZFunc()", () => { y3: 2, }], -100)(1, 1)).toEqual(25); }); + + it("caches Z by coordinate and invalidates with a new function", () => { + const triangle = { + a: [0, 0, 10] as [number, number, number], + b: [2, 0, 20] as [number, number, number], + c: [0, 2, 30] as [number, number, number], + det: 4, + x1: 0, + x2: 2, + x3: 0, + y1: 0, + y2: 0, + y3: 2, + }; + const getZ = getZFunc([triangle], -100); + expect(getZ(1, 1)).toEqual(25); + triangle.c[2] = 300; + expect(getZ(1, 1)).toEqual(25); + expect(getZFunc([triangle], -100)(1, 1)).toEqual(160); + }); }); diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx index 7326c664bc..0ec36b3a79 100644 --- a/frontend/three_d_garden/garden/__tests__/point_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx @@ -1,6 +1,9 @@ import React from "react"; import { fireEvent, render } from "@testing-library/react"; -import { DrawnPoint, DrawnPointProps, Point, PointProps } from "../point"; +import { + DrawnPoint, DrawnPointProps, Point, PointInstances, PointInstancesProps, + PointProps, +} from "../point"; import { INITIAL } from "../../config"; import { clone } from "lodash"; import { fakePoint } from "../../../__test_support__/fake_state/resources"; @@ -11,12 +14,23 @@ import { fakeDesignerState, fakeDrawnPoint, } from "../../../__test_support__/fake_designer_state"; import { SpecialStatus } from "farmbot"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; describe("", () => { + const mountedWrappers: ReturnType[] = []; + beforeEach(() => { location.pathname = Path.mock(Path.points()); }); + afterEach(() => { + mountedWrappers.splice(0).forEach(wrapper => + unmountRenderer(wrapper)); + }); + const fakeProps = (): PointProps => ({ config: clone(INITIAL), point: fakePoint(), @@ -73,6 +87,37 @@ describe("", () => { point && fireEvent.click(point); expect(mockNavigate).not.toHaveBeenCalled(); }); + + const fakeInstanceProps = (): PointInstancesProps => ({ + config: clone(INITIAL), + points: [fakePoint(), fakePoint()], + visible: true, + getZ: () => 0, + }); + + it("renders instanced point markers", () => { + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const meshes = wrapper.root.findAll(node => node.type == "instancedMesh"); + expect(meshes.length).toEqual(3); + expect(meshes[0].props.args[2]).toEqual(2); + }); + + it("navigates from a point instance", () => { + const p = fakeInstanceProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.points[0].body.id = 1; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const marker = wrapper.root + .findAll(node => node.props.name == "marker")[0]; + marker.props.onClick({ instanceId: 0 }); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_PANEL_OPEN, payload: true, + }); + expect(mockNavigate).toHaveBeenCalledWith(Path.points("1")); + }); }); describe("", () => { diff --git a/frontend/three_d_garden/garden/__tests__/weed_test.tsx b/frontend/three_d_garden/garden/__tests__/weed_test.tsx index d321f52d97..5e2dbb7a20 100644 --- a/frontend/three_d_garden/garden/__tests__/weed_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/weed_test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { fireEvent, render } from "@testing-library/react"; -import { Weed, WeedProps } from "../weed"; +import { Weed, WeedInstances, WeedInstancesProps, WeedProps } from "../weed"; import { INITIAL } from "../../config"; import { clone } from "lodash"; import { fakeWeed } from "../../../__test_support__/fake_state/resources"; @@ -9,15 +9,22 @@ import { Actions } from "../../../constants"; import { mockDispatch } from "../../../__test_support__/fake_dispatch"; import * as mapUtil from "../../../farm_designer/map/util"; import { Mode } from "../../../farm_designer/map/interfaces"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; describe("", () => { let getModeSpy: jest.SpyInstance; + const mountedWrappers: ReturnType[] = []; beforeEach(() => { getModeSpy = jest.spyOn(mapUtil, "getMode").mockReturnValue(Mode.none); }); afterEach(() => { + mountedWrappers.splice(0).forEach(wrapper => + unmountRenderer(wrapper)); getModeSpy.mockRestore(); }); @@ -68,4 +75,36 @@ describe("", () => { weed && fireEvent.click(weed); expect(mockNavigate).not.toHaveBeenCalled(); }); + + const fakeInstanceProps = (): WeedInstancesProps => ({ + config: clone(INITIAL), + weeds: [fakeWeed(), fakeWeed()], + visible: true, + getZ: () => 0, + }); + + it("renders instanced weeds", () => { + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const meshes = wrapper.root.findAll(node => node.type == "instancedMesh"); + expect(meshes.length).toEqual(2); + expect(meshes[0].props.name).toEqual("weed-icons"); + expect(meshes[1].props.name).toEqual("weed-radius"); + }); + + it("navigates from a weed instance", () => { + const p = fakeInstanceProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.weeds[0].body.id = 1; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const weedIcons = wrapper.root + .findAll(node => node.props.name == "weed-icons")[0]; + weedIcons.props.onClick({ instanceId: 0 }); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_PANEL_OPEN, payload: true, + }); + expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1")); + }); }); diff --git a/frontend/three_d_garden/garden/point.tsx b/frontend/three_d_garden/garden/point.tsx index d60892698d..dd530105fc 100644 --- a/frontend/three_d_garden/garden/point.tsx +++ b/frontend/three_d_garden/garden/point.tsx @@ -1,9 +1,19 @@ import React from "react"; import { SpecialStatus, TaggedGenericPointer, Xyz } from "farmbot"; import { Config } from "../config"; -import { Group, MeshPhongMaterial } from "../components"; +import { + Group, InstancedMesh, MeshPhongMaterial, +} from "../components"; import { Cylinder, Sphere, Torus } from "@react-three/drei"; -import { DoubleSide } from "three"; +import { + DoubleSide, + Euler, + InstancedMesh as InstancedMeshType, + Matrix4, + Quaternion, + Vector3, +} from "three"; +import { ThreeEvent } from "@react-three/fiber"; import { getWorldPositionFunc } from "../helpers"; import { useNavigate } from "react-router"; import { Path } from "../../internal_urls"; @@ -60,6 +70,192 @@ export const Point = (props: PointProps) => { />; }; +interface PointInstance { + point: TaggedGenericPointer; + position: [number, number, number]; + radius: number; +} + +interface PointInstanceBucket { + color: string | undefined; + alpha: number; + points: PointInstance[]; + ringPoints: PointInstance[]; +} + +export interface PointInstancesProps { + points: TaggedGenericPointer[]; + config: Config; + dispatch?: Function; + visible: boolean; + getZ(x: number, y: number): number; +} + +const pointAlpha = (point: TaggedGenericPointer) => + point.specialStatus !== SpecialStatus.SAVED ? 0.5 : 1; + +const pointBucketKey = (point: TaggedGenericPointer) => + `${point.body.meta.color || ""}-${pointAlpha(point)}`; + +const getPointInstanceBuckets = ( + points: TaggedGenericPointer[], + config: Config, + getZ: (x: number, y: number) => number, +) => { + const getWorldPosition = getWorldPositionFunc(config); + const buckets: Record = {}; + points.forEach(point => { + const alpha = pointAlpha(point); + const key = pointBucketKey(point); + const instance = { + point, + position: getWorldPosition({ + x: point.body.x, + y: point.body.y, + z: getZ(point.body.x, point.body.y), + }), + radius: point.body.radius, + }; + buckets[key] ||= { + color: point.body.meta.color, + alpha, + points: [], + ringPoints: [], + }; + buckets[key].points.push(instance); + if (point.body.radius > 0) { buckets[key].ringPoints.push(instance); } + }); + return Object.values(buckets); +}; + +interface PointInstanceBucketProps extends PointInstancesProps { + bucket: PointInstanceBucket; +} + +const PointBucketInstances = (props: PointInstanceBucketProps) => { + const { bucket, dispatch, visible } = props; + const navigate = useNavigate(); + // eslint-disable-next-line no-null/no-null + const pinRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null + const sphereRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null + const ringRef = React.useRef(null); + const tempMatrix = React.useMemo(() => new Matrix4(), []); + const tempPosition = React.useMemo(() => new Vector3(), []); + const pinRotation = React.useMemo(() => + new Quaternion().setFromEuler(new Euler(Math.PI / 2, 0, 0)), []); + const noRotation = React.useMemo(() => new Quaternion(), []); + const noScale = React.useMemo(() => new Vector3(1, 1, 1), []); + const ringScale = React.useMemo(() => new Vector3(), []); + + React.useEffect(() => { + const pinMesh = pinRef.current; + const sphereMesh = sphereRef.current; + if (!pinMesh?.setMatrixAt || !sphereMesh?.setMatrixAt) { return; } + bucket.points.forEach((instance, index) => { + const [x, y, z] = instance.position; + tempPosition.set(x, y, z + POINT_PIN_HEIGHT / 2); + tempMatrix.compose(tempPosition, pinRotation, noScale); + pinMesh.setMatrixAt(index, tempMatrix); + tempPosition.set(x, y, z + POINT_PIN_HEIGHT); + tempMatrix.compose(tempPosition, noRotation, noScale); + sphereMesh.setMatrixAt(index, tempMatrix); + }); + pinMesh.instanceMatrix.needsUpdate = true; + sphereMesh.instanceMatrix.needsUpdate = true; + }, [bucket.points, noRotation, noScale, pinRotation, tempMatrix, tempPosition]); + + React.useEffect(() => { + const ringMesh = ringRef.current; + if (!ringMesh?.setMatrixAt) { return; } + bucket.ringPoints.forEach((instance, index) => { + const [x, y, z] = instance.position; + tempPosition.set(x, y, z); + ringScale.set( + instance.radius, + instance.radius, + POINT_CYLINDER_SCALE_FACTOR, + ); + tempMatrix.compose(tempPosition, noRotation, ringScale); + ringMesh.setMatrixAt(index, tempMatrix); + }); + ringMesh.instanceMatrix.needsUpdate = true; + }, [bucket.ringPoints, noRotation, ringScale, tempMatrix, tempPosition]); + + const onClick = (instances: PointInstance[]) => + (event: ThreeEvent) => { + const instanceId = event.instanceId; + if (isUndefined(instanceId)) { return; } + const point = instances[instanceId]?.point; + if (point?.body.id && dispatch && visible && + !HOVER_OBJECT_MODES.includes(getMode())) { + dispatch(setPanelOpen(true)); + navigate(Path.points(point.body.id)); + } + }; + + return <> + + + + + + + + + {bucket.ringPoints.length > 0 && + + + + } + ; +}; + +export const PointInstances = React.memo((props: PointInstancesProps) => { + const buckets = React.useMemo( + () => getPointInstanceBuckets(props.points, props.config, props.getZ), + [props.points, props.config, props.getZ]); + return <> + {buckets.map(bucket => + )} + ; +}); + export interface DrawnPointProps { designer: DesignerState; usePosition: boolean; diff --git a/frontend/three_d_garden/garden/weed.tsx b/frontend/three_d_garden/garden/weed.tsx index fc8bc6b67a..56d1e461d7 100644 --- a/frontend/three_d_garden/garden/weed.tsx +++ b/frontend/three_d_garden/garden/weed.tsx @@ -2,8 +2,17 @@ import React from "react"; import { TaggedWeedPointer, Xyz } from "farmbot"; import { Config } from "../config"; import { ASSETS, HOVER_OBJECT_MODES, RenderOrder } from "../constants"; -import { Group, MeshPhongMaterial } from "../components"; -import { Image, Billboard, Sphere } from "@react-three/drei"; +import { + Group, InstancedMesh, MeshBasicMaterial, MeshPhongMaterial, PlaneGeometry, +} from "../components"; +import { Image, Billboard, Sphere, useTexture } from "@react-three/drei"; +import { + InstancedMesh as InstancedMeshType, + Matrix4, + Quaternion, + Vector3, +} from "three"; +import { ThreeEvent, useFrame } from "@react-three/fiber"; import { getWorldPositionFunc } from "../helpers"; import { useNavigate } from "react-router"; import { Path } from "../../internal_urls"; @@ -99,3 +108,221 @@ export const WeedBase = (props: WeedBaseProps) => { ; }; + +interface WeedInstance { + weed: TaggedWeedPointer; + position: [number, number, number]; + weedSize: number; + iconSize: number; +} + +interface WeedColorBucket { + color: string | undefined; + weeds: WeedInstance[]; +} + +export interface WeedInstancesProps { + weeds: TaggedWeedPointer[]; + config: Config; + dispatch?: Function; + visible: boolean; + getZ(x: number, y: number): number; +} + +const weedSize = (weed: TaggedWeedPointer) => + weed.body.radius == 0 ? 50 : weed.body.radius; + +const getWeedInstances = ( + weeds: TaggedWeedPointer[], + config: Config, + getZ: (x: number, y: number) => number, +) => { + const getWorldPosition = getWorldPositionFunc(config); + return weeds.map(weed => { + const size = weedSize(weed); + return { + weed, + position: getWorldPosition({ + x: weed.body.x, + y: weed.body.y, + z: getZ(weed.body.x, weed.body.y), + }), + weedSize: size, + iconSize: size * WEED_IMG_SIZE_FRACTION, + }; + }); +}; + +const getWeedColorBuckets = (weeds: WeedInstance[]) => { + const buckets: Record = {}; + weeds.forEach(weed => { + const color = weed.weed.body.meta.color; + const key = color || ""; + buckets[key] ||= { color, weeds: [] }; + buckets[key].weeds.push(weed); + }); + return Object.values(buckets); +}; + +interface WeedIconUpdateState { + lastCameraQuaternion: Quaternion; + hasCameraQuaternion: boolean; + needsMatrixUpdate: boolean; +} + +const newWeedIconUpdateState = (): WeedIconUpdateState => ({ + lastCameraQuaternion: new Quaternion(), + hasCameraQuaternion: false, + needsMatrixUpdate: true, +}); + +const useNavigateToWeed = ( + dispatch: Function | undefined, + visible: boolean, +) => { + const navigate = useNavigate(); + return (weed: TaggedWeedPointer | undefined) => { + if (weed?.body.id && dispatch && visible && + !HOVER_OBJECT_MODES.includes(getMode())) { + dispatch(setPanelOpen(true)); + navigate(Path.weeds(weed.body.id)); + } + }; +}; + +interface WeedIconInstancesProps extends WeedInstancesProps { + weedInstances: WeedInstance[]; +} + +const WeedIconInstances = (props: WeedIconInstancesProps) => { + const { weedInstances, dispatch, visible } = props; + const texture = useTexture(ASSETS.other.weed); + const navigateToWeed = useNavigateToWeed(dispatch, visible); + // eslint-disable-next-line no-null/no-null + const instancedRef = React.useRef(null); + const updateStateRef = + React.useRef(newWeedIconUpdateState()); + const getUpdateState = () => { + const current = + updateStateRef.current as Partial | undefined; + if (!current?.lastCameraQuaternion) { + updateStateRef.current = newWeedIconUpdateState(); + } + return updateStateRef.current; + }; + const tempMatrix = React.useMemo(() => new Matrix4(), []); + const tempPosition = React.useMemo(() => new Vector3(), []); + const tempScale = React.useMemo(() => new Vector3(), []); + const tempQuaternion = React.useMemo(() => new Quaternion(), []); + + React.useEffect(() => { + getUpdateState().needsMatrixUpdate = true; + }, [weedInstances]); + + useFrame(state => { + const mesh = instancedRef.current; + if (!mesh?.setMatrixAt || visible === false) { return; } + const updateState = getUpdateState(); + const cameraChanged = !updateState.hasCameraQuaternion + || !updateState.lastCameraQuaternion.equals(state.camera.quaternion); + if (!updateState.needsMatrixUpdate && !cameraChanged) { return; } + tempQuaternion.copy(state.camera.quaternion); + weedInstances.forEach((weed, index) => { + const [x, y, z] = weed.position; + tempPosition.set(x, y, z + weed.iconSize / 2); + tempScale.set(weed.iconSize, weed.iconSize, 1); + tempMatrix.compose(tempPosition, tempQuaternion, tempScale); + mesh.setMatrixAt(index, tempMatrix); + }); + mesh.instanceMatrix.needsUpdate = true; + updateState.lastCameraQuaternion.copy(state.camera.quaternion); + updateState.hasCameraQuaternion = true; + updateState.needsMatrixUpdate = false; + }); + + const onClick = (event: ThreeEvent) => { + const instanceId = event.instanceId; + if (isUndefined(instanceId)) { return; } + navigateToWeed(weedInstances[instanceId]?.weed); + }; + + return + + + ; +}; + +interface WeedRadiusInstancesProps extends WeedInstancesProps { + bucket: WeedColorBucket; +} + +const WeedRadiusInstances = (props: WeedRadiusInstancesProps) => { + const { bucket, dispatch, visible } = props; + const navigateToWeed = useNavigateToWeed(dispatch, visible); + // eslint-disable-next-line no-null/no-null + const instancedRef = React.useRef(null); + const tempMatrix = React.useMemo(() => new Matrix4(), []); + const tempPosition = React.useMemo(() => new Vector3(), []); + const noRotation = React.useMemo(() => new Quaternion(), []); + const tempScale = React.useMemo(() => new Vector3(), []); + + React.useEffect(() => { + const mesh = instancedRef.current; + if (!mesh?.setMatrixAt) { return; } + bucket.weeds.forEach((weed, index) => { + const [x, y, z] = weed.position; + tempPosition.set(x, y, z + weed.iconSize / 2); + tempScale.set(weed.weedSize, weed.weedSize, weed.weedSize); + tempMatrix.compose(tempPosition, noRotation, tempScale); + mesh.setMatrixAt(index, tempMatrix); + }); + mesh.instanceMatrix.needsUpdate = true; + }, [bucket.weeds, noRotation, tempMatrix, tempPosition, tempScale]); + + const onClick = (event: ThreeEvent) => { + const instanceId = event.instanceId; + if (isUndefined(instanceId)) { return; } + navigateToWeed(bucket.weeds[instanceId]?.weed); + }; + + return + + + ; +}; + +export const WeedInstances = React.memo((props: WeedInstancesProps) => { + const weedInstances = React.useMemo( + () => getWeedInstances(props.weeds, props.config, props.getZ), + [props.weeds, props.config, props.getZ]); + const buckets = React.useMemo( + () => getWeedColorBuckets(weedInstances), + [weedInstances]); + return <> + + {buckets.map(bucket => + )} + ; +}); diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index ba4ba5e235..8f1f7b7fe3 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -19,7 +19,7 @@ import { Sky, Solar, Sun, sunPosition, ZoomBeacons, PlantInstances, PlantSpreadInstances, - Point, Grid, Clouds, Ground, Weed, + PointInstances, Grid, Clouds, Ground, WeedInstances, ThreeDGardenPlant, NorthArrow, skyColor, @@ -195,26 +195,6 @@ export const GardenModel = (props: GardenModelProps) => { }, [threeDPlants, config, getZ, hoveredPlant]); - const pointNodes = React.useMemo( - () => props.mapPoints?.map(point => - ), - [props.mapPoints, showPoints, config, getZ, dispatch]); - - const weedNodes = React.useMemo( - () => props.weeds?.map(weed => - ), - [props.weeds, showWeeds, config, getZ, dispatch]); - // eslint-disable-next-line no-null/no-null return { - {pointNodes} + {(props.mapPoints?.length || 0) > 0 && + } - {weedNodes} + {(props.weeds?.length || 0) > 0 && + } - (x: number, y: number) => { +) => { + const cache: Record = {}; + return (x: number, y: number) => { + const key = `${x},${y}`; + const cached = cache[key]; + if (cached !== undefined) { return cached; } for (const t of triangles) { const { a, b, c, x1, y1, x2, y2, x3, y3, det } = t; const l1 = ((y2 - y3) * (x - x3) + (x3 - x2) * (y - y3)) / det; @@ -46,8 +50,11 @@ export const getZFunc = ( const l3 = 1 - l1 - l2; if (l1 >= 0 && l2 >= 0 && l3 >= 0) { - return l1 * a[2] + l2 * b[2] + l3 * c[2]; + cache[key] = l1 * a[2] + l2 * b[2] + l3 * c[2]; + return cache[key]; } } - return fallback; + cache[key] = fallback; + return cache[key]; }; +}; From 03c15393c0c8889c43df29a968239c640d6e9c18 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 23 Apr 2026 19:16:42 -0700 Subject: [PATCH 04/83] Add stress demo seed accounts --- app/mutations/devices/create_seed_data.rb | 14 +- .../devices/seeders/demo_account_seeder.rb | 9 +- app/mutations/devices/seeders/stress_data.rb | 220 ++++++++++++++++++ frontend/messages/__tests__/cards_test.tsx | 12 + frontend/messages/cards.tsx | 8 + .../devices/create_seed_data_spec.rb | 18 ++ .../devices/seeders/stress_data_spec.rb | 33 +++ 7 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 app/mutations/devices/seeders/stress_data.rb create mode 100644 spec/mutations/devices/seeders/stress_data_spec.rb diff --git a/app/mutations/devices/create_seed_data.rb b/app/mutations/devices/create_seed_data.rb index 2ef30c595f..a5e8da45aa 100644 --- a/app/mutations/devices/create_seed_data.rb +++ b/app/mutations/devices/create_seed_data.rb @@ -1,5 +1,11 @@ module Devices class CreateSeedData < Mutations::Command + STRESS_DEMO_ONLY = + "Stress product lines are only available for demo accounts." + STRESS_PRODUCT_LINES = + Devices::Seeders::StressData::PRODUCT_LINES + .transform_values { Devices::Seeders::GenesisXlOneEight } + PRODUCT_LINES = { "express_1.0" => Devices::Seeders::ExpressOneZero, "express_1.1" => Devices::Seeders::ExpressOneOne, @@ -22,7 +28,7 @@ class CreateSeedData < Mutations::Command "genesis_xl_1.8" => Devices::Seeders::GenesisXlOneEight, "none" => Devices::Seeders::None, - } + }.merge(STRESS_PRODUCT_LINES) required do model :device @@ -33,6 +39,12 @@ class CreateSeedData < Mutations::Command boolean :demo end + def validate + if Devices::Seeders::StressData.stress?(product_line) && !demo + add_error(:product_line, :demo_only, STRESS_DEMO_ONLY) + end + end + def execute if device.account_seeded_at return { done: "Device already has seed data." } diff --git a/app/mutations/devices/seeders/demo_account_seeder.rb b/app/mutations/devices/seeders/demo_account_seeder.rb index 960f4caf90..f09063a11a 100644 --- a/app/mutations/devices/seeders/demo_account_seeder.rb +++ b/app/mutations/devices/seeders/demo_account_seeder.rb @@ -187,8 +187,13 @@ def before_product_line_seeder def after_product_line_seeder(product_line) create_webcam_feed(product_line) - add_plants(product_line) - add_soil_height_points(product_line) + stress_count = Devices::Seeders::StressData.count_for(product_line) + if stress_count + Devices::Seeders::StressData.new(device, stress_count).seed! + else + add_plants(product_line) + add_soil_height_points(product_line) + end add_point_groups tool = device.tools.find_by(name: ToolNames::WATERING_NOZZLE) Tools::Update.run(tool: tool, flow_rate_ml_per_s: 100) if tool diff --git a/app/mutations/devices/seeders/stress_data.rb b/app/mutations/devices/seeders/stress_data.rb new file mode 100644 index 0000000000..2ffd43fa94 --- /dev/null +++ b/app/mutations/devices/seeders/stress_data.rb @@ -0,0 +1,220 @@ +module Devices + module Seeders + class StressData + include Constants + + PRODUCT_LINES = { + "genesis_xl_1.8_stress_250" => 250, + "genesis_xl_1.8_stress_500" => 500, + "genesis_xl_1.8_stress_750" => 750, + "genesis_xl_1.8_stress_1000" => 1_000, + }.freeze + + CROP_DATA = [ + %w[Spinach spinach], + %w[Broccoli broccoli], + %w[Beet beet], + ].freeze + + IMAGE_PATH = Rails.public_path.join("soil.png") + + attr_reader :device, :count + + def self.count_for(product_line) + PRODUCT_LINES[product_line] + end + + def self.stress?(product_line) + count_for(product_line).present? + end + + def initialize(device, count) + @device = device + @count = count + end + + def seed! + [plant_rows, soil_height_rows, weed_rows].each do |rows| + Point.insert_all!(rows) + end + Image.insert_all!(image_rows, returning: %w[id]).then do |result| + attach_images(result.rows.flatten) + end + SensorReading.insert_all!(sensor_reading_rows) + update_demo_settings + end + + private + + def plant_rows + count.times.map do |i| + crop_name, slug = CROP_DATA[i % CROP_DATA.length] + x, y = coordinate(i) + timestamp = timestamp(i) + { + created_at: timestamp, + updated_at: timestamp, + device_id: device.id, + pointer_type: "Plant", + name: crop_name, + openfarm_slug: slug, + plant_stage: i.even? ? "planted" : "planned", + planted_at: timestamp, + radius: 25, + depth: 5, + x: x, + y: y, + z: 0, + meta: {}, + } + end + end + + def soil_height_rows + count.times.map do |i| + x, y = coordinate(i, x_offset: 35, y_offset: 20) + timestamp = timestamp(i) + { + created_at: timestamp, + updated_at: timestamp, + device_id: device.id, + pointer_type: "GenericPointer", + name: "Soil Height", + radius: 0, + x: x, + y: y, + z: -550 + (i % 101), + meta: { color: "gray", at_soil_level: "true" }, + } + end + end + + def weed_rows + count.times.map do |i| + x, y = coordinate(i, x_offset: 70, y_offset: 40) + timestamp = timestamp(i) + { + created_at: timestamp, + updated_at: timestamp, + device_id: device.id, + pointer_type: "Weed", + name: "Stress Weed #{i + 1}", + plant_stage: "active", + radius: 20 + (i % 6), + x: x, + y: y, + z: 0, + meta: { color: i.even? ? "red" : "yellow" }, + } + end + end + + def image_rows + count.times.map do |i| + x, y = coordinate(i, x_offset: 105, y_offset: 60) + timestamp = timestamp(i) + { + created_at: timestamp, + updated_at: timestamp, + device_id: device.id, + attachment_processed_at: timestamp, + meta: { x: x, y: y, z: 0, name: "Stress Image #{i + 1}" }.to_yaml, + } + end + end + + def sensor_reading_rows + count.times.map do |i| + x, y = coordinate(i, x_offset: 140, y_offset: 80) + timestamp = timestamp(i) + { + created_at: timestamp, + updated_at: timestamp, + read_at: timestamp, + device_id: device.id, + pin: 59, + mode: ANALOG, + value: 300 + ((i * 37) % 600), + x: x, + y: y, + z: 0, + } + end + end + + def attach_images(image_ids) + blob = File.open(IMAGE_PATH) do |file| + ActiveStorage::Blob.create_and_upload!( + io: file, + filename: "stress-soil.png", + content_type: "image/png", + ) + end + rows = image_ids.map do |image_id| + { + name: "attachment", + record_type: "Image", + record_id: image_id, + blob_id: blob.id, + created_at: Time.now, + } + end + ActiveStorage::Attachment.insert_all!(rows) + end + + def update_demo_settings + device.update!(max_images_count: count) + device.web_app_config.update!( + show_images: true, + show_points: true, + show_plants: true, + show_sensor_readings: true, + show_moisture_interpolation_map: true, + show_weeds: true, + show_spread: true, + three_d_garden: true, + ) + end + + def coordinate(index, x_offset: 0, y_offset: 0) + col_count = Math.sqrt(count).ceil + row_count = (count.to_f / col_count).ceil + col = index % col_count + row = index / col_count + [ + clamp(100 + col * x_spacing(col_count) + x_offset, map_size_x), + clamp(100 + row * y_spacing(row_count) + y_offset, map_size_y), + ].map(&:round) + end + + def x_spacing(col_count) + span(map_size_x).fdiv([col_count - 1, 1].max) + end + + def y_spacing(row_count) + span(map_size_y).fdiv([row_count - 1, 1].max) + end + + def span(axis_length) + [axis_length - 200, 1].max + end + + def clamp(value, max) + value.clamp(0, max) + end + + def map_size_x + @map_size_x ||= device.web_app_config.map_size_x + end + + def map_size_y + @map_size_y ||= device.web_app_config.map_size_y + end + + def timestamp(index) + @timestamp ||= Time.now + @timestamp - index.seconds + end + end + end +end diff --git a/frontend/messages/__tests__/cards_test.tsx b/frontend/messages/__tests__/cards_test.tsx index 2dd1f2f65f..addd7edb30 100644 --- a/frontend/messages/__tests__/cards_test.tsx +++ b/frontend/messages/__tests__/cards_test.tsx @@ -333,6 +333,18 @@ describe("SEED_DATA_OPTIONS()", () => { mockFeatureBoolean = true; expect(SEED_DATA_OPTIONS().length).toEqual(19); }); + + it("returns demo stress options", () => { + const labels = SEED_DATA_OPTIONS(true).map(option => option.label); + + expect(SEED_DATA_OPTIONS(true).length).toEqual(23); + expect(SEED_DATA_OPTIONS().map(option => option.label)) + .not.toContain("Stress 1000"); + expect(labels).toContain("Stress 250"); + expect(labels).toContain("Stress 500"); + expect(labels).toContain("Stress 750"); + expect(labels).toContain("Stress 1000"); + }); }); describe("", () => { diff --git a/frontend/messages/cards.tsx b/frontend/messages/cards.tsx index ee61903a00..92e82a570e 100644 --- a/frontend/messages/cards.tsx +++ b/frontend/messages/cards.tsx @@ -255,9 +255,17 @@ const FirmwareMissing = (props: FirmwareMissingProps) => ; +const DEMO_STRESS_SEED_DATA_OPTIONS: DropDownItem[] = [ + { label: "Stress 250", value: "genesis_xl_1.8_stress_250" }, + { label: "Stress 500", value: "genesis_xl_1.8_stress_500" }, + { label: "Stress 750", value: "genesis_xl_1.8_stress_750" }, + { label: "Stress 1000", value: "genesis_xl_1.8_stress_1000" }, +]; + export const SEED_DATA_OPTIONS = (displayAll = false): DropDownItem[] => [ { label: "Genesis v1.8", value: "genesis_1.8" }, { label: "Genesis v1.8 XL", value: "genesis_xl_1.8" }, + ...(displayAll ? DEMO_STRESS_SEED_DATA_OPTIONS : []), { label: "Genesis v1.7", value: "genesis_1.7" }, { label: "Genesis v1.7 XL", value: "genesis_xl_1.7" }, { label: "Genesis v1.6", value: "genesis_1.6" }, diff --git a/spec/mutations/devices/create_seed_data_spec.rb b/spec/mutations/devices/create_seed_data_spec.rb index 53a2e04620..f2a18371e4 100644 --- a/spec/mutations/devices/create_seed_data_spec.rb +++ b/spec/mutations/devices/create_seed_data_spec.rb @@ -1,6 +1,24 @@ require "spec_helper" describe Devices::CreateSeedData do + it "accepts stress demo product lines" do + expect(described_class::PRODUCT_LINES.fetch("genesis_xl_1.8_stress_250")) + .to eq(Devices::Seeders::GenesisXlOneEight) + expect(described_class::PRODUCT_LINES.fetch("genesis_xl_1.8_stress_1000")) + .to eq(Devices::Seeders::GenesisXlOneEight) + end + + it "rejects stress product lines outside of demo accounts" do + result = described_class.run( + device: FactoryBot.create(:device), + product_line: "genesis_xl_1.8_stress_250", + ) + + expect(result.success?).to be(false) + expect(result.errors.message_list) + .to include(described_class::STRESS_DEMO_ONLY) + end + it "passes `none`" do device = FactoryBot.create(:device) previous_peripherals_count = device.peripherals.count diff --git a/spec/mutations/devices/seeders/stress_data_spec.rb b/spec/mutations/devices/seeders/stress_data_spec.rb new file mode 100644 index 0000000000..3ea157970a --- /dev/null +++ b/spec/mutations/devices/seeders/stress_data_spec.rb @@ -0,0 +1,33 @@ +require "spec_helper" + +describe Devices::Seeders::StressData do + let(:device) { FactoryBot.create(:device) } + + it "maps stress product lines to row counts" do + expect(described_class.count_for("genesis_xl_1.8_stress_250")).to eq(250) + expect(described_class.count_for("genesis_xl_1.8_stress_500")).to eq(500) + expect(described_class.count_for("genesis_xl_1.8_stress_750")).to eq(750) + expect(described_class.count_for("genesis_xl_1.8_stress_1000")).to eq(1_000) + expect(described_class.count_for("genesis_xl_1.8")).to be_nil + end + + it "adds stress resources and display settings", :slow do + device.web_app_config.update!(map_size_x: 5_900, map_size_y: 2_730) + + described_class.new(device, 3).seed! + + expect(device.plants.count).to eq(3) + expect(device.generic_pointers.where(name: "Soil Height").count).to eq(3) + expect(device.points.where(pointer_type: "Weed").count).to eq(3) + expect(device.images.count).to eq(3) + expect(device.images.all? { |image| image.attachment.attached? }).to be(true) + expect(device.sensor_readings.count).to eq(3) + expect(device.sensor_readings.pluck(:pin).uniq).to eq([59]) + expect(device.sensor_readings.pluck(:mode).uniq) + .to eq([CeleryScriptSettingsBag::ANALOG]) + expect(device.reload.max_images_count).to eq(3) + expect(device.web_app_config.show_sensor_readings).to be(true) + expect(device.web_app_config.show_moisture_interpolation_map).to be(true) + expect(device.web_app_config.three_d_garden).to be(true) + end +end From 0aa3bfe35669cba267eac6eaa978d43b22d63f51 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 23 Apr 2026 20:08:45 -0700 Subject: [PATCH 05/83] Add 3D designer perf benchmark instrumentation --- frontend/hacks.d.ts | 6 + frontend/performance/__tests__/perf_test.tsx | 47 ++++ frontend/performance/perf.ts | 63 +++++ frontend/plants/plant_inventory_item.tsx | 2 + frontend/three_d_garden/fps_probe.tsx | 3 + frontend/three_d_garden/garden_model.tsx | 9 + frontend/three_d_garden/index.tsx | 3 + scripts/perf/stress_1000_3d.js | 250 +++++++++++++++++++ 8 files changed, 383 insertions(+) create mode 100644 frontend/performance/__tests__/perf_test.tsx create mode 100644 frontend/performance/perf.ts create mode 100644 scripts/perf/stress_1000_3d.js diff --git a/frontend/hacks.d.ts b/frontend/hacks.d.ts index 349312d6a3..39e1a3a05e 100644 --- a/frontend/hacks.d.ts +++ b/frontend/hacks.d.ts @@ -22,6 +22,12 @@ interface Window { logStore: LogStore; __fps?: number; __scene_metrics?: string; + __fbPerf?: { + startedAt: number; + marks: Record; + counts: Record; + samples: Record; + }; } declare namespace jest { diff --git a/frontend/performance/__tests__/perf_test.tsx b/frontend/performance/__tests__/perf_test.tsx new file mode 100644 index 0000000000..447ec63921 --- /dev/null +++ b/frontend/performance/__tests__/perf_test.tsx @@ -0,0 +1,47 @@ +import { + PerfMark, perfCount, perfEnabled, perfMark, perfSample, perfStore, + usePerfRenderCount, +} from "../perf"; +import React from "react"; +import { render } from "@testing-library/react"; + +describe("perf helpers", () => { + beforeEach(() => { + window.localStorage.clear(); + delete window.__fbPerf; + history.pushState({}, "", "/app/designer"); + }); + + afterEach(() => { + window.localStorage.clear(); + delete window.__fbPerf; + }); + + it("stays disabled by default", () => { + expect(perfEnabled()).toBe(false); + expect(perfStore()).toBeUndefined(); + perfMark("test"); + expect(window.__fbPerf).toBeUndefined(); + }); + + it("can be enabled by localStorage", () => { + window.localStorage.setItem("FB_PERF_BENCHMARK", "true"); + perfMark("ready"); + perfCount("render.Test"); + perfSample("fps", 60); + expect(perfStore()?.marks.ready.length).toEqual(1); + expect(perfStore()?.counts["render.Test"]).toEqual(1); + expect(perfStore()?.samples.fps).toEqual([60]); + }); + + it("counts renders", () => { + window.localStorage.setItem("FB_PERF_BENCHMARK", "true"); + const TestComponent = () => { + usePerfRenderCount("TestComponent"); + return ; + }; + render(); + expect(perfStore()?.counts["render.TestComponent"]).toEqual(1); + expect(perfStore()?.marks.component_rendered.length).toEqual(1); + }); +}); diff --git a/frontend/performance/perf.ts b/frontend/performance/perf.ts new file mode 100644 index 0000000000..381c5a9dba --- /dev/null +++ b/frontend/performance/perf.ts @@ -0,0 +1,63 @@ +import React from "react"; + +export interface PerfStore { + startedAt: number; + marks: Record; + counts: Record; + samples: Record; +} + +const PERF_FLAG = "FB_PERF_BENCHMARK"; + +const hasWindow = () => typeof window !== "undefined"; + +export const perfEnabled = () => { + if (!hasWindow()) { return false; } + const params = new URLSearchParams(window.location.search); + return params.get("fb_perf") == "1" + || window.localStorage.getItem(PERF_FLAG) == "true"; +}; + +const emptyStore = (): PerfStore => ({ + startedAt: performance.now(), + marks: {}, + counts: {}, + samples: {}, +}); + +export const perfStore = (): PerfStore | undefined => { + if (!perfEnabled()) { return undefined; } + window.__fbPerf ||= emptyStore(); + return window.__fbPerf; +}; + +export const perfMark = (name: string) => { + const store = perfStore(); + if (!store) { return; } + store.marks[name] ||= []; + store.marks[name].push(performance.now() - store.startedAt); +}; + +export const perfCount = (name: string, by = 1) => { + const store = perfStore(); + if (!store) { return; } + store.counts[name] = (store.counts[name] || 0) + by; +}; + +export const perfSample = (name: string, value: number) => { + const store = perfStore(); + if (!store) { return; } + store.samples[name] ||= []; + store.samples[name].push(value); +}; + +export const usePerfRenderCount = (name: string) => { + perfCount(`render.${name}`); +}; + +export const PerfMark = ({ name }: { name: string }) => { + React.useEffect(() => { + perfMark(name); + }, [name]); + return undefined; +}; diff --git a/frontend/plants/plant_inventory_item.tsx b/frontend/plants/plant_inventory_item.tsx index e26dc4a98a..ca66d86ddb 100644 --- a/frontend/plants/plant_inventory_item.tsx +++ b/frontend/plants/plant_inventory_item.tsx @@ -11,6 +11,7 @@ import { isUndefined, round } from "lodash"; import { Path } from "../internal_urls"; import { useNavigate } from "react-router"; import { findIcon } from "../crops/find"; +import { usePerfRenderCount } from "../performance/perf"; export interface PlantInventoryItemProps { plant: TaggedPlant; @@ -21,6 +22,7 @@ export interface PlantInventoryItemProps { // The individual plants that show up in the farm designer sub nav. export const PlantInventoryItem = (props: PlantInventoryItemProps) => { + usePerfRenderCount("PlantInventoryItem"); const navigate = useNavigate(); const { plant, dispatch } = props; diff --git a/frontend/three_d_garden/fps_probe.tsx b/frontend/three_d_garden/fps_probe.tsx index 5c1c0de2f7..b25d7bb933 100644 --- a/frontend/three_d_garden/fps_probe.tsx +++ b/frontend/three_d_garden/fps_probe.tsx @@ -1,5 +1,6 @@ import React from "react"; import { useFrame, useThree } from "@react-three/fiber"; +import { perfSample } from "../performance/perf"; type SceneObject = { isMesh?: boolean; @@ -86,6 +87,8 @@ export const FPSProbe = () => { const { geometries, textures } = gl.info.memory; const sceneCounts = countSceneObjects(scene); window.__fps = fps; + perfSample("fps", fps); + perfSample("frame_ms", 1000 / fps); const linesToLogObj: Record = { epoch: Date.now(), FPS: fps.toFixed(2), diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 8f1f7b7fe3..72cb949482 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -50,6 +50,9 @@ import { GroupOrderVisual } from "./group_order_visual"; import { MoistureReadings } from "./garden/moisture_texture"; import { FPSProbe } from "./fps_probe"; import { CameraSelectionUI } from "./camera_selection_ui"; +import { + PerfMark, perfMark, usePerfRenderCount, +} from "../performance/perf"; const AnimatedGroup = animated(Group); @@ -74,6 +77,7 @@ export interface GardenModelProps { // eslint-disable-next-line complexity export const GardenModel = (props: GardenModelProps) => { + usePerfRenderCount("GardenModel"); const { config, addPlantProps, threeDPlants } = props; const dispatch = addPlantProps?.dispatch; const Camera = config.perspective ? PerspectiveCamera : OrthographicCamera; @@ -137,6 +141,10 @@ export const GardenModel = (props: GardenModelProps) => { // eslint-disable-next-line no-null/no-null React.useState(null); + React.useEffect(() => { + perfMark("garden_model_mounted"); + }, []); + const showPlants = !addPlantProps || !!addPlantProps.getConfigValue(BooleanSetting.show_plants); const plantsVisible = props.activeFocus != "Planter bed" && showPlants; @@ -201,6 +209,7 @@ export const GardenModel = (props: GardenModelProps) => { ? e => console.log(e.intersections.map(x => x.object.name)) : undefined}> + {config.stats && } {config.stats && } {config.zoomBeacons && { + usePerfRenderCount("ThreeDGarden"); return
{ shadows={props.config.lowDetail ? false : "variance"} onCreated={({ gl }) => { gl.localClippingEnabled = true; + perfMark("canvas_created"); }}> { + const [command = "run", ...rest] = process.argv.slice(2); + const args = { command }; + for (let i = 0; i < rest.length; i += 2) { + args[rest[i].replace(/^--/, "")] = rest[i + 1]; + } + return args; +}; + +const median = values => { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length == 0) { return undefined; } + return sorted[Math.floor(sorted.length / 2)]; +}; + +const percentile = (values, p) => { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length == 0) { return undefined; } + return sorted[Math.ceil((p / 100) * sorted.length) - 1]; +}; + +const summary = runs => { + const metric = key => median(runs.map(run => run[key])); + return { + coreReadyMs: metric("coreReadyMs"), + fullReadyMs: metric("fullReadyMs"), + fpsMedian: metric("fpsMedian"), + frameP95Ms: metric("frameP95Ms"), + navPlantMs: metric("navPlantMs"), + navPointMs: metric("navPointMs"), + navWeedMs: metric("navWeedMs"), + gardenModelRenders: metric("gardenModelRenders"), + threeDGardenRenders: metric("threeDGardenRenders"), + plantInventoryItemRenders: metric("plantInventoryItemRenders"), + }; +}; + +const createDemoSession = async (browser, baseUrl) => { + const secret = crypto.randomUUID().replaceAll("-", ""); + const page = await browser.newPage(); + await page.goto(`${baseUrl}/demo`, { waitUntil: "domcontentloaded" }); + await page.addScriptTag({ path: require.resolve("mqtt/dist/mqtt.min.js") }); + const session = await page.evaluate(async ({ demoUser, line, value }) => { + const configResponse = await fetch("/api/global_config"); + const config = await configResponse.json(); + const topic = `demos/${value}`; + const client = window.mqtt.connect(config.MQTT_WS, { + username: demoUser, + password: "required, but not used.", + }); + const tokenPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + client.end(true); + reject(new Error("Timed out waiting for demo token over MQTT.")); + }, 180_000); + client.on("connect", () => { + client.subscribe(topic, error => error && reject(error)); + }); + client.on("message", (_topic, buffer) => { + clearTimeout(timeout); + client.end(true); + resolve(buffer.toString()); + }); + client.on("error", reject); + }); + const response = await fetch("/api/demo_account", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ secret: value, product_line: line }), + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return tokenPromise; + }, { + demoUser: DEMO_USER, + line: PRODUCT_LINE, + value: secret, + }); + await page.close(); + return session; +}; + +const waitFor3D = async page => { + await page.waitForFunction(() => { + const canvas = document.querySelector(".garden-bed-3d-model canvas"); + return Boolean(canvas && typeof canvas.dataset.engine == "string"); + }, { timeout: TIMEOUT }); + await page.waitForFunction(() => typeof window.__fps == "number", { + timeout: TIMEOUT, + }); +}; + +const clickAndMeasure = async (page, route, itemSelector, panelSelector) => { + await page.goto(route, { waitUntil: "domcontentloaded" }); + await waitFor3D(page); + const item = page.locator(itemSelector).first(); + const count = await item.count(); + if (count == 0) { return undefined; } + const startedAt = await page.evaluate(() => performance.now()); + await item.click(); + await page.waitForSelector(panelSelector, { timeout: TIMEOUT }); + return page.evaluate(start => performance.now() - start, startedAt); +}; + +const collectRun = async (browser, baseUrl, session, runIndex) => { + const context = await browser.newContext({ + viewport: { width: 1440, height: 1000 }, + }); + await context.addInitScript(value => { + window.localStorage.setItem("session", value.session); + window.localStorage.setItem("FB_PERF_BENCHMARK", "true"); + window.localStorage.setItem("FPS_LOGS", "false"); + }, { session }); + const page = await context.newPage(); + page.setDefaultTimeout(TIMEOUT); + const appUrl = `${baseUrl}/app/designer/plants?fb_perf=1`; + await page.goto(appUrl, { waitUntil: "domcontentloaded" }); + await waitFor3D(page); + await page.waitForTimeout(12_000); + const perf = await page.evaluate(() => window.__fbPerf); + const marks = perf?.marks || {}; + const samples = perf?.samples || {}; + const counts = perf?.counts || {}; + const fpsSamples = samples.fps || []; + const frameSamples = samples.frame_ms || []; + const navPlantMs = await clickAndMeasure( + page, + `${baseUrl}/app/designer/plants?fb_perf=1`, + ".plant-search-item", + ".plant-info-panel", + ); + const navPointMs = await clickAndMeasure( + page, + `${baseUrl}/app/designer/points?fb_perf=1`, + ".point-search-item", + ".point-info-panel", + ); + const navWeedMs = await clickAndMeasure( + page, + `${baseUrl}/app/designer/weeds?fb_perf=1`, + ".weed-search-item", + ".weed-info-panel", + ); + await context.close(); + return { + runIndex, + coreReadyMs: marks.garden_model_rendered?.[0], + fullReadyMs: marks.garden_model_mounted?.[0], + fpsMedian: median(fpsSamples), + frameP95Ms: percentile(frameSamples, 95), + navPlantMs, + navPointMs, + navWeedMs, + gardenModelRenders: counts["render.GardenModel"], + threeDGardenRenders: counts["render.ThreeDGarden"], + plantInventoryItemRenders: counts["render.PlantInventoryItem"], + }; +}; + +const runBenchmark = async args => { + const baseUrl = args["base-url"] || DEFAULT_URL; + const runs = Number(args.runs || 5); + const warmups = Number(args.warmups || 1); + const out = args.out || "tmp/perf/stress_1000_3d.json"; + const browser = await chromium.launch({ + headless: true, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--enable-gpu", + ], + }); + try { + const session = await createDemoSession(browser, baseUrl); + const measuredRuns = []; + for (let i = 0; i < warmups + runs; i++) { + const run = await collectRun(browser, baseUrl, session, i); + console.log(`${i < warmups ? "warmup" : "run"} ${i + 1}`, run); + if (i >= warmups) { measuredRuns.push(run); } + } + const result = { + productLine: PRODUCT_LINE, + createdAt: new Date().toISOString(), + runs: measuredRuns, + summary: summary(measuredRuns), + }; + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, `${JSON.stringify(result, undefined, 2)}\n`); + console.log(`Wrote ${out}`); + console.log(result.summary); + } finally { + await browser.close(); + } +}; + +const readJson = file => JSON.parse(fs.readFileSync(file, "utf8")); + +const compare = args => { + const before = readJson(args.before); + const after = readJson(args.after); + const metric = args.metric; + const direction = args.direction || ( + ["fpsMedian"].includes(metric) ? "up" : "down"); + const threshold = Number(args.threshold || 10); + const beforeValue = before.summary[metric]; + const afterValue = after.summary[metric]; + if (!Number.isFinite(beforeValue) || !Number.isFinite(afterValue)) { + throw new Error(`Metric ${metric} is missing from benchmark results.`); + } + const improvement = direction == "up" + ? 100 * (afterValue - beforeValue) / beforeValue + : 100 * (beforeValue - afterValue) / beforeValue; + console.log({ + metric, + direction, + before: beforeValue, + after: afterValue, + improvement: `${improvement.toFixed(2)}%`, + threshold: `${threshold}%`, + }); + if (improvement < threshold) { + process.exitCode = 1; + } +}; + +const main = async () => { + const args = parseArgs(); + if (args.command == "compare") { + compare(args); + } else { + await runBenchmark(args); + } +}; + +main().catch(error => { + console.error(error); + process.exitCode = 1; +}); From ba2aaee7d0cd914386369a319d81cbde98453ffa Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 23 Apr 2026 20:21:52 -0700 Subject: [PATCH 06/83] Defer non-core 3D garden scene loading --- frontend/three_d_garden/bot/bot.tsx | 2 - frontend/three_d_garden/garden_model.tsx | 222 +++++++++++++---------- scripts/perf/stress_1000_3d.js | 34 +++- 3 files changed, 161 insertions(+), 97 deletions(-) diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index 527fee6841..fa55adae8c 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -91,8 +91,6 @@ type XAxisCCMount = GLTF & { materials: never; } -Object.values(ASSETS.models).map(model => useGLTF.preload(model, LIB_DIR)); - export interface FarmbotModelProps { config: Config; configPosition: PositionConfig; diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 72cb949482..a28f29e66a 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -13,7 +13,6 @@ import { OrthographicCamera as ThreeOrthographicCamera, PerspectiveCamera as ThreePerspectiveCamera, } from "three"; -import { Bot } from "./bot"; import { AddPlantProps, Bed } from "./bed"; import { Sky, Solar, Sun, sunPosition, ZoomBeacons, @@ -55,6 +54,19 @@ import { } from "../performance/perf"; const AnimatedGroup = animated(Group); +const LazyBot = React.lazy(() => + import("./bot").then(module => ({ default: module.Bot }))); + +interface SceneBoundaryProps { + markName?: string; + children: React.ReactNode; +} + +const SceneBoundary = (props: SceneBoundaryProps) => + + {props.children} + {props.markName && } + ; export interface GardenModelProps { config: Config; @@ -210,13 +222,17 @@ export const GardenModel = (props: GardenModelProps) => { : undefined}> - {config.stats && } - {config.stats && } - {config.zoomBeacons && } + + {config.stats && } + {config.stats && } + {config.zoomBeacons && } + + {config.viewCube && } + { maxZoom={10} minDistance={config.lightsDebug ? 50 : 500} maxDistance={config.lightsDebug ? BigDistance.devZoom : BigDistance.zoom} />} - - {config.viewCube && } - - - - - {showMoistureMap && props.config.moistureDebug && - } - {showFarmbot && - + + + + + + + + } + mapPoints={props.mapPoints || []} + showMoistureMap={showMoistureMap} + showMoistureReadings={showMoistureReadings} + sensors={props.sensors || []} + sensorReadings={props.sensorReadings || []} + activePositionRef={activePositionRef} + addPlantProps={addPlantProps} /> + + + {showMoistureMap && props.config.moistureDebug && + } + + + {showFarmbot && + } + {plantLabelNodes} @@ -297,65 +321,77 @@ export const GardenModel = (props: GardenModelProps) => { config={config} getZ={getZ} activeFocus={props.activeFocus} /> - - - + - - - {(props.mapPoints?.length || 0) > 0 && - + } - - - {(props.weeds?.length || 0) > 0 && - + } - - - {props.addPlantProps?.designer.visualizedSequence && - } - - - - {config.cameraSelectionView && - + + + + + {(props.mapPoints?.length || 0) > 0 && + } + + + + + {(props.weeds?.length || 0) > 0 && + } + + + + } + tryGroupSortType={props.addPlantProps?.designer.tryGroupSortType} + getZ={getZ} /> + {props.addPlantProps?.designer.visualizedSequence && + } + + + + + + + + {config.cameraSelectionView && + } + ; }; diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js index 817d89d073..45fc25c37c 100644 --- a/scripts/perf/stress_1000_3d.js +++ b/scripts/perf/stress_1000_3d.js @@ -45,6 +45,20 @@ const summary = runs => { }; }; +const firstMark = (marks, ...names) => { + for (const name of names) { + const value = marks[name]?.[0]; + if (Number.isFinite(value)) { return value; } + } +}; + +const maxMark = (marks, names) => { + const values = names + .map(name => marks[name]?.[0]) + .filter(Number.isFinite); + return values.length > 0 ? Math.max(...values) : undefined; +}; + const createDemoSession = async (browser, baseUrl) => { const secret = crypto.randomUUID().replaceAll("-", ""); const page = await browser.newPage(); @@ -155,8 +169,24 @@ const collectRun = async (browser, baseUrl, session, runIndex) => { await context.close(); return { runIndex, - coreReadyMs: marks.garden_model_rendered?.[0], - fullReadyMs: marks.garden_model_mounted?.[0], + coreReadyMs: firstMark( + marks, + "three_d_core_ready", + "garden_model_rendered", + ), + fullReadyMs: maxMark(marks, [ + "three_d_bot_ready", + "three_d_bed_ready", + "three_d_clouds_ready", + "three_d_decorations_ready", + "three_d_visualizations_ready", + "three_d_camera_ui_ready", + "three_d_debug_ready", + "three_d_ground_ready", + "three_d_moisture_debug_ready", + "three_d_points_ready", + "three_d_weeds_ready", + ]) || marks.garden_model_mounted?.[0], fpsMedian: median(fpsSamples), frameP95Ms: percentile(frameSamples, 95), navPlantMs, From 5ec98df45aeb1d7f794ddd2b6568d6b221d3918d Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 23 Apr 2026 16:11:14 -0700 Subject: [PATCH 07/83] upgrade deps (ruby) --- .ruby-version | 2 +- Gemfile | 2 +- Gemfile.lock | 8 ++++---- bun.lock | 24 +++++++++++------------- docker_configs/api.Dockerfile | 2 +- lib/tasks/coverage.rake | 2 +- package.json | 8 ++++---- 7 files changed, 23 insertions(+), 25 deletions(-) diff --git a/.ruby-version b/.ruby-version index 4d54daddb6..c4e41f9459 100755 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -4.0.2 +4.0.3 diff --git a/Gemfile b/Gemfile index 567affe78b..4980dcebdf 100755 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source "https://rubygems.org" -ruby "~> 4.0.2" +ruby "~> 4.0.3" gem "rails", "~> 8" gem "active_model_serializers" diff --git a/Gemfile.lock b/Gemfile.lock index 9df3375765..b657a9cb39 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -195,7 +195,7 @@ GEM i18n (1.14.8) concurrent-ruby (~> 1.0) io-console (0.8.2) - irb (1.17.0) + irb (1.18.0) pp (>= 0.6.0) prism (>= 1.3.0) rdoc (>= 4.0.0) @@ -245,7 +245,7 @@ GEM mutex_m (0.3.0) net-http (0.9.1) uri (>= 0.11.1) - net-imap (0.6.3) + net-imap (0.6.4) date net-protocol net-pop (0.1.2) @@ -511,7 +511,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 4.0.2 + ruby 4.0.3 BUNDLED WITH - 4.0.9 + 4.0.10 diff --git a/bun.lock b/bun.lock index edd6ff89b2..21b258a6e8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "farmbot-web-frontend", "dependencies": { - "@blueprintjs/core": "6.12.0", - "@blueprintjs/select": "6.1.9", + "@blueprintjs/core": "6.12.1", + "@blueprintjs/select": "6.1.10", "@monaco-editor/react": "4.7.0", "@react-spring/three": "10.0.3", "@react-three/drei": "10.7.7", @@ -33,7 +33,7 @@ "farmbot": "15.9.3", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.0.6", + "i18next": "26.0.7", "lodash": "4.18.1", "markdown-it": "14.1.1", "markdown-it-emoji": "3.0.0", @@ -98,7 +98,7 @@ "react-test-renderer": "19.2.5", "sass": "1.99.0", "sass-lint": "1.13.1", - "stylelint": "17.8.0", + "stylelint": "17.9.0", "stylelint-config-standard-scss": "17.0.0", "ts-jest": "29.4.9", "tslint": "6.1.3", @@ -186,11 +186,11 @@ "@blueprintjs/colors": ["@blueprintjs/colors@5.1.16", "", { "dependencies": { "tslib": "~2.6.2" } }, "sha512-P9uX0Aj2TP9+6aUcori1iPl4snxM/Vgq0LZbhUl1l5bHTgNxxwm/0+IoS/SlQg93HBRl8KTAM1evEqtPbwV10A=="], - "@blueprintjs/core": ["@blueprintjs/core@6.12.0", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/icons": "^6.9.0", "@floating-ui/react": "^0.27.13", "@popperjs/core": "^2.11.8", "classnames": "^2.3.1", "normalize.css": "^8.0.1", "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", "tslib": "~2.6.2", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"], "bin": { "upgrade-blueprint-2.0.0-rename": "scripts/upgrade-blueprint-2.0.0-rename.sh", "upgrade-blueprint-3.0.0-rename": "scripts/upgrade-blueprint-3.0.0-rename.sh" } }, "sha512-huBwfAU0/n4XG33C1xl4cWd/f+POtU11AL1wjTG/zWqyV4HRd57TE92z+SdioBbvCm6fmwEjNfjKAu1P1ojWxw=="], + "@blueprintjs/core": ["@blueprintjs/core@6.12.1", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/icons": "^6.9.1", "@floating-ui/react": "^0.27.13", "@popperjs/core": "^2.11.8", "classnames": "^2.3.1", "normalize.css": "^8.0.1", "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"], "bin": { "upgrade-blueprint-2.0.0-rename": "scripts/upgrade-blueprint-2.0.0-rename.sh", "upgrade-blueprint-3.0.0-rename": "scripts/upgrade-blueprint-3.0.0-rename.sh" } }, "sha512-HVWCbNjW6MLFrcWBGOMMsbn+Q4AqddDixCkNjXnO2Hjh7CQ/y27pPuZuEEIs0c90ADgLZaIL0d78B0RofFX1OQ=="], - "@blueprintjs/icons": ["@blueprintjs/icons@6.9.0", "", { "dependencies": { "change-case": "^4.1.2", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-pnwnw6dPARk7Q4CZ9ZKeVe9C8PO1OE9cv9DO4mPgNqJBIOrI9aVAWbHsfjqpbOPXx82/O65kNOQweP8foAzPRA=="], + "@blueprintjs/icons": ["@blueprintjs/icons@6.9.1", "", { "dependencies": { "change-case": "^4.1.2", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-Zy4SY0jDaUoDJ9nMUM5PVaAuZx1ViQbEgaXElySb4MOdhxJMM97OY2YKL6sdJspZOsPPsuBG2DABd81JU3GSdA=="], - "@blueprintjs/select": ["@blueprintjs/select@6.1.9", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/core": "^6.12.0", "@blueprintjs/icons": "^6.9.0", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-PtHQl0MWr606/PGxvN3xjYYvMc+gLyeHy/FdzK0/SXK4s9dBCBXGbdJEo8yVtDo71jyrJXZfjUkJlf+KpqRibQ=="], + "@blueprintjs/select": ["@blueprintjs/select@6.1.10", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/core": "^6.12.1", "@blueprintjs/icons": "^6.9.1", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-QWvehtSZEgOboLCeC/UTvcNI3o7roRC3cMIJ9tXXxKcwRygzvDVAWL8AshKSrHW3Vpcl0/6koya1Yi+GO70vYg=="], "@cacheable/memory": ["@cacheable/memory@2.0.8", "", { "dependencies": { "@cacheable/utils": "^2.4.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw=="], @@ -198,13 +198,13 @@ "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], - "@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="], + "@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="], "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], - "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.2", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA=="], + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="], "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], @@ -1226,7 +1226,7 @@ "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - "i18next": ["i18next@26.0.6", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg=="], + "i18next": ["i18next@26.0.7", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-f7tL/iw0VQsx4nC5oNxBM2RjM8alNys5KzyiQTU6A9TI5TI89py4/Ez1cKFvHiLWsvzOXvuGUES+Kk/A2WiANQ=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -2020,7 +2020,7 @@ "strip-json-comments": ["strip-json-comments@1.0.4", "", { "bin": "cli.js" }, "sha512-AOPG8EBc5wAikaG1/7uFCNFJwnKOuQwFTpYBdTW6OvWHeZBQBrAA/amefHGrEiOnCPcLFZK6FUPtWVKpQVIRgg=="], - "stylelint": ["stylelint@17.8.0", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.2", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.1", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.2", "global-modules": "^2.0.0", "globby": "^16.2.0", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", "is-plain-object": "^5.0.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.5.9", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.0", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" } }, "sha512-oHkld9T60LDSaUQ4CSVc+tlt9eUoDlxhaGWShsUCKyIL14boZfmK5bSphZqx64aiC5tCqX+BsQMTMoSz8D1zIg=="], + "stylelint": ["stylelint@17.9.0", "", { "dependencies": { "@csstools/css-calc": "^3.2.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.1", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.2", "global-modules": "^2.0.0", "globby": "^16.2.0", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", "is-plain-object": "^5.0.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.5.9", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.0", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" } }, "sha512-xO0jeY6z1/urFL5L/BZLmB1yYlbRiRMQnYH6ArZIDWJ+SZXGssOY7XoYb1JIv/L220+EBnwwJXJS4Mt/F96SvA=="], "stylelint-config-recommended": ["stylelint-config-recommended@18.0.0", "", { "peerDependencies": { "stylelint": "^17.0.0" } }, "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg=="], @@ -2552,8 +2552,6 @@ "https-proxy-agent/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], - "i18next/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "inquirer/ansi-escapes": ["ansi-escapes@1.4.0", "", {}, "sha512-wiXutNjDUlNEDWHcYH3jtZUhd3c4/VojassD8zHdHCY13xbZy2XbW+NKQwA0tWGBVzDA9qEzYwfoSsWmviidhw=="], diff --git a/docker_configs/api.Dockerfile b/docker_configs/api.Dockerfile index b1837ff564..a8a4c93802 100644 --- a/docker_configs/api.Dockerfile +++ b/docker_configs/api.Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:4.0.2 +FROM ruby:4.0.3 RUN curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/trusted.gpg.d/apt.postgresql.org.gpg > /dev/null RUN sh -c '. /etc/os-release; echo $VERSION_CODENAME; echo "deb http://apt.postgresql.org/pub/repos/apt/ $VERSION_CODENAME-pgdg main" >> /etc/apt/sources.list.d/pgdg.list' RUN apt-get update -qq && apt-get install -y build-essential libpq-dev postgresql postgresql-contrib lcov diff --git a/lib/tasks/coverage.rake b/lib/tasks/coverage.rake index 099892a257..94e2820616 100644 --- a/lib/tasks/coverage.rake +++ b/lib/tasks/coverage.rake @@ -3,7 +3,7 @@ require "find" COVERAGE_FILE_PATH = "./coverage_fe/index.html" JSON_COVERAGE_FILE_PATH = "./coverage_fe/coverage-final.json" LCOV_FILE_PATH = "./coverage_fe/lcov.info" -THRESHOLD = 0.001 +THRESHOLD = 0.05 REPO_URL = "https://api.github.com/repos/Farmbot/Farmbot-Web-App" LATEST_COV_URL = "https://coveralls.io/github/FarmBot/Farmbot-Web-App.json" COV_API_BUILDS_PER_PAGE = 5 diff --git a/package.json b/package.json index 88ed025076..d56de716f7 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,8 @@ "mqtt": "mqtt/dist/mqtt.esm.js" }, "dependencies": { - "@blueprintjs/core": "6.12.0", - "@blueprintjs/select": "6.1.9", + "@blueprintjs/core": "6.12.1", + "@blueprintjs/select": "6.1.10", "@monaco-editor/react": "4.7.0", "@react-spring/three": "10.0.3", "@react-three/drei": "10.7.7", @@ -66,7 +66,7 @@ "farmbot": "15.9.3", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.0.6", + "i18next": "26.0.7", "lodash": "4.18.1", "markdown-it": "14.1.1", "markdown-it-emoji": "3.0.0", @@ -131,7 +131,7 @@ "react-test-renderer": "19.2.5", "sass": "1.99.0", "sass-lint": "1.13.1", - "stylelint": "17.8.0", + "stylelint": "17.9.0", "stylelint-config-standard-scss": "17.0.0", "ts-jest": "29.4.9", "tslint": "6.1.3" From b69df7894a4797961671cc849e9844f3cea392ad Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 23 Apr 2026 21:09:20 -0700 Subject: [PATCH 08/83] Add low detail option to 3D perf benchmark --- scripts/perf/stress_1000_3d.js | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js index 45fc25c37c..75b62b336e 100644 --- a/scripts/perf/stress_1000_3d.js +++ b/scripts/perf/stress_1000_3d.js @@ -105,6 +105,39 @@ const createDemoSession = async (browser, baseUrl) => { return session; }; +const authHeader = session => JSON.parse(session).token.encoded; + +const apiJson = async (baseUrl, session, endpoint, options = {}) => { + const response = await fetch(`${baseUrl}${endpoint}`, { + ...options, + headers: { + Authorization: authHeader(session), + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}: ${endpoint}`); + } + return response.json(); +}; + +const setFarmwareEnv = async (baseUrl, session, key, value) => { + const envs = await apiJson(baseUrl, session, "/api/farmware_envs"); + const existing = envs.find(env => env.key == key); + if (existing) { + await apiJson(baseUrl, session, `/api/farmware_envs/${existing.id}`, { + method: "PUT", + body: JSON.stringify({ value }), + }); + } else { + await apiJson(baseUrl, session, "/api/farmware_envs", { + method: "POST", + body: JSON.stringify({ key, value }), + }); + } +}; + const waitFor3D = async page => { await page.waitForFunction(() => { const canvas = document.querySelector(".garden-bed-3d-model canvas"); @@ -203,6 +236,7 @@ const runBenchmark = async args => { const runs = Number(args.runs || 5); const warmups = Number(args.warmups || 1); const out = args.out || "tmp/perf/stress_1000_3d.json"; + const lowDetail = ["1", "true"].includes(args["low-detail"]); const browser = await chromium.launch({ headless: true, args: [ @@ -214,6 +248,9 @@ const runBenchmark = async args => { }); try { const session = await createDemoSession(browser, baseUrl); + if (lowDetail) { + await setFarmwareEnv(baseUrl, session, "3D_lowDetail", "1"); + } const measuredRuns = []; for (let i = 0; i < warmups + runs; i++) { const run = await collectRun(browser, baseUrl, session, i); From 856160a2a0d5c6ed5cd2820785e45c63c8acbced Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 23 Apr 2026 21:15:30 -0700 Subject: [PATCH 09/83] Fix moisture layer refresh --- .../__tests__/interpolation_map_test.tsx | 33 +++++++++++++++++++ .../map/layers/points/interpolation_map.tsx | 10 +++++- .../garden/__tests__/images_test.tsx | 20 +++++++++-- frontend/three_d_garden/garden/images.tsx | 32 +++++++++++++++--- 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/frontend/farm_designer/map/layers/points/__tests__/interpolation_map_test.tsx b/frontend/farm_designer/map/layers/points/__tests__/interpolation_map_test.tsx index e48daf0aae..ef1f5fb730 100644 --- a/frontend/farm_designer/map/layers/points/__tests__/interpolation_map_test.tsx +++ b/frontend/farm_designer/map/layers/points/__tests__/interpolation_map_test.tsx @@ -12,6 +12,7 @@ import { InterpolationSetting, InterpolationSettingProps, getZAtLocation, + generateData, } from "../interpolation_map"; import { changeBlurableInput } from "../../../../../__test_support__/helpers"; @@ -72,6 +73,38 @@ describe("interpolatedZ()", () => { }); }); +describe("generateData()", () => { + it("regenerates when a sensor reading value changes", () => { + localStorage.removeItem("interpolationDataMoisture"); + localStorage.removeItem("interpolationHashMoisture"); + const reading = fakeSensorReading(); + reading.uuid = "SensorReading.1"; + reading.body.x = 0; + reading.body.y = 0; + reading.body.value = 100; + const props = { + kind: "SensorReading" as const, + points: [reading], + gridSize: { x: 100, y: 100 }, + getColor: jest.fn(() => ({ rgb: "rgb(0, 0, 255)", a: 0 })), + options: { + ...DEFAULT_INTERPOLATION_OPTIONS, + stepSize: 100, + }, + }; + generateData(props); + expect(getInterpolationData("SensorReading")).toEqual([ + { x: 0, y: 0, z: 100 }, + ]); + + reading.body.value = 800; + generateData(props); + expect(getInterpolationData("SensorReading")).toEqual([ + { x: 0, y: 0, z: 800 }, + ]); + }); +}); + describe("fetchInterpolationOptions()", () => { it("fetches options", () => { const env1 = fakeFarmwareEnv(); diff --git a/frontend/farm_designer/map/layers/points/interpolation_map.tsx b/frontend/farm_designer/map/layers/points/interpolation_map.tsx index 95f04f8543..f78fd0c20a 100644 --- a/frontend/farm_designer/map/layers/points/interpolation_map.tsx +++ b/frontend/farm_designer/map/layers/points/interpolation_map.tsx @@ -104,12 +104,20 @@ const convertToPointObject = }) : undefined; +const getInterpolationPointHash = + (point: TaggedGenericPointer | TaggedSensorReading) => [ + point.uuid, + point.body.x, + point.body.y, + point.kind == "SensorReading" ? point.body.value : point.body.z, + ]; + export const generateData = (props: GenerateInterpolationMapDataProps) => { const points = selectMostRecentPoints(props.points); const { gridSize } = props; const { stepSize } = props.options; const hash = [ - JSON.stringify(points.map(p => p.uuid)), + JSON.stringify(points.map(getInterpolationPointHash)), JSON.stringify(gridSize), JSON.stringify(props.options), ].join(""); diff --git a/frontend/three_d_garden/garden/__tests__/images_test.tsx b/frontend/three_d_garden/garden/__tests__/images_test.tsx index b09bb4a218..77683f4321 100644 --- a/frontend/three_d_garden/garden/__tests__/images_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/images_test.tsx @@ -2,13 +2,13 @@ let mockDemo = false; import React from "react"; import { render, screen } from "@testing-library/react"; import { - extraRotation, getImagePosition, getMirrorTextureProps, + extraRotation, getImagePosition, getImageTextureKey, getMirrorTextureProps, ImageTexture, ImageTextureProps, } from "../images"; import { INITIAL } from "../../config"; import { clone } from "lodash"; import { - fakeImage, fakeWebAppConfig, + fakeImage, fakeSensorReading, fakeWebAppConfig, } from "../../../__test_support__/fake_state/resources"; import { fakeAddPlantProps } from "../../../__test_support__/fake_props"; import * as mustBeOnline from "../../../devices/must_be_online"; @@ -157,6 +157,22 @@ describe("", () => { render(); expect(screen.queryAllByText("image").length).toEqual(1); }); + + it("changes texture key when moisture visibility changes", () => { + const p = fakeProps(); + const key = getImageTextureKey(p); + p.showMoistureMap = false; + expect(getImageTextureKey(p)).not.toEqual(key); + }); + + it("changes texture key when moisture data changes", () => { + const p = fakeProps(); + const reading = fakeSensorReading(); + p.sensorReadings = [reading]; + const key = getImageTextureKey(p); + reading.body.value = 800; + expect(getImageTextureKey(p)).not.toEqual(key); + }); }); describe("extraRotation()", () => { diff --git a/frontend/three_d_garden/garden/images.tsx b/frontend/three_d_garden/garden/images.tsx index f33acd3a3e..057be598a0 100644 --- a/frontend/three_d_garden/garden/images.tsx +++ b/frontend/three_d_garden/garden/images.tsx @@ -87,6 +87,33 @@ export interface ImageTextureProps extends BaseProps { showMoistureMap: boolean; } +export const getImageTextureKey = (props: ImageTextureProps) => { + const extents = soilSurfaceExtents(props.config); + const moistureVisible = props.showMoistureMap || props.showMoistureReadings; + return [ + extents.x.min, extents.x.max, + extents.y.min, extents.y.max, + props.showMoistureMap, + props.showMoistureReadings, + moistureVisible && props.sensors.map(sensor => [ + sensor.uuid, + sensor.body.label, + sensor.body.mode, + sensor.body.pin, + ].join(",")).join("|"), + moistureVisible && props.sensorReadings.map(reading => [ + reading.uuid, + reading.body.x, + reading.body.y, + reading.body.z, + reading.body.value, + reading.body.mode, + reading.body.pin, + reading.body.read_at, + ].join(",")).join("|"), + ].join(":"); +}; + export const ImageTexture = (props: ImageTextureProps) => { const extents = soilSurfaceExtents(props.config); const width = extents.x.max - extents.x.min; @@ -98,10 +125,7 @@ export const ImageTexture = (props: ImageTextureProps) => { const textureHeight = height >= width ? textureSize : Math.max(1, Math.round(textureSize * height / width)); - const textureKey = [ - extents.x.min, extents.x.max, - extents.y.min, extents.y.max, - ].join(":"); + const textureKey = getImageTextureKey(props); const { bedXOffset, bedYOffset, bedWallThickness } = props.config; const soilTexture = useTexture(ASSETS.textures.soil + "?=soilT"); const color = getColorFromBrightness(props.config.soilBrightness); From f95dd633b994f3627cda41c8fde6978e8368356c Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 23 Apr 2026 21:17:59 -0700 Subject: [PATCH 10/83] Animate main nav entry --- frontend/css/app/navbar.scss | 17 +++++++++++++++++ frontend/nav/__tests__/index_test.tsx | 1 + frontend/nav/index.tsx | 1 + 3 files changed, 19 insertions(+) diff --git a/frontend/css/app/navbar.scss b/frontend/css/app/navbar.scss index e9851bec2f..f390fbcdf1 100644 --- a/frontend/css/app/navbar.scss +++ b/frontend/css/app/navbar.scss @@ -10,6 +10,23 @@ background: linear-gradient(0deg, transparent, $translucent3 60%, $translucent5), linear-gradient(0deg, transparent 30%, $translucent3); } +@keyframes nav-slide-in { + from { + opacity: 0; + transform: translateY(-100%); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: no-preference) { + .nav-load-in { + animation: nav-slide-in 320ms ease-out both; + } +} + .skip-nav-link { display: none; } diff --git a/frontend/nav/__tests__/index_test.tsx b/frontend/nav/__tests__/index_test.tsx index 3bfffa4eb2..2226fcaaf1 100644 --- a/frontend/nav/__tests__/index_test.tsx +++ b/frontend/nav/__tests__/index_test.tsx @@ -124,6 +124,7 @@ describe("", () => { const { container } = renderNavBar(); const navWrapper = container.querySelector(".nav-wrapper"); expect(navWrapper).toBeTruthy(); + expect(navWrapper?.classList.contains("nav-load-in")).toBeTruthy(); expect(navWrapper?.classList.contains("red")).toBeFalsy(); expect(container.querySelector(".connectivity-button.hover")).toBeFalsy(); }); diff --git a/frontend/nav/index.tsx b/frontend/nav/index.tsx index e8a8266d0e..5c82167b45 100644 --- a/frontend/nav/index.tsx +++ b/frontend/nav/index.tsx @@ -483,6 +483,7 @@ export class RawNavBar extends React.Component return
From 64b2aa12513b860fce6b01a91220a996fb835fe4 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 23 Apr 2026 21:20:28 -0700 Subject: [PATCH 11/83] Animate 3D bed entry --- .../__tests__/garden_model_test.tsx | 1 + .../__tests__/progressive_load_test.tsx | 14 +++++ frontend/three_d_garden/garden_model.tsx | 32 ++++++---- frontend/three_d_garden/progressive_load.tsx | 59 +++++++++++++++++++ 4 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 frontend/three_d_garden/__tests__/progressive_load_test.tsx create mode 100644 frontend/three_d_garden/progressive_load.tsx diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 5ce1801f44..470905b86a 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -81,6 +81,7 @@ describe("", () => { expect(container.innerHTML).toContain("zoom-beacons"); expect(container.innerHTML).not.toContain("stats"); expect(container.innerHTML).toContain("darkgreen"); + expect(container.innerHTML).toContain("bed-load-in"); }); it("renders top down view", () => { diff --git a/frontend/three_d_garden/__tests__/progressive_load_test.tsx b/frontend/three_d_garden/__tests__/progressive_load_test.tsx new file mode 100644 index 0000000000..0a98e81666 --- /dev/null +++ b/frontend/three_d_garden/__tests__/progressive_load_test.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { PopInGroup } from "../progressive_load"; + +describe("", () => { + it("renders children inside a named load-in group", () => { + const { container } = render( + content + ); + + expect(container.innerHTML).toContain("bed-load-in"); + expect(screen.getByText("content")).toBeTruthy(); + }); +}); diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index a28f29e66a..8f470b9641 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -52,6 +52,7 @@ import { CameraSelectionUI } from "./camera_selection_ui"; import { PerfMark, perfMark, usePerfRenderCount, } from "../performance/perf"; +import { PopInGroup } from "./progressive_load"; const AnimatedGroup = animated(Group); const LazyBot = React.lazy(() => @@ -280,19 +281,24 @@ export const GardenModel = (props: GardenModelProps) => { - + + + {showMoistureMap && props.config.moistureDebug && diff --git a/frontend/three_d_garden/progressive_load.tsx b/frontend/three_d_garden/progressive_load.tsx new file mode 100644 index 0000000000..94b62e8d77 --- /dev/null +++ b/frontend/three_d_garden/progressive_load.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { animated, useSpring } from "@react-spring/three"; +import { Group } from "./components"; + +const AnimatedGroup = animated(Group); + +const loadInConfig = { + tension: 220, + friction: 26, +}; + +interface LoadInGroupProps { + name: string; + children: React.ReactNode; + delay?: number; + fromPosition?: [number, number, number]; + toPosition?: [number, number, number]; + fromScale?: number | [number, number, number]; + toScale?: number | [number, number, number]; +} + +export const LoadInGroup = (props: LoadInGroupProps) => { + const { position, scale } = useSpring({ + from: { + position: props.fromPosition || [0, 0, 0], + scale: props.fromScale || 1, + }, + to: { + position: props.toPosition || [0, 0, 0], + scale: props.toScale || 1, + }, + delay: props.delay, + config: loadInConfig, + }); + + return + {props.children} + ; +}; + +interface PopInGroupProps { + name: string; + children: React.ReactNode; + delay?: number; + distance?: number; +} + +export const PopInGroup = (props: PopInGroupProps) => + + {props.children} + ; From b2ee31ecdeac9275a041c8a311bfa10de3a702a8 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 23 Apr 2026 21:21:23 -0700 Subject: [PATCH 12/83] Animate FarmBot entry --- .../__tests__/garden_model_test.tsx | 1 + .../__tests__/progressive_load_test.tsx | 13 ++++++++++- frontend/three_d_garden/garden_model.tsx | 23 +++++++++++-------- frontend/three_d_garden/progressive_load.tsx | 17 ++++++++++++++ 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 470905b86a..1174059013 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -82,6 +82,7 @@ describe("", () => { expect(container.innerHTML).not.toContain("stats"); expect(container.innerHTML).toContain("darkgreen"); expect(container.innerHTML).toContain("bed-load-in"); + expect(container.innerHTML).toContain("bot-load-in"); }); it("renders top down view", () => { diff --git a/frontend/three_d_garden/__tests__/progressive_load_test.tsx b/frontend/three_d_garden/__tests__/progressive_load_test.tsx index 0a98e81666..85c115103e 100644 --- a/frontend/three_d_garden/__tests__/progressive_load_test.tsx +++ b/frontend/three_d_garden/__tests__/progressive_load_test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { render, screen } from "@testing-library/react"; -import { PopInGroup } from "../progressive_load"; +import { FallInGroup, PopInGroup } from "../progressive_load"; describe("", () => { it("renders children inside a named load-in group", () => { @@ -12,3 +12,14 @@ describe("", () => { expect(screen.getByText("content")).toBeTruthy(); }); }); + +describe("", () => { + it("renders children inside a named load-in group", () => { + const { container } = render( + bot + ); + + expect(container.innerHTML).toContain("bot-load-in"); + expect(screen.getByText("bot")).toBeTruthy(); + }); +}); diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 8f470b9641..d933b5890e 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -52,7 +52,7 @@ import { CameraSelectionUI } from "./camera_selection_ui"; import { PerfMark, perfMark, usePerfRenderCount, } from "../performance/perf"; -import { PopInGroup } from "./progressive_load"; +import { FallInGroup, PopInGroup } from "./progressive_load"; const AnimatedGroup = animated(Group); const LazyBot = React.lazy(() => @@ -311,14 +311,19 @@ export const GardenModel = (props: GardenModelProps) => { {showFarmbot && - } + + + } {plantLabelNodes} diff --git a/frontend/three_d_garden/progressive_load.tsx b/frontend/three_d_garden/progressive_load.tsx index 94b62e8d77..2b359e89bf 100644 --- a/frontend/three_d_garden/progressive_load.tsx +++ b/frontend/three_d_garden/progressive_load.tsx @@ -57,3 +57,20 @@ export const PopInGroup = (props: PopInGroupProps) => toScale={[1, 1, 1]}> {props.children} ; + +interface FallInGroupProps { + name: string; + children: React.ReactNode; + delay?: number; + distance?: number; +} + +export const FallInGroup = (props: FallInGroupProps) => + + {props.children} + ; From 3f22784db7aa77cceef4c30d656bcef50df655ac Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 23 Apr 2026 21:23:09 -0700 Subject: [PATCH 13/83] Animate plant and weed entry --- .../__tests__/garden_model_test.tsx | 3 +- frontend/three_d_garden/garden_model.tsx | 81 +++++++++++-------- 2 files changed, 50 insertions(+), 34 deletions(-) diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 1174059013..cac5e17614 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -82,7 +82,8 @@ describe("", () => { expect(container.innerHTML).not.toContain("stats"); expect(container.innerHTML).toContain("darkgreen"); expect(container.innerHTML).toContain("bed-load-in"); - expect(container.innerHTML).toContain("bot-load-in"); + expect(container.innerHTML).toContain("plant-instances-load-in"); + expect(container.innerHTML).toContain("weeds-load-in"); }); it("renders top down view", () => { diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index d933b5890e..40239d744b 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -325,35 +325,45 @@ export const GardenModel = (props: GardenModelProps) => { toolSlots={props.toolSlots} /> } - - {plantLabelNodes} - + + + {plantLabelNodes} + + - - - + - + onPointerEnter={setHover(true)} + onPointerMove={setHover(true)} + onPointerLeave={setHover(false)}> + + + + { - - {(props.weeds?.length || 0) > 0 && - } - + + + {(props.weeds?.length || 0) > 0 && + } + + Date: Thu, 23 Apr 2026 21:23:54 -0700 Subject: [PATCH 14/83] Animate point marker entry --- .../__tests__/garden_model_test.tsx | 1 + frontend/three_d_garden/garden_model.tsx | 25 +++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index cac5e17614..b92f71a6b8 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -83,6 +83,7 @@ describe("", () => { expect(container.innerHTML).toContain("darkgreen"); expect(container.innerHTML).toContain("bed-load-in"); expect(container.innerHTML).toContain("plant-instances-load-in"); + expect(container.innerHTML).toContain("points-load-in"); expect(container.innerHTML).toContain("weeds-load-in"); }); diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 40239d744b..f751d954ec 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -366,16 +366,21 @@ export const GardenModel = (props: GardenModelProps) => { - - {(props.mapPoints?.length || 0) > 0 && - } - + + + {(props.mapPoints?.length || 0) > 0 && + } + + Date: Thu, 23 Apr 2026 21:24:52 -0700 Subject: [PATCH 15/83] Animate garden grid reveal --- .../__tests__/garden_model_test.tsx | 1 + .../__tests__/progressive_load_test.tsx | 15 ++++++++++++++- frontend/three_d_garden/garden_model.tsx | 16 +++++++++++----- frontend/three_d_garden/progressive_load.tsx | 15 +++++++++++++++ 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index b92f71a6b8..b2371cd204 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -82,6 +82,7 @@ describe("", () => { expect(container.innerHTML).not.toContain("stats"); expect(container.innerHTML).toContain("darkgreen"); expect(container.innerHTML).toContain("bed-load-in"); + expect(container.innerHTML).toContain("grid-load-in"); expect(container.innerHTML).toContain("plant-instances-load-in"); expect(container.innerHTML).toContain("points-load-in"); expect(container.innerHTML).toContain("weeds-load-in"); diff --git a/frontend/three_d_garden/__tests__/progressive_load_test.tsx b/frontend/three_d_garden/__tests__/progressive_load_test.tsx index 85c115103e..3db0b218af 100644 --- a/frontend/three_d_garden/__tests__/progressive_load_test.tsx +++ b/frontend/three_d_garden/__tests__/progressive_load_test.tsx @@ -1,6 +1,8 @@ import React from "react"; import { render, screen } from "@testing-library/react"; -import { FallInGroup, PopInGroup } from "../progressive_load"; +import { + FallInGroup, GridRevealGroup, PopInGroup, +} from "../progressive_load"; describe("", () => { it("renders children inside a named load-in group", () => { @@ -23,3 +25,14 @@ describe("", () => { expect(screen.getByText("bot")).toBeTruthy(); }); }); + +describe("", () => { + it("renders children inside a named load-in group", () => { + const { container } = render( + grid + ); + + expect(container.innerHTML).toContain("grid-load-in"); + expect(screen.getByText("grid")).toBeTruthy(); + }); +}); diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index f751d954ec..f163e7a591 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -52,7 +52,9 @@ import { CameraSelectionUI } from "./camera_selection_ui"; import { PerfMark, perfMark, usePerfRenderCount, } from "../performance/perf"; -import { FallInGroup, PopInGroup } from "./progressive_load"; +import { + FallInGroup, GridRevealGroup, PopInGroup, +} from "./progressive_load"; const AnimatedGroup = animated(Group); const LazyBot = React.lazy(() => @@ -333,10 +335,14 @@ export const GardenModel = (props: GardenModelProps) => { {plantLabelNodes} - + + + toScale={1}> {props.children} ; + +interface GridRevealGroupProps { + name: string; + children: React.ReactNode; + delay?: number; +} + +export const GridRevealGroup = (props: GridRevealGroupProps) => + + {props.children} + ; From 355611b01ab80f682eeae714f7f8ac74f2d714af Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 10:42:17 -0700 Subject: [PATCH 16/83] only show stress n seed options when searched for --- frontend/demo/__tests__/demo_iframe_test.tsx | 22 ++++++++ frontend/demo/demo_iframe.tsx | 5 +- frontend/messages/__tests__/cards_test.tsx | 32 +++++++++--- frontend/messages/cards.tsx | 23 ++++++++ frontend/ui/__tests__/filter_search_test.tsx | 35 ++++++++++++- frontend/ui/__tests__/new_fb_select_test.tsx | 55 ++++++++++++-------- frontend/ui/filter_search.tsx | 20 +++++-- frontend/ui/new_fb_select.tsx | 6 ++- 8 files changed, 159 insertions(+), 39 deletions(-) diff --git a/frontend/demo/__tests__/demo_iframe_test.tsx b/frontend/demo/__tests__/demo_iframe_test.tsx index 765c27e9c7..89aedaa69e 100644 --- a/frontend/demo/__tests__/demo_iframe_test.tsx +++ b/frontend/demo/__tests__/demo_iframe_test.tsx @@ -40,6 +40,7 @@ describe("", () => { .mockImplementation(((props: FBSelectProps) =>
; From 81034fb0bbf99596617f0e5420366313b0e53f4f Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 11:35:43 -0700 Subject: [PATCH 17/83] Progressively stage 3D designer load --- frontend/__test_support__/three_d_mocks.tsx | 14 +- .../css/farm_designer/three_d_garden.scss | 41 +++- .../__tests__/garden_model_test.tsx | 14 +- .../three_d_garden/__tests__/index_test.tsx | 1 + .../__tests__/progressive_load_test.tsx | 47 +++- frontend/three_d_garden/garden_model.tsx | 229 ++++++++++-------- frontend/three_d_garden/index.tsx | 63 +++-- frontend/three_d_garden/progressive_load.tsx | 135 +++++++++++ 8 files changed, 389 insertions(+), 155 deletions(-) diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index 6ec691e255..2cfc183de1 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -229,15 +229,21 @@ jest.mock("@react-three/fiber", () => ({ jest.mock("@react-spring/three", () => ({ useSpring: (props: UseSpringProps) => { - if (typeof props == "function") { (props as Function)(); } + const springProps = typeof props == "function" + ? (props as Function)() + : props; + const onRest = springProps.onRest; + React.useEffect(() => { + onRest?.(); + }, [onRest]); const resolvedTo = - props.to && typeof props.to == "object" - ? props.to + springProps.to && typeof springProps.to == "object" + ? springProps.to : {}; const api = { start: jest.fn(() => Promise.resolve()), }; - return [{ ...props, ...props.from, ...resolvedTo }, api]; + return [{ ...springProps, ...springProps.from, ...resolvedTo }, api]; }, // mocks for `", () => { beforeEach(() => { mockIsDesktop = false; mockIsMobile = false; - let useStateCalls = 0; useStateSpy = jest.spyOn(React, "useState") // eslint-disable-next-line comma-spacing .mockImplementation((initialState?: S | (() => S)) => { - useStateCalls += 1; - if (useStateCalls == 2) { + // eslint-disable-next-line no-null/no-null + if (initialState === null) { return [{}, jest.fn()]; } const value = typeof initialState == "function" @@ -83,7 +82,7 @@ describe("", () => { expect(container.innerHTML).toContain("darkgreen"); expect(container.innerHTML).toContain("bed-load-in"); expect(container.innerHTML).toContain("grid-load-in"); - expect(container.innerHTML).toContain("plant-instances-load-in"); + expect(container.innerHTML).toContain("plants-load-in"); expect(container.innerHTML).toContain("points-load-in"); expect(container.innerHTML).toContain("weeds-load-in"); }); @@ -188,15 +187,14 @@ describe("", () => { it("renders only the hovered label when labels on hover are enabled", () => { useStateSpy.mockRestore(); - let useStateCalls = 0; useStateSpy = jest.spyOn(React, "useState") // eslint-disable-next-line comma-spacing .mockImplementation((initialState?: S | (() => S)) => { - useStateCalls += 1; - if (useStateCalls == 1) { + if (initialState === undefined) { return [0, jest.fn()]; } - if (useStateCalls == 2) { + // eslint-disable-next-line no-null/no-null + if (initialState === null) { return [{}, jest.fn()]; } const value = typeof initialState == "function" diff --git a/frontend/three_d_garden/__tests__/index_test.tsx b/frontend/three_d_garden/__tests__/index_test.tsx index 22eb2d6391..36ebcc934e 100644 --- a/frontend/three_d_garden/__tests__/index_test.tsx +++ b/frontend/three_d_garden/__tests__/index_test.tsx @@ -34,6 +34,7 @@ describe("", () => { it("renders", () => { const { container } = render(); expect(container).toContainHTML("three-d-garden"); + expect(container.textContent).not.toContain("Loading interactive 3D FarmBot"); }); it("disables canvas shadows in low-detail mode", () => { diff --git a/frontend/three_d_garden/__tests__/progressive_load_test.tsx b/frontend/three_d_garden/__tests__/progressive_load_test.tsx index 3db0b218af..bbf7942c1c 100644 --- a/frontend/three_d_garden/__tests__/progressive_load_test.tsx +++ b/frontend/three_d_garden/__tests__/progressive_load_test.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { - FallInGroup, GridRevealGroup, PopInGroup, + FallInGroup, GridRevealGroup, LoadStepReady, PopInGroup, + THREE_D_LOAD_STEPS, ThreeDLoadProgressOverlay, useThreeDLoadProgress, } from "../progressive_load"; describe("", () => { @@ -36,3 +37,45 @@ describe("", () => { expect(screen.getByText("grid")).toBeTruthy(); }); }); + +describe("3D load progress", () => { + const ProgressHarness = () => { + const progress = useThreeDLoadProgress(); + const currentStep = progress.currentStep; + return
+ +

{currentStep?.id || "complete"}

+

+ {"" + progress.isStepAllowed("bed")} +

+ +
; + }; + + it("marks ready steps and hides the progress bar when complete", () => { + const consoleLog = jest.spyOn(console, "log").mockImplementation(jest.fn()); + render(); + + expect(screen.getByTestId("current-step").textContent) + .toEqual("environment"); + expect(screen.getByTestId("bed-allowed").textContent).toEqual("false"); + expect(document.querySelector(".three-d-load-progress")).toBeTruthy(); + THREE_D_LOAD_STEPS.forEach(step => { + expect(screen.getByTestId("current-step").textContent).toEqual(step.id); + fireEvent.click(screen.getByText("advance")); + }); + + expect(screen.getByTestId("current-step").textContent).toEqual("complete"); + expect(document.querySelector(".three-d-load-progress")).toBeFalsy(); + expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining("Total")); + consoleLog.mockRestore(); + }); + + it("marks one step ready", () => { + const markStep = jest.fn(); + render(); + expect(markStep).toHaveBeenCalledWith("plants"); + }); +}); diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index f163e7a591..06ae469e62 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -53,7 +53,9 @@ import { PerfMark, perfMark, usePerfRenderCount, } from "../performance/perf"; import { - FallInGroup, GridRevealGroup, PopInGroup, + FallInGroup, GridRevealGroup, LoadStepReady, PopInGroup, + ThreeDLoadProgress, ThreeDLoadProgressOverlay, ThreeDLoadStepId, + useThreeDLoadProgress, } from "./progressive_load"; const AnimatedGroup = animated(Group); @@ -62,14 +64,28 @@ const LazyBot = React.lazy(() => interface SceneBoundaryProps { markName?: string; + loadProgress?: ThreeDLoadProgress; + loadStep?: ThreeDLoadStepId; + markReadyOnMount?: boolean; children: React.ReactNode; } -const SceneBoundary = (props: SceneBoundaryProps) => - +const SceneBoundary = (props: SceneBoundaryProps) => { + if (props.loadStep + && props.loadProgress + && !props.loadProgress.isStepAllowed(props.loadStep)) { + return undefined; + } + const markReadyOnMount = props.markReadyOnMount !== false; + return {props.children} + {markReadyOnMount && props.loadStep && props.loadProgress && + } {props.markName && } ; +}; export interface GardenModelProps { config: Config; @@ -155,6 +171,7 @@ export const GardenModel = (props: GardenModelProps) => { const [controlsCamera, setControlsCamera] = // eslint-disable-next-line no-null/no-null React.useState(null); + const loadProgress = useThreeDLoadProgress(); React.useEffect(() => { perfMark("garden_model_mounted"); @@ -225,24 +242,6 @@ export const GardenModel = (props: GardenModelProps) => { : undefined}> - - {config.stats && } - {config.stats && } - {config.zoomBeacons && } - - {config.viewCube && } - - - - - { maxZoom={10} minDistance={config.lightsDebug ? 50 : 500} maxDistance={config.lightsDebug ? BigDistance.devZoom : BigDistance.zoom} />} - - - + + + + + + + + - - - - + loadProgress.markStep("bed")} distance={config.bedHeight + config.bedZOffset}> { addPlantProps={addPlantProps} /> - - {showMoistureMap && props.config.moistureDebug && - + loadProgress.markStep("grid")}> + } - - - {showFarmbot && - - - } + getZ={getZ} + activeFocus={props.activeFocus} /> +
- - - {plantLabelNodes} - - - - - - + loadProgress.markStep("plants")} distance={200}> + + {plantLabelNodes} + { - + + loadProgress.markStep("weeds")} + distance={200}> + + {(props.weeds?.length || 0) > 0 && + } + + + + loadProgress.markStep("points")} distance={config.columnLength + 1000}> @@ -388,24 +404,47 @@ export const GardenModel = (props: GardenModelProps) => { - - - - {(props.weeds?.length || 0) > 0 && - } - - + + {showFarmbot && + loadProgress.markStep("farmbot")} + distance={config.columnLength + 1500}> + + } - + + {config.stats && } + {config.stats && } + {config.zoomBeacons && } + + {config.viewCube && } + + {showMoistureMap && props.config.moistureDebug && + } { visualizedSequenceUUID={props.addPlantProps?.designer.visualizedSequence} config={config} configPosition={props.configPosition} />} - - - - {config.cameraSelectionView && { usePerfRenderCount("ThreeDGarden"); return
- - {"Loading -

- {t("Loading interactive 3D FarmBot...")} -

-
}> - { - gl.localClippingEnabled = true; - perfMark("canvas_created"); - }}> - - - + { + gl.localClippingEnabled = true; + perfMark("canvas_created"); + }}> + +
; }; diff --git a/frontend/three_d_garden/progressive_load.tsx b/frontend/three_d_garden/progressive_load.tsx index acb01887e3..7481f77fc4 100644 --- a/frontend/three_d_garden/progressive_load.tsx +++ b/frontend/three_d_garden/progressive_load.tsx @@ -1,9 +1,136 @@ import React from "react"; import { animated, useSpring } from "@react-spring/three"; +import { Html } from "@react-three/drei"; import { Group } from "./components"; const AnimatedGroup = animated(Group); +export const THREE_D_LOAD_STEPS = [ + { id: "environment", label: "Loading environment" }, + { id: "bed", label: "Loading bed and soil" }, + { id: "grid", label: "Loading grid" }, + { id: "plants", label: "Loading plants" }, + { id: "weeds", label: "Loading weeds" }, + { id: "points", label: "Loading points" }, + { id: "farmbot", label: "Loading FarmBot" }, + { id: "details", label: "Loading scene details" }, +] as const; + +export type ThreeDLoadStepId = typeof THREE_D_LOAD_STEPS[number]["id"]; +type ReadyStepTimes = Partial>; + +interface ReadyAction { + step: ThreeDLoadStepId; + elapsed: number; +} + +const readyStepReducer = + (state: ReadyStepTimes, action: ReadyAction): ReadyStepTimes => + state[action.step] === undefined + ? { ...state, [action.step]: action.elapsed } + : state; + +const now = () => + typeof performance == "undefined" ? Date.now() : performance.now(); + +export interface ThreeDLoadProgress { + readyStepTimes: ReadyStepTimes; + currentStep: typeof THREE_D_LOAD_STEPS[number] | undefined; + progress: number; + complete: boolean; + markStep(step: ThreeDLoadStepId): void; + isStepAllowed(step: ThreeDLoadStepId): boolean; +} + +const rounded = (value: number | undefined) => Math.round(value || 0); + +export const useThreeDLoadProgress = (): ThreeDLoadProgress => { + const startTimeRef = React.useRef(now()); + const loggedRef = React.useRef(false); + const [readyStepTimes, dispatch] = + React.useReducer(readyStepReducer, {} as ReadyStepTimes); + + const markStep = React.useCallback((step: ThreeDLoadStepId) => { + dispatch({ step, elapsed: now() - startTimeRef.current }); + }, []); + + const isStepAllowed = React.useCallback((step: ThreeDLoadStepId) => { + const index = THREE_D_LOAD_STEPS.findIndex(item => item.id == step); + return THREE_D_LOAD_STEPS + .slice(0, index) + .every(item => readyStepTimes[item.id] !== undefined); + }, [readyStepTimes]); + + const readyStepCount = + THREE_D_LOAD_STEPS.filter(step => readyStepTimes[step.id] !== undefined) + .length; + const currentStep = + THREE_D_LOAD_STEPS.find(step => readyStepTimes[step.id] === undefined); + const complete = readyStepCount == THREE_D_LOAD_STEPS.length; + const progress = readyStepCount / THREE_D_LOAD_STEPS.length * 100; + + React.useEffect(() => { + if (!complete || loggedRef.current) { return; } + loggedRef.current = true; + let previousElapsed = 0; + THREE_D_LOAD_STEPS.forEach(step => { + const elapsed = readyStepTimes[step.id] || 0; + const duration = elapsed - previousElapsed; + previousElapsed = elapsed; + console.log(`[3D load] ${step.label}: ${rounded(duration)}ms`); + }); + console.log(`[3D load] Total: ${rounded(previousElapsed)}ms`); + }, [complete, readyStepTimes]); + + return React.useMemo(() => ({ + readyStepTimes, + currentStep, + progress, + complete, + markStep, + isStepAllowed, + }), [ + complete, + currentStep, + isStepAllowed, + markStep, + progress, + readyStepTimes, + ]); +}; + +interface LoadStepReadyProps { + step: ThreeDLoadStepId; + markStep(step: ThreeDLoadStepId): void; +} + +export const LoadStepReady = (props: LoadStepReadyProps) => { + const { markStep, step } = props; + React.useEffect(() => { + markStep(step); + }, [markStep, step]); + return undefined; +}; + +interface ThreeDLoadProgressOverlayProps { + progress: ThreeDLoadProgress; +} + +export const ThreeDLoadProgressOverlay = + (props: ThreeDLoadProgressOverlayProps) => { + if (props.progress.complete) { return undefined; } + return +
+
+
+
+

{props.progress.currentStep?.label}

+
+ ; + }; + const loadInConfig = { tension: 220, friction: 26, @@ -13,6 +140,7 @@ interface LoadInGroupProps { name: string; children: React.ReactNode; delay?: number; + onRest?: () => void; fromPosition?: [number, number, number]; toPosition?: [number, number, number]; fromScale?: number | [number, number, number]; @@ -30,6 +158,7 @@ export const LoadInGroup = (props: LoadInGroupProps) => { scale: props.toScale || 1, }, delay: props.delay, + onRest: props.onRest, config: loadInConfig, }); @@ -45,6 +174,7 @@ interface PopInGroupProps { name: string; children: React.ReactNode; delay?: number; + onRest?: () => void; distance?: number; } @@ -52,6 +182,7 @@ export const PopInGroup = (props: PopInGroupProps) => @@ -62,6 +193,7 @@ interface FallInGroupProps { name: string; children: React.ReactNode; delay?: number; + onRest?: () => void; distance?: number; } @@ -69,6 +201,7 @@ export const FallInGroup = (props: FallInGroupProps) => @@ -79,12 +212,14 @@ interface GridRevealGroupProps { name: string; children: React.ReactNode; delay?: number; + onRest?: () => void; } export const GridRevealGroup = (props: GridRevealGroupProps) => {props.children} From daa234fcf681a1c3912ce2785d1fbdff01e3f70c Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 12:02:22 -0700 Subject: [PATCH 18/83] 3D progressive loading polish --- app/views/dashboard/promo.html.erb | 4 - .../css/farm_designer/three_d_garden.scss | 34 +---- frontend/internal_urls.ts | 1 - .../__tests__/garden_model_test.tsx | 8 +- .../three_d_garden/__tests__/index_test.tsx | 1 - .../__tests__/progressive_load_test.tsx | 71 ++++++++++ frontend/three_d_garden/garden_model.tsx | 42 +++++- frontend/three_d_garden/progressive_load.tsx | 35 ++--- .../scenes/__tests__/greenhouse_test.tsx | 10 ++ .../scenes/__tests__/lab_test.tsx | 10 ++ frontend/three_d_garden/scenes/greenhouse.tsx | 131 +++++++++--------- frontend/three_d_garden/scenes/lab.tsx | 99 +++++++------ public/app-resources/languages/zh.json | 1 - public/promo_loading_image.avif | Bin 183031 -> 0 bytes public/promo_loading_image_full_bot.avif | Bin 243804 -> 0 bytes 15 files changed, 273 insertions(+), 174 deletions(-) delete mode 100644 public/promo_loading_image.avif delete mode 100644 public/promo_loading_image_full_bot.avif diff --git a/app/views/dashboard/promo.html.erb b/app/views/dashboard/promo.html.erb index 97f76e5473..5722dd921b 100644 --- a/app/views/dashboard/promo.html.erb +++ b/app/views/dashboard/promo.html.erb @@ -7,7 +7,3 @@ margin: 0; } -
- Loading 3D interactive experience... -

Loading interactive 3D FarmBot...

-
diff --git a/frontend/css/farm_designer/three_d_garden.scss b/frontend/css/farm_designer/three_d_garden.scss index c325e9d302..ec138f077b 100644 --- a/frontend/css/farm_designer/three_d_garden.scss +++ b/frontend/css/farm_designer/three_d_garden.scss @@ -2,36 +2,6 @@ @use "sass:color"; @use "../global/fonts" as *; -.promo-loading-container { - display: grid; - align-content: center; - justify-content: center; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - z-index: -9999999; -} - -.promo-loading-image { - height: 100vh; -} - -.promo-loading-text { - font-family: $inknut; - font-size: 2rem; - font-weight: bold; - color: $off_white; - text-shadow: 0 0 3.5rem #000, 0 0 1rem #000; - text-align: center; - margin: 0; - position: absolute; - width: 100vw; - height: 100vh; - align-content: center; -} - .promo { &.three-d-garden { .garden-bed-3d-model { @@ -76,7 +46,7 @@ .three-d-load-progress { position: absolute; left: 50%; - bottom: 4rem; + bottom: 6rem; transform: translateX(-50%); width: min(36rem, calc(100vw - 3rem)); pointer-events: none; @@ -95,7 +65,7 @@ .three-d-load-progress-fill { height: 100%; border-radius: inherit; - background: $green; + background: rgba(255, 255, 255, 0.75); transition: width 180ms ease; } diff --git a/frontend/internal_urls.ts b/frontend/internal_urls.ts index bb2cefb1fc..ce508b2d65 100644 --- a/frontend/internal_urls.ts +++ b/frontend/internal_urls.ts @@ -107,7 +107,6 @@ export namespace FilePath { export const emptyState = (bug: string) => `${images("empty_state")}/${bug}.png`; export const DEFAULT_ICON = image("generic-plant"); export const DEFAULT_WEED_ICON = image("generic-weed"); - export const THREE_D_GARDEN_LOADING = "/promo_loading_image.avif"; } export enum Icon { diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 18ddf7d3d2..1eff35070b 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -6,7 +6,7 @@ import { OrbitControls, useTexture } from "@react-three/drei"; import { GardenModelProps, GardenModel } from "../garden_model"; import { clone } from "lodash"; import { INITIAL, INITIAL_POSITION, SurfaceDebugOption } from "../config"; -import { render } from "@testing-library/react"; +import { render, waitFor } from "@testing-library/react"; import { fakePlant, fakePoint, fakeSensor, fakeSensorReading, fakeWeed, } from "../../__test_support__/fake_state/resources"; @@ -85,6 +85,7 @@ describe("", () => { expect(container.innerHTML).toContain("plants-load-in"); expect(container.innerHTML).toContain("points-load-in"); expect(container.innerHTML).toContain("weeds-load-in"); + expect(container.innerHTML).toContain("zoom-beacons-load-in"); }); it("renders top down view", () => { @@ -127,12 +128,13 @@ describe("", () => { expect(camera?.props.zoom).toEqual(0.5); }); - it("renders camera selection view", () => { + it("renders camera selection view", async () => { const p = fakeProps(); p.config.cameraSelectionView = true; p.config.viewpointHeading = 45; const { container } = render(); - expect(container.innerHTML).toContain("camera-selection"); + await waitFor(() => + expect(container.innerHTML).toContain("camera-selection")); }); it("renders no user plants", () => { diff --git a/frontend/three_d_garden/__tests__/index_test.tsx b/frontend/three_d_garden/__tests__/index_test.tsx index 36ebcc934e..22eb2d6391 100644 --- a/frontend/three_d_garden/__tests__/index_test.tsx +++ b/frontend/three_d_garden/__tests__/index_test.tsx @@ -34,7 +34,6 @@ describe("", () => { it("renders", () => { const { container } = render(); expect(container).toContainHTML("three-d-garden"); - expect(container.textContent).not.toContain("Loading interactive 3D FarmBot"); }); it("disables canvas shadows in low-detail mode", () => { diff --git a/frontend/three_d_garden/__tests__/progressive_load_test.tsx b/frontend/three_d_garden/__tests__/progressive_load_test.tsx index bbf7942c1c..063eeb9ceb 100644 --- a/frontend/three_d_garden/__tests__/progressive_load_test.tsx +++ b/frontend/three_d_garden/__tests__/progressive_load_test.tsx @@ -48,9 +48,42 @@ describe("3D load progress", () => {

{"" + progress.isStepAllowed("bed")}

+

+ {"" + progress.isStepAllowed("grid")} +

+

+ {"" + progress.isStepAllowed("plants")} +

+

+ {"" + progress.isStepAllowed("weeds")} +

+

+ {"" + progress.isStepAllowed("points")} +

+

+ {"" + progress.isStepAllowed("farmbot")} +

+

+ {"" + progress.isStepAllowed("details")} +

+ + + + +
; }; @@ -61,6 +94,12 @@ describe("3D load progress", () => { expect(screen.getByTestId("current-step").textContent) .toEqual("environment"); expect(screen.getByTestId("bed-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("grid-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("plants-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("weeds-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("points-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("farmbot-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("details-allowed").textContent).toEqual("false"); expect(document.querySelector(".three-d-load-progress")).toBeTruthy(); THREE_D_LOAD_STEPS.forEach(step => { expect(screen.getByTestId("current-step").textContent).toEqual(step.id); @@ -73,6 +112,38 @@ describe("3D load progress", () => { consoleLog.mockRestore(); }); + it("allows steps as soon as their dependencies are ready", () => { + render(); + + fireEvent.click(screen.getByText("mark environment")); + expect(screen.getByTestId("bed-allowed").textContent).toEqual("true"); + expect(screen.getByTestId("grid-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("plants-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("weeds-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("points-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("farmbot-allowed").textContent).toEqual("false"); + + fireEvent.click(screen.getByText("mark bed")); + expect(screen.getByTestId("grid-allowed").textContent).toEqual("true"); + expect(screen.getByTestId("plants-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("weeds-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("points-allowed").textContent).toEqual("false"); + expect(screen.getByTestId("farmbot-allowed").textContent).toEqual("false"); + + fireEvent.click(screen.getByText("mark grid")); + expect(screen.getByTestId("plants-allowed").textContent).toEqual("true"); + expect(screen.getByTestId("weeds-allowed").textContent).toEqual("true"); + expect(screen.getByTestId("points-allowed").textContent).toEqual("true"); + expect(screen.getByTestId("farmbot-allowed").textContent).toEqual("true"); + expect(screen.getByTestId("details-allowed").textContent).toEqual("false"); + + fireEvent.click(screen.getByText("mark plants")); + expect(screen.getByTestId("details-allowed").textContent).toEqual("false"); + + fireEvent.click(screen.getByText("mark FarmBot")); + expect(screen.getByTestId("details-allowed").textContent).toEqual("true"); + }); + it("marks one step ready", () => { const markStep = jest.fn(); render(); diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 06ae469e62..eb4f98d2a1 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -172,6 +172,10 @@ export const GardenModel = (props: GardenModelProps) => { // eslint-disable-next-line no-null/no-null React.useState(null); const loadProgress = useThreeDLoadProgress(); + const markLoadStep = loadProgress.markStep; + const markDetailsLoaded = React.useCallback(() => { + markLoadStep("details"); + }, [markLoadStep]); React.useEffect(() => { perfMark("garden_model_mounted"); @@ -204,6 +208,9 @@ export const GardenModel = (props: GardenModelProps) => { BooleanSetting.show_moisture_interpolation_map); const showMoistureReadings = !!props.addPlantProps?.getConfigValue( BooleanSetting.show_sensor_readings); + const sceneDetailsLoadIn = + config.scene == "Lab" || config.scene == "Greenhouse"; + const animatedDetailsLoadIn = sceneDetailsLoadIn || config.zoomBeacons; const topDownAtStart = !!props.addPlantProps?.getConfigValue( BooleanSetting.top_down_view); @@ -427,14 +434,21 @@ export const GardenModel = (props: GardenModelProps) => { {config.stats && } {config.stats && } - {config.zoomBeacons && } + {config.zoomBeacons && + + + } {config.viewCube && } @@ -457,13 +471,27 @@ export const GardenModel = (props: GardenModelProps) => { config={config} configPosition={props.configPosition} />} - - + + {config.cameraSelectionView && } + {!animatedDetailsLoadIn && + } ; }; diff --git a/frontend/three_d_garden/progressive_load.tsx b/frontend/three_d_garden/progressive_load.tsx index 7481f77fc4..af54e84573 100644 --- a/frontend/three_d_garden/progressive_load.tsx +++ b/frontend/three_d_garden/progressive_load.tsx @@ -18,6 +18,18 @@ export const THREE_D_LOAD_STEPS = [ export type ThreeDLoadStepId = typeof THREE_D_LOAD_STEPS[number]["id"]; type ReadyStepTimes = Partial>; +type StepDependencies = Record; + +export const THREE_D_LOAD_STEP_DEPENDENCIES: StepDependencies = { + environment: [], + bed: ["environment"], + grid: ["bed"], + plants: ["grid"], + weeds: ["grid"], + points: ["grid"], + farmbot: ["grid"], + details: ["farmbot"], +}; interface ReadyAction { step: ThreeDLoadStepId; @@ -55,10 +67,8 @@ export const useThreeDLoadProgress = (): ThreeDLoadProgress => { }, []); const isStepAllowed = React.useCallback((step: ThreeDLoadStepId) => { - const index = THREE_D_LOAD_STEPS.findIndex(item => item.id == step); - return THREE_D_LOAD_STEPS - .slice(0, index) - .every(item => readyStepTimes[item.id] !== undefined); + return THREE_D_LOAD_STEP_DEPENDENCIES[step] + .every(stepId => readyStepTimes[stepId] !== undefined); }, [readyStepTimes]); const readyStepCount = @@ -72,14 +82,13 @@ export const useThreeDLoadProgress = (): ThreeDLoadProgress => { React.useEffect(() => { if (!complete || loggedRef.current) { return; } loggedRef.current = true; - let previousElapsed = 0; + let totalElapsed = 0; THREE_D_LOAD_STEPS.forEach(step => { const elapsed = readyStepTimes[step.id] || 0; - const duration = elapsed - previousElapsed; - previousElapsed = elapsed; - console.log(`[3D load] ${step.label}: ${rounded(duration)}ms`); + totalElapsed = Math.max(totalElapsed, elapsed); + console.log(`[3D load] ${step.label}: ${rounded(elapsed)}ms`); }); - console.log(`[3D load] Total: ${rounded(previousElapsed)}ms`); + console.log(`[3D load] Total: ${rounded(totalElapsed)}ms`); }, [complete, readyStepTimes]); return React.useMemo(() => ({ @@ -139,7 +148,6 @@ const loadInConfig = { interface LoadInGroupProps { name: string; children: React.ReactNode; - delay?: number; onRest?: () => void; fromPosition?: [number, number, number]; toPosition?: [number, number, number]; @@ -157,7 +165,6 @@ export const LoadInGroup = (props: LoadInGroupProps) => { position: props.toPosition || [0, 0, 0], scale: props.toScale || 1, }, - delay: props.delay, onRest: props.onRest, config: loadInConfig, }); @@ -173,7 +180,6 @@ export const LoadInGroup = (props: LoadInGroupProps) => { interface PopInGroupProps { name: string; children: React.ReactNode; - delay?: number; onRest?: () => void; distance?: number; } @@ -181,7 +187,6 @@ interface PopInGroupProps { export const PopInGroup = (props: PopInGroupProps) => interface FallInGroupProps { name: string; children: React.ReactNode; - delay?: number; onRest?: () => void; distance?: number; } @@ -200,7 +204,6 @@ interface FallInGroupProps { export const FallInGroup = (props: FallInGroupProps) => interface GridRevealGroupProps { name: string; children: React.ReactNode; - delay?: number; onRest?: () => void; } export const GridRevealGroup = (props: GridRevealGroupProps) => diff --git a/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx b/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx index 4a834e2e66..634b2d4832 100644 --- a/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx +++ b/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx @@ -51,4 +51,14 @@ describe("", () => { expect(container).toContainHTML("greenhouse-environment"); expect(container).not.toContainHTML("people"); }); + + it("animates scene details in", () => { + const p = fakeProps(); + const onDetailsLoadInRest = jest.fn(); + p.config.scene = "Greenhouse"; + const { container } = render(); + expect(container).toContainHTML("greenhouse-scene-details-load-in"); + expect(onDetailsLoadInRest).toHaveBeenCalled(); + }); }); diff --git a/frontend/three_d_garden/scenes/__tests__/lab_test.tsx b/frontend/three_d_garden/scenes/__tests__/lab_test.tsx index d64521afe1..31d7c6345f 100644 --- a/frontend/three_d_garden/scenes/__tests__/lab_test.tsx +++ b/frontend/three_d_garden/scenes/__tests__/lab_test.tsx @@ -40,4 +40,14 @@ describe("", () => { expect(container).toContainHTML("shelf"); expect(container).toContainHTML("people"); }); + + it("animates scene details in", () => { + const p = fakeProps(); + const onDetailsLoadInRest = jest.fn(); + p.config.scene = "Lab"; + const { container } = render(); + expect(container).toContainHTML("lab-scene-details-load-in"); + expect(onDetailsLoadInRest).toHaveBeenCalled(); + }); }); diff --git a/frontend/three_d_garden/scenes/greenhouse.tsx b/frontend/three_d_garden/scenes/greenhouse.tsx index 16676b2a44..5795056ca9 100644 --- a/frontend/three_d_garden/scenes/greenhouse.tsx +++ b/frontend/three_d_garden/scenes/greenhouse.tsx @@ -6,10 +6,12 @@ import { threeSpace } from "../helpers"; import { Config } from "../config"; import { Group, MeshPhongMaterial } from "../components"; import { StarterTray, PottedPlant, GreenhouseWall, People } from "./props"; +import { PopInGroup } from "../progressive_load"; export interface GreenhouseProps { config: Config; activeFocus: string; + onDetailsLoadInRest?(): void; } const wallLength = 10000; @@ -34,72 +36,77 @@ export const Greenhouse = (props: GreenhouseProps) => { return + - - - - - - - + + + + + + + + + + + - - - - - - - + + + - + - - - + + + + ; }; diff --git a/frontend/three_d_garden/scenes/lab.tsx b/frontend/three_d_garden/scenes/lab.tsx index 399816b49a..10c7dcfece 100644 --- a/frontend/three_d_garden/scenes/lab.tsx +++ b/frontend/three_d_garden/scenes/lab.tsx @@ -6,10 +6,12 @@ import { threeSpace } from "../helpers"; import { Config } from "../config"; import { Desk, People } from "./props"; import { Group, MeshPhongMaterial } from "../components"; +import { PopInGroup } from "../progressive_load"; export interface LabProps { config: Config; activeFocus: string; + onDetailsLoadInRest?(): void; } const wallLength = 10000; @@ -47,55 +49,60 @@ export const Lab = (props: LabProps) => { }, [shelfWoodTextureBase]); return - - + - - - {[wallHeight / 2, wallHeight / 3].map((shelfHeight, index) => ( - - - - ))} - - - + + + {[wallHeight / 2, wallHeight / 3].map((shelfHeight, index) => ( + + + + ))} + + + + ; }; diff --git a/public/app-resources/languages/zh.json b/public/app-resources/languages/zh.json index 649226cf01..25f99b128f 100644 --- a/public/app-resources/languages/zh.json +++ b/public/app-resources/languages/zh.json @@ -1052,7 +1052,6 @@ "live chat": "在线聊天", "Load (%)": "负载 (%)", "Loading": "加载中", - "Loading interactive 3D FarmBot...": "正在加载交互式3D FarmBot...", "Loading slots": "加载槽位", "Loading...": "加载中...", "Local IP": "本地 IP", diff --git a/public/promo_loading_image.avif b/public/promo_loading_image.avif deleted file mode 100644 index f6c7cb8f2a27a729a96766dc0bc0cc61181e265f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 183031 zcmXuKb8s%q?>Btawr$(CZMUbk-JaUE?Vj4UZQHhu_x}Fgd6JpnlguW6?Cj3$WB~vG zp_z-PgQ1(H8Q?$qpSHC$W3sh0G?Noz68;Z*ur+lt{9o)pqO>rvar*yF0Kmc0*yaD( z|I;>>M*nXacn3=t+y9FJ{U`A)ZS0Nz=ZOLUK>zXoBmf8-06f0^pG|3LY4?A%|1X05 zpJNLAzvTaN4BZ%+MC@(s|F5K#rGt~hf4GdLgR%X88r9O-!SsKL006`s003(BpMmIL z>0$Xl1OyTi@;?P@=*A=x015j)2*udm#>vju#sdKSAA|e<2Vqz`*c$#X_8rS)9&{yEF>KxM=hj3Vq%fV7TuzL?? z23rfaxBlMhmKC4adct2`%-zB%=|g{yeAm9K8pgu`y+Az_s!k#7G$8HM*Nu&{rOceB zMiRCoSFCYn_%Ofj@b7u}o*`ucs;XB)hN{Q((JV4SX zKO+n=ScPlf+_U+ve6fQ46E^UhXj$2X;cfAJIbmd$DL=C1irU$5BCV)E`ZacD8N@w~ zfBY(pxx3PWFrxwS)6FuQKMXGphc2dC&cOQQCBDE<-&8^dqb;b~CCW9t$aUfZUiplb zg9%&(i-a#&x~nxf0m^n>b(*?jPT6@-bGpMR+tLf%$~3~XhqY$c(r2GP$J#)v3aJLn zay`W!LO8LLiE^ej9#{9Y-70#v@5}j*6-)?e{esL}4iQc{$Sxl~C6deH8mwDm-(r#@ zUE5J}4TFLU{u7LX)Qc&>=*Us7GkR`&LFH63eb7p4|Mib~6{a0={T>4@lwz}Zg9a=B zY_HO!iAyJ)t!=DIk_jat^#0&DGVqeWojMY=icw^6{v~z)I?EdkN@6dyuk`B4-bR`r zy~O&nJ6Rzav(4l3{aE`wkP=9QtbM3*w;-6Eykt9$D??9tl$2SbIy0P%{!4wC^k*4y zq8NZDNVS1*(Z^om=?4r^7&#Br`Xz3jGVVyhYY%TA7S#Qv@?%(OGC5*zd4N=ag1-s0z-yz|95xH;s!U?rD^}K43++njjlMvkRd-$c`e+N%iBa z=MM3E#Kt=rh~p8y7C7^eEkhW>=b~X&;04yx>={D?AP#x81m|2JKknt|O9*;;Q7VHI z9Lx8!5*kb9C?e-PcYWyk{5*R7L_dufb|cJ3NnE9{tqqc3H8*3f+p=?~;Vn@$&SB887b5zDDUvSD2r<~X&gC1X=AjGF9pEt*bDAoUW$tZc8g?(^lR3Dec#;?({ z2Jv;MS_-*_WTuHe%<~C>kEJ{G2SdJ;!ilp+#K>=&Kg|j+aIc{#lVLUNHBwLJ8NUQn zCitAqFBqr48nA97)sg8x=c=f*v!#>sgLuERMK8!?3zyD&*)(Xinyr1&@&th%vA5U3 z<7KU`p;ItRP^mY*24>^ksf^YoaIs(#Q|L6BuyE zTlW0L(aAJXjw2Rzyk6T-J4~{ZOzkKSWeJvpnUQ3jGw0zv$jL{p4f(`uPTcG(xAHKJ z?V(mfG_s|f-6E$T14=Ym(P-fIZvwIpK`_ z%YGFy`VZAH*^$?;hXBG4({Jo1>r)sf1-zStU&PGwny4sqlb(=|k!t`~Anl-{rdfnC ztn>I!qm$BWzb+y-*&#f1p!*?&!B{gMA+;;a{1-?j22f$~z70^}$^-L~vA^v7c-3gv zpU-`a-9lWS&1&7=_r_A;kiTD~(B~s;PY;aH1S?l4P*0~>j?4^H!KLc3v=ca{M!;zQ zj$8nH@R0dUMsfkmDn9{o&6zzt?LfDwgTYCX2?&TsBTcLz7Rhm$4AYCbUPuW-WyqJ{ zspDB~`GoHCngsTlq?=SKW+4|OQVn#{o^rtOYd=_ZeS+wGPIx#t8{t2qR_Qc=V!m+3 zxU~gXA^eR~(u#E56R3Adw5Q@%t3sJyoHo9E?_@b~c(hC5%NO~D667L8KiDVur926Z zT3Ki8NV4rsWbz!d)(G4YA{9Ja&spzj^=5XCS5isU8=b=2z~@=1MJ(Y2^J5~AWP{b0 z+JCrCR{Y?5cq=bzj5%&cD`aVjpRWH#kU|G5>fRSR+nVEHXdad>>@U%r%;r(y~HPwtHfG1%IV^DY{i-<%*h9*W_6uV#2(wot-e$npo zddjP`uSTR37iB4reP^Su^A0Ks5Z^i4%k%PdDsD`jbPG3=G6)iPfn#b@tIy5a|ifJ`LFOKsiICW5k$gxFAXD2NQHhV2`~GoBq1GxIH1FDmtYXHX}5>^scw?ihPqn+ znucHzd_`u6MH}Q7&t7FXw2V00wsC%J+vRYAdU!u?yDJjcNBb&LQ(WPFBys)Qz?fEI z9KZ&!2AGk-A+#`oDW?3u)@aE~QeE=1cX7U29U~hGI!?o*_(n6D$e}_JWk>kiIj%wb5vTAJ`F;_v!?&GCPJ4_6GdQ-_;GO_Pd8dNvn~HRMoA zf#U>*EfkERBD>o0sn<2;qg8PCm~R<^=b$xJB3o1!4y4P93_vveZliMl<-HL~`z+(- zOBf6Tv;SKMVN!Z9h^zx3Z!WFS}pk{&pm^dMydnVc!XW z3?5a5x8PD6cpU%}NXG|cL0@vpxZZ=_Wdmsyr9hhwx$>~@nud9s&_1Fj-C2ue%=T24 ztF6I&G`PKP>Y>-j;-&G%1jsbnk(_LEuT>QI7M1XdenEgRJ7&3t!0BSl2GeY?vbPz$ zU`O3~<}fH}Ch+JT5^R?cdV1dN#uT}M=A?ZHWFY)0quAErID7JC1 z?{XV0BAz5jAf=ZF&zkS3gVd@r7Z42y?&sez*&V-hpBVkA9%-MlVUKvoPJvZL+d{dK z=-1JzdYlx?>WwH+-D%c~D(;#iK-JoR$J^9#~AVWb7*X$sD(pd;#J@e^|BL~ zTM^rW3tNZHPKo#gP^4oOr`m6+^Yk#B#H!1s_|XIqyIee*h4>dW_P5H_}YwY#IUB{01=_)k=bYX7!Vedvinju63OZ1rkP?8#-r<0>&e%{-; zbL1=B3EPftzY6p}Cw1`2u(hs)ZlKO8E_9eiwwJ3%zhW!QjvbqzbDCKmfV4#tAo?YF z()g$#+850?fIF2GQyFuks$p1{m))jbHT<3xwSwA?c@OCYm-m@L7iFm8uDXW|-hzM5 zum8Mw5Tp+grC4M`Qb002tqi?Bhi)tX9ZrJPg3Izbn_ln$kgZl6wUlBpM$M10s-4*k z(>4UtYz>8u$uEH&Da`c(9k4|4!0VfVduCrjHT9ns;E=ABUOUSrVapIhGc^olau+Wl zOKoYChS?fio^ushKzQz`;N+Nc%erB8MY_+xHH?B8I+B&n<(cGj-`DA;zzkL(Mk}5x zdLmm2{2A^ngqO&y6=6tk*Q%Kl<~mXNW|NGMA1E-Qb_&{g@z^wG?iJ6^dqRVj8rH-u z0$X-%+UPay@eD9d<|YGfO^H^v_Hj%mfb|z^j)W7Q@1>7A1Z*&8Mg2stHpL`h#tD5q zyMDwJ6iVS{nttGi4?k+WS%KyZiF+PT)Cl2$ea)?Wf%+z%r?b-jR>&C9GY?Ks;TYJ0 z8O!4%{bgPi?zbj`@3q$P)dn`;H*h`JiQwiy#E|>Vif50?=`8Hb$VnU}+sxg6!fu~j z=gMroISWtOSAQRjw2sx#J`f+AtPqTYm~gjTyQc46NSV4{E>uSy91nxAS&7!c07X+}7+LA-iDJyTvNLO`C%e~_@e zf2t=+c^gqWU{*7Wf=(#2vpr-TZ0Cu#vH&%q|C)DLmWh?@oos7`!ri6Ov#CabvE#Y&^)i zt&@6zF4d0tVVH_7QrXj$m8stATJ<`Rf3OxzvTb*T91Dat%4V0Lz(HzWPt31BEc56P zFmr~I>Z50=P8aNOF^5>nDTzFxXjmoA#=#-J%St`O0OW17D)SG3uy#;2XW(gh0Z><}Px|->R^0`z1ef^ef zr)>CkPwy!0C>%t7D?gAo*(WBq;a@Nh){2;9vdvVe*=Mlwl5gN#%u?`>0&oD6DgBUk zZSnEeT1oUe?^5p$j3!3xHD3xv;WiVO{!_Hct?V9-+>)8)2TF(DAk!+rmJ>T`UzP<^ z*hy>R>x|^Pn~{BQ3A!9?smZ(=Z$1&mC@k`}4Tap#_L?*^L`|sV69QBrx3KQXs!WpJ zEFR1j#}xSW%U7xX`%}k(JlxWq<97p3&R0M5O0OV1GE;1pkJ(dqmddlU|GZ9iSQGl0 zjCM?lpz)dBZeMKUUeHFW?rO0$2{2f#M&~AP)_bJ;0YMZ{QJ2w*iW>|vfKb7Ks;-{A zJ(<)uQ9&am%hjUAaIgAI0bATrT5{nX>FH`a`TE5JunS_Bva-%J1&`>65#W?4lGV>Nz5S?}!-gD>Z*|Cw^v4*CmP z*kD&BIJAxt>_k#R7zA=NQJYa|D~fIW@01rVdn|U~ zfEAwzlmi&{ykVON+{hLZ00gxWAs4WvmvQLibLI3-@K@wx5az@4@UJ9X&SvtsWk7ta zOez@#yeu-}w4RKPITkrZ*Idt;gF%>0=w0Tqe}&qe?;`{P6(68nJwVYPL(gx{aexXR zw77iGo;#LdeBsmYJR?b`xjx=saRA(zFVYR#Zf*gF5m6~^&htCbURp*)l~Xr z%(!AdfD(+uf}(0D!fTj&M87@tV%yGOJd+}w@{5Kd5@M;M%8IcqZa{wbAf^JZZhmnw z|3AFI*%E5?lrM`s?tWlpeJDILFXqyK*s5FbJU`@hJ%S6X#ep(ta&IJOj8d}Z@b1(A zU3+dH9Zr<|#NAbMUDhHSzFEJJLWz)Tj+WQi2?s_#(@i|)b0pWi)_Rwacvk<~BC${2V9}^v`&GqCV8b%;f zSuos(-4B3k)74ycnBQ``9K3&E86=FVCD{|h^vpeaIfDia)ICa|JL@h|flJOp1kmZ8 zq%R4^*2SyEEWOWnhL{C-!{t6dhgMwA6hQgZ!cD%RCJ7E=mBHIs{Kc=j-Nz`T5((5z zniuhl@{(G{yMv}RwCedGfT6NJM#=CCOUQ>IYVXztc4$GooKCDKML_DE_&;o&2A6{} zdqi8x8EysnNT4$*r)>n0_~HAg+WQpfvuS5U|Cb^5&>$Zi&5(MEdY4u#_s#%pOK#b#LI^ka?VW~`s zJF5cK@GlwaslI&w41H4Uo&tiTB8?5wYkE8k45HKw2UI*i+qCt{{Hpfg*;DDF`aqne zOEo!o_jGYza@=&$i6+y*^e=rWD~l^%ioj2EPoH#Swtmt-$1U(UgOw{gl+V!YGIuKD z+bu+XtKzSg{Q|~V*oWkJiCBqZ-)PPuQ)d)l- zZ)u7J-2ru%g?j--+fp@$RYr&GwHZ39-!weJC-R&`G(kr&tGmHaK6~_~KDcH)IN*-9 zSi~G4IMR<$y1KEAP_aIpY*E%SXz2YX(HiMzt#cT3MBc&mo`S|87sGRrezFdwU*%xTz zO;LyNAQCTTNGO=SqOadjO#1D*W@RXsyzs80gP3ew?xW4$eI$}-^pfQy4jcQXGRpG0 z+u+Jlp*a0U@rCG734+}nu$aQ)I+OoqBT+a*pqY9ppSZ>P)ft$V32qz zA18PXC0BIxM%tbv7z3NU%C=Rn@;$2rETfiiWb4AKn7q0}A@Eg;_SnMuio2`!mfIdX ze7q{Imj6DOXceVA|0XQk|3=>ca0_yO zDk{Gn-N*$ka-0%r{V}#b+{nan>6k(t3W4(qCbycxf4e>{##ab|Jy*Yczee1F5Pw~V z;^;dXd|E--_X4Yl<%WWPI?K=y;VqG*obxfp}E zrb7Z{##q*@_qsw74*VmFsbFgJa`QvP%zNKcj|U>0V*+j%;6Mk2utIdc8%*mmHAbfa zyLNk}3Ca9DKV1MK($T`4Hi0T_rO>MHllnGhMZb%C7gPO#O+1#`T?qYR@z!|xNhnh< z>TS*E!Y&OXIz>keLs6QNl^y+{Hwy2pRBkVvz;~wbdc7bpIB|F+xh`la#n;VSya;IW zcBEF1+4}jxS=DaV9QbBrDqRShQvxN}tL^Ov=_TrLqG1oxPd22I+y$Gz41PTVg%@n}I64bIUSk%$6Bqw8OV5W< zw^#yPL+Y1}q$AU8M?JbGeE87BKomniU}mK&(c>g+TV7t`UIF{Nn3mK76*$)H!y)>J z7t5Hr?sormVsFK9o$ie~q&N~*V4Vh-J&jyY$- z9HdtS`eJ~fF3*0Sve|ZQaZBbqKHz~aL}j9SYN5Qu&t<-a)=nvP?57-tx+xZ+VkOEv zr5JtkYvhPp8c?*YRaXeGu9T$Sz5>)Zs-$_O;`LNMv9gt)sGCsMMi7Nq3`NzmFf64qcuHH{>U6=T%eblyV6JfzKwB4{#Wj>u@*fxX-*ohwx{Ck_8I zA3SCr4E!XblIwy6EfGP&L25_$rN#OG+1HG`j`@{UnLS!vjf2ns{Fz&UttrVWYs8@CCOUZ!z*aJ3B9BDsNR$ zecip|H{Ki}0t5O#fe;OcWbd&eIA3wDg}L&U1qAs>{={%p1)?O$68U`53=)z}fg?5ct2@x%IR<#N!Y7}*4*fxlZl$X|I;4VjbLDrPimaG8 z8IYNHA+v@sQ(TSGl1kEEnVax{v%52dSM$W;p{w(}B2wn) zRak<-G~TiGXzMJ`d+GsCbKAB3-1kMS5n2ibWo{3XlCJ0M#eYMb=}^8(tp;)hxolm@ zknVKmXCj^kEYYpzlp9DoIS~Jdd}sb-;{8?>{EpW!mzS24n`;UB{Fnsj*}scP9l5K}C|*MYjsjP< znkmVh1)(;@Il&N|CZ}ux4KP4F2(vTwh8Xwl)K(}kUpA)ior?o35vPe?imcE_dyMlEi&*+yEdlAZHgOx*XvbM1$b{ms9sA{5w*`O14Y3 zua#>3H2Z3ZqJ@o|-(EaDmSXii)6m#pd16x*Hyqf3z>o3hL9tQsP72p;F1;wm6Fgtw z5$4iOUmcA`sicRj-t%Ht%fp7jIWt>?#E{39<^`&z4Cu~lf#GTSN}+tOYzc)DuL>W{ zB_cHoqQ^ltEVBVKxix{jI||+YMyS_d5aNkfe}<}aysngxCZiO0m7z7j?peMwcSeqC zx`O90c=;fq{>&NsfRF;d+a@b!&#Z*XO!hu2NQsId`-U;X~ zl4Jz`L=mhtdbyxHVfxZIxMG=5>MtT=U-5jUW{Iv}tU`OE9@aPb7M;y#6@VE}J9t$P z&!^Gjm{A0!vaExyvN^jgy}(?lM>pgQ3!Uka&REiMNLZ=+ZEG=h4vP+K#cfafC^kji*M(ov28v49=b{~5$6Y;vU=$_y!Ct}rM^Y3 zG$B<^VuPAD%~^fdul;Hae*L4`E{-zT=B+7Fu*{Kxa6Ry_&i!e+Wu}jHzyD3q-0&Xb zZZn)^Uk}i`NcwD#^wuDCmV(!}#SKc@J6i`fxQWWe>hBBe-}?dcPfS(`nvP?l!Fvl* z=K>-7YbXWHjs@Pt`s4FJX&(;2eoLhMMW zd)9#LSXfit&`4{#qu61Sn0l@s;uukj;IGzq-{+U!G}NMJuY>znO-1p^Lh8gt<1ozp za|P24M$bQSE0XF1zXDw%VZZJp8VOTJeKaLb3>dbXWYhyES&h^dJKe8GyeqKRA}tXy zQ&K_CTk%`wx{~nxyd>TI^S$^2%}xPbzgcOJ{>)C`BF2fKCIdr(Oy>T=1XbPZq%M)Q z-<5)8%D?~5FmuFGu$9vxHi{ANc>ib!d<)sl2)t$d(&9b~|M-6MK-G1H(^=;!g^V6> z@DCM^uLydY3zgRZX`Z;P_qDNzfN0ADV{dL#66~U8(KtaoYRR{xJtBFXOySLGSc7T{)oU4XH%>Oi)HhR zx_OvGdB8)^Lcg2R?N2m|CB^%a8W*@T!gy-JVJZ&1&IYfe^e~m}CIxw^od*fNug*e- z|J_5ju3!5#Sghb5h^dnQw#33**eK6%vpKOvN~Ni;puYa6`;(MdKaJg|KdD2k(~&p) zggW}8WL>kcny?pbbRk`sl<)m#yv}?-+wnx4i#8$1hVfCqsATSXG4vMnElZ2=A-n&0 zCkEGDukssj2i#uJkdM1;XgZ5BIOvMi|^fncU1i zvDZo;>Et$U4J?*!$KQzWE{fdXuvVi4)~G=nd9i$38Rd41KNVE>Lke+kMl5AK$`;*? zAKL?n5T-Bhv%+~whAHID`TF4nZ#&xENEyF+M|gRVKCfjGm;2Sv5cVS@qGSE8Sz6?u zKqOl%sP9Cx{le4v^gDgx~VnBVVVLD9&=f{ zcm?#yzf@$XJlc_8z+t#K++VDic+3InnmsW>kywJFq8Xqk#z23=_wn4>Eh2Qz3^Rbh zG&C0!3TW`JWr^R{VyKFw#;-6AkQ+8CX-$XVXMY{T0mYl#2R8}=Oh~(0aZcvEwt2_& z6(?wfv`B>eF)6q{u9)*5tn_EvtmxY!az(N~%;C+wBUAVEZ1%@^k!^#>KHS}!36kcX zCWgBeKRGei@jBTw5pBkKlR}Zy12Y!EGAb}TsZ3ieQu;7xi@tL4 zK3z->XgxPER(k^YkGvL#agcT@@zC#a$62xz7^k;@MV{bY38d<8+!@Ojs}wYP9kg&& zh`sJp1}(*jq($XuY3FMhC|H?QCd=?5gR({lzi%zxJ4&7c&09-wtaoP*d<+eHexC{l zH+XHfF{sAslR%rLPq#!a{iMhXueEYI`-PzmOka)@r*#QzJkR0Ch{cW+hdx@_i0a zEP&B4?QY*jmBG02+6%vB6&7xZfNhuD6W&zFQhr$R$#A<3O-=&-s{MD5?M+$AzMAM< z$m_pfqg2T(9{`O^#0q@DkaOJ%@k?F3sDyYLmL#i)DCI2UwMn}D0p3B2v=~&OPfbq= z4b#l%`?!1hT_d954O=aMy=9h_i1k0BbC`U9VHp*`brCCK;ml2=c>+U#8Zy;=Mvz6! zs+LUcAf+Cg@U5>~BD$4$0k(8_-85AVg7S*qVlnO9z5ED>X)Ci-^w%bv@*jHm0jqD* z2S-)jw10f8|Euoz_f~F)-sp0JqW0XofX27lzv%dZ8d9_9P-oC!eJPs)Ls`eiBOAl_ z6Q}odihAQEJ@L0paLc`tL9Gd>LWZ-2lud24Bd351gZhk#mcd#@gpQbM1GY+0qxtTA zIAI3;GB`ls(lKS@51M;0aMcg-h98fms}E2szDZR38RP@GJJ7_JzlR5mB9XxR&!))M zM;kVR+S?i`JTykV-p5HVJFVy9?U*fczrYPt`nkR+W;X+jW@7muL3!t(QqF#n^e(6+ zP=A0wFWm)MwKJQQ@Eh3XsyKz>1+7ZVz7_a#--I!-`zk0-r~aV_-BE@+s_xBz0kkf!FLmCck<_3 zo>>A~zn`PKQoTlm`xE%5b$9W^4k+@eQjUmHWPUv3@M{ z6v;x{4I>);)LX1c_$RzxDsa?^J;*?KQvn6iD$Sz7jAd9s>*V#)V8vE#jXd3Q^qm$O{t#Ikq?%jJ~)#7kr6)2>*&bv~dmiSG?IY)uX9?I`1 zi}Xy%ozpUv%SJ^sZnAUPr%$``r1JoPz|=NtfHEnpjFljTupQVL^-_&%GE}WH{8<3D zJ@d~|D$-pwk7NU(pvyh#(82B368l;kaqL5>`@mo2XACHr4X8=wPOJvM9;}H$IG7&F zLj4qkhH+(#99bPg8Fit(Ap&s~jIqn76BBKQu#BigkUJt6Et+VN`WS4O=EM0+^Y z%+UR!;^N>DM2dVQ1WUJ2%fQ0|=mJ~(N?`;88)DR`ua!1#@;9#rrJ-hX3Yj;dtF$k= zAgiE+^l&-(=2wJ$>SaK&{*h);IeIo1b0S?_`L0ee|LVF=M z=jGP&zen86pGn&kCL|vn)BEv zWT8^I$%)c4t%9jhp3cSM`L|+i^|~R+mDqMt_;RrsHmW@%G+S z)~B1Io`xK_%rq!OaZ9@(`e4c+PBjia9)A7#Zz9v7=+{2L58ZomsgJlO1*dI{&wbS< z^lw<1X`9*+;E`f?C{)$mUxXsnX$E?=@oylp6U^7B7w?8K8_Je*#e7}{BVVcAI@u6# z$^Tq6LQ_#3)ph4MDv-~wMh05FAQ@D0swK>uW0(73!WVY(1QZ|9txBjlU~zq$$^%KV z<46SBo((_Xw7r0?*8;mh9pijTWp$WH_t{rjK>}z|c8~u+pH`S;?qj$qCvUR8Da505 zMbEFwuKsSrR_8JGZWvMVG%HulEjk_0Q_-fABLZ#&Gkz$+B7 zK4Sg-?^EZ`oOG<=; z-Bjf60;Kq?^}dFw6^f)Kt*Y85p3f&< zsN=1sh+iz#!?1)E?c+^?*J({)89ryyGS{I3*|=n^PEjr!0TibKH55D{B*cfI~JDZr9P3nXtfN zjB*&<$=+Sf%O=QXp&fACqr&%TAu;!-b^a+T&K&4yi^|i)>>&o_c3^x65Iwv2n))@v z&oB0|X-fvU>7ASEOMUl~oKj-;IaB3uK!cw7<;!`kdJpf0>Y0#ZWB%>}R6d@YLhC1V z!zd}Hgq<2o{DrOhq;~JmYUC#S-ekG41Biu!Xpc7QNxd%+9L{z|MO0+;EqOLVe%j7` zn7nhAp`gh+;NSsCNE(7BfA2wG;ov+lyb*n|5nac?CJ+8Z&QdCSn5;XBo?{j1pUxLkyinJAfB3PvxKQN6y|6S^ zqr&X3$ZcnC$s5ZdtHuL9(hzV7<4p1x7ws2|A`HPXFC-(9Mlv_W622?PNz)1JLeqLR zAN3CB#CY&M>PKA-22Z5nj}>^i?QI3Jk&TcyVC^zqZ?e2EI3~9i{LGeXyG_TLON$&6 zz&0IdfkzfruKmFOXbZ#BTxP`WJ?x^WkcO}CHXa2k^kFc5F7uVqeaF2Td4&wh7%ok! z&ytDcC{)*tWUkx_&&6r%l5$g#A+PGUL>qY9Y#>xxG}^Zs>1zFT(9ZD+5_M)S_Deq0 z$!F1ASQoQTpW`S}dMN!6>2;|~x_y7J_GahS#~RIArZhf&Czuo6bw6nEYHc?p^wC>q zy8ko;geXRtWJmUIu8LK*wX{q`D^~KJc&VVLngfZMQwW&J3-5)(2#O50TzxKb7D(Y5 zTZv0@+Hc$xxm7kOc}5s2rhFVccoZda8oOJfK`#cbUNllrjkfUWzu^*@_Trih$=mcl z>9goFmdZ5O8fKXhjB|rUq85=G@2rImAZGn~_=1-eZD4a>w#L00*`C?f=H#7UGA2-t zo$8;*a)0d$n%=sz>o1SCeS>I6AO4bMjnDgxk19nPB7VXIt7q^M<$ntBZX=GLw1kQj z?bpr``j1%$U2)#|KWr@rjb1$i+r;0QIby0|Y@y9GELcK*KfBvP4=WZZGG_S-Cg8oG zL4;=UQKEIT=DTS*;c!#lAX4bfT&Ow%o-}xVA84Rz=da_x(#* zy(})WHuhwIXM*^)KXvhPn)BJew(ys5y!UAK+d=Y*u%Xm;yMt%?`g(<>uA0Ko;zd72 z?|1rL(wjNR(8L0+Z!6s#7+uOjy9I}!iP5_o>6G4OW*BI#8u{9uUl?0%In9Y-N2>oV z1jQPSMV+b%EwEG`H1cB#ZSdEQ=La>mcQ7QLsf{FcAvtc={FuV-PM zYy@*@Txr%C0FU;)h)(%c#l}D+PL^AnR0Z?#X$-C|7GKfg>dSxUpB)}#RoA1?Olvyy z3Yo@=WzTIOXBudclrI%KR?~iju0DM!aYt#NAIKb;n9-a_ENVm#2`ZhI;jtSB=QC!n7F*r^^E)LD$=^23)A7%MftsPoSxzgy4y1N& z4$`-S)=@)}bw%fo2hQ#U!VA*8Pb6}B6yds8&F8mg{C%E7vx{Rx?GnX1h(-;VZVrLc zChW5FJ{Kx!im;fJJacV!WqmZ7iMN;&us=patJ~x@7}+?5zK2w?1^Xu-1sY7oaEenX zsGDaDnLTAk-bG1aq8s@uQFVv-lI9f8Vaf7=Ezjw#ABETUCFY)G+k*R~Ko;0Pfc^pf zD^E4N*@>$$&^cZGcXiMbH7B^9$c4t=7o?n+fg*U0Bc)(Sz)1LT>?ksPoZEL-^d?hf z8@{mnnHAS(r_m4B#)By*FMY_ptcmv}ge`l zx+ZN$XPZdCt>1oqwe;P+$%xmUQo&cq>+)_VK1^SM12fpcCl*-^O^8WTIl&tCZmF7c zv*z>uxTQPBa_6eE>UQ*i=UQ$~PM?GI6@ps$%Z!SC>AbsIf_m&Y)Rc0jkrZdYli%6a zUd;#7zbut+h=*l#vVvEVmX35q`O^%Yl*aU9v6$h@x65G0iP$l-B(=d#Zc^!ms@C-g zr?qy!AqU0PPo6+Ld3ac~dHLuu^%K%JzYRqVU5jRC!YSj)HRPbhHI>B>K&guX&N8=2J!p!)Uec8Hmr93IBAC2x{4^~E?<&2nJHoZMEz&Kag zNJa2zO2x*I61^+NTR4P4c3}x%yBD+`PuFCz39O>`F&qech0oK^KFS-Od^G~Oz|9&m zMAzMS(1_N+Y|}wWt;|EQCFkPmwv?YC0@tn3@9DD3dZ3YUED{a?o=(_)wwRoGxU**L9Y0L0fs;?ta>Z2)ODOkdzBYbzDyZmuWe`S%!hLG&Y1lrnDwIylpmiB#Uo&SZJQIh)*>`vRB zJHQNFaOj5>j|rv&RZ?XnPh%^4K zI@V|u(x@mCjwNn&kKpyCDwp$ss^5jlqOWI(^A8h#4Qs9@hs{kBYx-l;8lh)fUE>ww z0R6>?1*}oyAT4zjtkOdjBYVhXq_V330Et%m!6TPv zxkywr5~jt^Y9p#mjs*LZ`8UUM0BN{sTbh5P;o-IWiOEG}m{r;ql5n^JRS#c`6~VTB z2y>l#P(kUi!6;#spKI?aX;CV9HuT-LJ*vFjWh5YELQ)?_8w`a|h{xo-(>;Qhw!w-? zIVHv8hV{oxB(V}i$*1N8mg*lxflUK>iE8g+5~0u8sZ@9lyDq$(wcx_MBJ`~;lW_FM z*DYMlHmJpH8X=E(k&r42!hL4Q-v~?9n$2!?y~tEMJ(rKL8BvV4eO39LE01Z+2meD} zYq%0L2n651#y*;Oo^=5Xn40l8uKk?9swY~0Fk18{bDHB=J*ApEn3FVW=rHjz>uJqw|q`spIEb}1$qZry3~mgw#n>#X^_Fk*o{gc`DY7`t2@`lsr`iO-{0B0 z8ltmvlTrFFPDH8B?m|$u0}vHAEXiOqr<_)sB|MjsRL(>q&R{L|scqyxRE5cDsO{iH zL%#HXQ`nUUe;~^$NUEx2l2;y@upBB^ea&&1>~>^Go=l_JjoJy2$4xnNH;o#`fHV;b z!fc-2yxfz%*J{+NKOcOm;K;}UrC~J3eQ_uD?MyiH*0-ugk<;-_sj4i@_VC_=R4|Vu z{;@(FYV0$JXZowW-y%@z_bd8e0hZNIW*6lP#FE zx@Ul6UlDfIGZH;$JD;&tej4ADivffK8)Arxj58VVbSdmKa~GNK*S4r0J4I4u;_no! z`_qHrQ)Gdv5l1S(;s4@!IQGBjBxDrQ~DNQXr2^Nh*7*40H_doZ;`$f^KAFNFE zz1|vwYYV61{OAtl1G-8FsKK<2p{H;S4KoTn-5()Xz)I+K5~3qL|I)-%My5zrYh5>0Hh(E?W%{gm48g_zfx4%G=0HS0`RstyJj54rr>0#;1{qaRL*S`3X@xQa%1V$cm%ekc)2tLVbl7 z)Z9zgFj1XcMCt7(?g{15z94uW)S6B=yX-#n`xqB(waCMTH_hv+FuuN!5RGfI2yJz# zDT?}@{%SHba(v413blmV^w|##LA!M3N;%9*CVH+A{`CWHv1iA+ZXQjhmRftLu(3Bf zR1#kMn+cAhKH|C)MBLN0p+<|&(#!lFGWcN>i^{<%3QwCRVSNYWo5}t@T+Q zD{`&z$}Z}bEz^4R@AQW=E`f|`LsTUu%+gGIrtKaC6Ls?ML*#OwQQcr=fw~@F0r6)R zJmO2}KiB^s04+e$zqz<&e>j<*&BCzsVXgl!RcPPo>BP-pb<2UWUMsekU70En(y+k^{yLg zt%4%Hq+uP+)N?c9+werqN%WfA3nq8cFhH{bwd~+VmwFM-L^%Hgo>BGZm3aes{{e?? zZh0s#iB{}04ro-d%jML%vs-X2*j9;=RpD}9d|y{CLcBJ-Dv6Hh)?cDsIhN7bl+@rX zHPeiS11ym9kFz&EyMh0G?9)L_G(^y)bNY=XszFJIuE>`0(%fZd030Ghs)bYYwUfqO zjxg@Run4HLqhf2A3s$-E&Hh9nD>#aM{LruwFmEPzV)9~PKgj31p+CiC9Z#8Da6g@9 zU7G+lK|@7@#{C466mis~^C$_w`bnJUS8q;-T>8FPZeZoV@$Epk>@TPt zQojeU|HB&~etFc7c+!_0tp7ehBkE{oF}{$1T*zDHfLDwyareV(hIWd_j@A9ip!zd; zGaHOUay#FGlOeR8Csy|?eIyqkj?Pne$*?G;yjl1rxnUgAYP^w++7mjRaa!hj?3c7MWI zv|*BH3NUq#&|&`-$oP)RQT#h-vI=%;_93B#dG3vak5Tx8X{l%ATCkIY5<@cPk%xk% z)I@tFCoyZ0=_NG3dos20D8sRq?FvDHCJ7ci3iY3$(SQO?KM|Iedf@bA?n)+QN){`} z+S0mR^!i@gdI()6_gf0|b4)MvX6DE{wYMBaSQE6ldFjYC>SS(Vdb>LCvK* zoED7e-H@q9UME4OCPi6GV!7}ZSDK~W$; zU$0h3JRAg87`n!Ix%NynV4zZ1G!C3Ya{V!@xBFA=A?S&IH(ZE?oaM9@^2WqB9?!0z z1(uK)_2xW0tqtkKDEnF--I0+DHp8vckyl_VeRHI4aj`_l0C^{cg>3SJb-2$LNefZD zwot*Q!{{T50UbNZk;fjPIdd|Qf8Ag>7afJE97=T6eT|_+?a%zEE7O_!VIhs|E&1 z5^j{<+z!DX43vier$aJ!9za%-jD(l zY{A(60jsyk<39hB?WYs2WOY*Z!uhDw&#nv%thRoIpTmZtP{om@9$G;ox2xJVFu3E6 zPy?UODTjo#G<Ex0UcCXG->60MAmt+1Yz>RB9c^Ux?! z(qkJQezkqqP&q1$06#_mRxx#C#ufINfYao(7z4XUuvj== zZ_A{S`W4d`mbSAij!e{mlB<&_2ghL{4?I7%zLNP_i&`%i&A8^tx>3lCWi)>Kz zRp~S>8pfVa{{DgCxY{MR{*PD*@9v1O4^WzsIMnV_AnH={JFF#TeR+ZtNgHXFYD~!@ zCFd!ar)PY~m&9<4DRIvW5hsY=F#M;Y9e}(Yh^Qu)E=o4nu;v~$MnrnJZE7jMtmr#Q z@WEKf4XmbXA?=1S=i<}^LDjt7h0X2}>EC(`R<0K-xFb!FRdNT`#+A(VxfWCYqi*R|<=t z?UXfsx}U>&dJvQd$4cWt@>m0|`--szFQ~p3?M&3ndF~7qblyX<`u9#N!)77q**jtr zOZ^I!;T_o~Ds(xtMo#l@vQ6u6#<9q1PvXTAJaD@R@DvGp%BIBq(4`HIIIc2JSBc1i zGF;H;%ihJa)fIpE{@dhR?4zMM0 zqj0GiP8^-0q4-plRV^tgZU%>qH(r-RtCG=uE3w#LuLTAXB@c}n9|&*4j>3y~iFWAr zwb{g=q~kx|N3rmhY#E9G|A;m&-K4aBOy$+mk4iZvYTldF|2w9c754`os43I+5YOs6 z$l_0ZWjC29)Gg$r2&*7CRwG(dn1ED7%)uj5_oK@EV=i(4}Pr^Ol%b%&=;LUcVefrQ+Qz z?-QSAoE2G?R%)rBP5m7JE*`8E^AQI0Z4V~40>3b`&-t!L@I`cSgE8;1T29?5)`Z)YpzC&(%MPa?jy;gbFC> zdh_iD(-ELtHsLBp&bC7upFqc%kpwrwVb5$1(>{+6<QA0^Wnb<(55pEDy zsTTnuUH;Y$Q|ylKZZWMlG~>?oNG>Pnh7tLWpqnHpA&o^~ir)A9$Vs%N%Sh*Et=>z( zTWxbRPH^r`;(WDfV&_Z49ui^S=A~-ACjz4+{CuEgV)`n78?*Fgj|+9J#ZQC2N%@AW zL$Q1xG{c4WJ4HChl;vFX$8LdC|&6QE+x-tt^qJSHONv?Gu|sCliqHK!e~ zn9KI%@gc#m%v)Q@ksrQF!kzfab6o%f34W6(IW`cJGit$th1rx38>xYD+qn$^n9`YI zmzSytti)C2K+XO3klqY8bp`b%D4DZBh;lSR^*nNSesPaHfFu~lOTX1!$x@chB`NI( zro`Gr3y1IsG)c4QCsWzPyJ0|^7-Z;x_NQj@ULaww#?uO( zRcqLxPQ2u!Z8VJaJWKOFK_p@*xPDuTXa#6Sj!I)V(4gMGdiDF6SHlgjVZ7p>Vd=P1 zgd$ajGkpI-G|W4V=IqvmjU2EFf*%jDW-*I1B55Aa=}-^&fC|x>(22t^K2>1+YZePZ zn+1ZuzEa-uR>QMXA>g6#h(sG!xW;5%+UNv^;zE^$|Kn5khu5HcZ*=nl1)+q%LHDb? z;)fS2Xpuiw7l48A_N*o&dfII%TpvNTuXI9yi|RD*oEb&8p3GSH5?>p5N+$PO!ZAR_P+i%qJ|Y8ikE2;$uN_*5hf_mylN{;be@#$LQ79sZ(`y3>Osr# z@MsN7>otfH+AHKeo!9{^0nQ{MG}N_vM0UDMLA-pJZP-fB`?Pfv3~0A~l?_8_WU`u` zzh-o#Bd% zy;`N}1O30aC@0S%_OSxVTIfk!dfSkx$9357_gn~QlTC*dBbg7wm21~kx@LeCr?MK9 ze2ZB--;nEj2^<^9I#xJ1H6r4y{`}PS0s931z}lF3lpbpU{0|E0r95Nu*PW9AY)3lV zboiUgI!>-EyCjTpiA(E7u_v+6L4l8f>ID&6_9>##^5Ll0iPEf(;0cmbfkrgxeRi~` z6(>(1Kr5(gGf)wbpVTI!(baMzc$))_fL!AkLoi;O{p?6RvMKB3_pH(mXZtvJUdY$xw^D{Jf+&kT)rKn3^W5+24DzumW(Lmo7HrxDP0jD|odyyt^mqa zV;U*R^YaHD7$6jZshm^8Jx}JPf}ENSOa>m_WcTFTn!YW^qN+}~Y%|hv5YaY%ur(zR zZoWhk{2$f>S>+#a<*@af^lu#UHYgXJi??`fyF0$;koRPGzYTE6w<*3BPDuWJRw7pp zxk|juxnrPTt5OOOb&&QP7D=Dj1=Y@9h;L|yyfS}|267d7#2~tcbP8*}XdQ<_1V&)z z^7ne^=#zy!pKzBi)iKoHcaaoH=9MBKuVW@zjq{t+BdnQHp#3}8&@{{H#(2{MjMYvC z0;~$wYi_8=9te#@!CfNzEO8yl3&56BR=S92?P%fHwYssU-8*6@>~!Gv{ZjAGdEni> z73^hrx!#UzznRBwtUr@-t(qQ#C`YDXz+sLy#G$y9XG}Qu8YTnb_$wnrH=WhJ=bu!v zO>n3Dtm03b`ETxz>82CA5Y#6VVW2g2cC37uUtqnTzI{j4)7-L__!`U&>ivI>kXZZW zo^AAk&k5a&(9q}^B+Bf}y{z^(Jb7;3t0RgSC+#o8Y#cDTzV>F+?{apf?MI-b=#NV>M1kxE0>4neq^%Ze>T;>&W5!P2urea0Gce~A%M(~pjQth^{5p<+8 z+ZCwntxOOv)22w11}bFk!iad5YtEY-gGKicZeeQl`JW&-D-F%JGbi5i?jQkd&HJdo zH{kYkADqlka9Q?epBFLtB&4wj69?<0p!_z0OoY}@UCZD*i#Zl-Wrf=Us2?V|{&(p4 zb4@L5jo(1MP*0U+=VMu-K$)adiqD5grNmwyk**y+YvkDWx94}w*<*Kz{`IY9Mo-GE4q zoG1j%W#*e15Ayra)+!t*x(Nv8|HApBgSEi81ou(uS33j_zxT7(1%ioeEtR>WgYfvv zE@0sdK>VQ_nEMf#{#H{N%tYd>ydFo(Y!f!c6%X`DesP@rWArUxj%Sad@5Sd1yYd|D z90KQpah#drcVY$N7+}7Vu4?+al(N6bG&G1JO;4$Yk%FEpQHPkDa)E$iWRaUT0pd1k zn`tI}_8XDnt!nlAfrj*d%>In+6(i8!k97JDLAmk{UYQB?9c6-=xlWiI8<|)*5@OO~aoEzaGcZ^ThGfp`YX>5OJjLX&2Vee?oRl zAAH{*ytz6vbP4xLnL{W7g9E`9&2<(_5Odkr9c zdDSs2A*rPnWGTODqWpBV1%&K6D-AFGw0%eF>Ho#phzXadpHl2k-92b9BQ5~rr?Mzy zTSq`p93OH23xu5c8ff2{Ou2IO=rfkV_TZZiF>y{3dNmAV;FQmB3TO)LkSQM*jC3P} zAb_a;^1c39Z8Z!agwJjvJ?V7VTorH+Oqpj!83kQoe<_`_t20u}v=F?bdDW5Kxz!M2 z@i#skADY=qA74Zv*EFa=tw8voU8baNndXHmD%uA_R9cE-PRVf0f#1oJhzyJ2EK_H+ z>A1gA(9BTkj6209zsUbbPd_?5Yu86_X{)hLTHT5x{(GTRtg0C{>yGQ;0_h4c%4*`C zQF=hx#H68x1_LayTQTuli1m&&VEWiy;=osF{+OEr=#s{h&5$fL8AAJbqO%mAs|UK6 zK8InwQUOj<{9CykDoq@9yHHbH9XDoWDO_P|@EL6`xjv~|_02%b<;Gtrd7&|~EW{v~ zQsHb}Ju(X{=m>d#jYD;!nI-@R$`I>thp?X>BNS3SnEQZx)uMAm|28TpM%d>T{(>)t z0ry!3s^{;pu*l3tM$;I$>l>R}M`L6bRT+H5hjo%^BG&Rn?%v}WO>Kt>-$tkuWd~cw z0_GZfSV7##_e|R-M_xd_PsQMO%PKQSmM3APpkuxbUf((7~ zPHAGu2#J6tMu7dLk)$TaK~;r!hu^@z{wTs^{>&pwK1L62HZauo zseB(sCh5nymsNnKf-0jkfSh>IT#M-aJQ<=_XxH45 zcj<~JA^)S@`5ydqW`f#yG$nR@<9!)mU!BhcmFRL|Vl678>Gk-)E;KIkfe>ek@NAC< zE}?MJgzHYHA>=P|1U@HCB<6j;9fe-7F2Y!Hgqu3&E8Y5Pye}}fygh3)iM!ygEsko| zxnLEKQ43tzI#%FC*D$B&RIpIj!A8dSy-p?I24?vaWHz(HEK|28R-oFT8-h*CHh2gQ zLU;lJAER?!Kq<+R^BbsSM|1g#Wty?sJnJ3i?Je%)M4sR_yt&^!$?@fn(tQk!_Z9b> z&h7Q%VLId&*z=q?povAWpt~by3I)3Pr6_R7M?@yW#ua2ahryRM-XVFER!)$^IWAKLXoC^2}&Q2ClU{`H*Lq1v7Eps zYQg(dygwgSv>956nnn$pz`L~6ySg5bF6NNI4Z5cwAe*o&5})`;C5B4J^i?wT)eboB zrR4WaY;ZPD^55$iX0;XYpG^mrCK}eOmbH^ZgP7oHTr{5-)z$&72tS@X4k#{gLqKV1 zI!Ot7=cbb!9?2#4McqTvFm$8fz+Roh+>uVouTH{yjKLZ=hstoEkw;4?khX4xx52vE zvuRy7rC`o5HS=(Zii-Qr0-_@Hv$NwSeaqw?Re`6nGAZn}Ni6wlH;vvDn$F38_LDof z_`IfMzjHn{n{-n})Vu{%-r=iKu}X&Cb;^VAi+Q1RRV zxCDFA%#t`1%$XDlEOK$1om#|nD-u!23@X=1w+*|j*Amv6EsSE#2de|nIP|&WWhT5s zuSIf4XLWG??fJWI*TJF%4jr8>l1*X(BBkXNnSAlkWgfi!aBLiTJ|;Ub7*oORsh+zu z5vTcu6;&q(AAsEVBi$IjM;bsa`((5Z%!9=hoa2D}(7|jRXR@XpNAZHE_PC{>l0{#$ zrdXfrKk<1)x4E8CK_M2}vf!tj{eRmcvi$L|vK%(bngmdO?5;P73Y+tIZYli4UU(mFcrqg`NVot!DnGzbV$RH; znd@lJW}#9MBeg+Xd)d@7CaxFEMf5SBs74KY#HPp^Xj7K#KOl}FkUm0Z>IHlRY!=3S z6!Cb__EIeb6~dwgfRRdt_0w3%Dmg4eoP)j)Tt{JB*49MR57QcQEy|w=@UG-BXlvak z02t}7q~4H?FDq>z;H$ht?o<_0W}6S#E?tww6YtM;sPjBv}d<(&UY}HH7!bn61T{xX{I$je5up_4q zvJy9_mXj(>-44t3q^l`HQCej%29o!c-&A#G03<9<>d}o-RXNkl%@k+*^h)QjdAkY;6N4H5`oGf1{%oCE1OOlkf9TX6@_k>NWA0WmxuRdm^8 z{sdX*MDm-#c4GF1ltaN1(U665nUt6KlK=^0GMbCxtoLe1 z$@^A{+s>J!9alVhi^c0ITWi%?L2w#qS5qT(;Phjku!R1aw1pKUzD$4%kyzjR1QL2OS5wCo~G0k9^jxt^YHqb|+U&qV5Vp%GRe9V2|>G4j_ZHbFz zgpI&{`7W14Y6=2lQD2jxFpvBN7E{Tv==sOF{XkJWnVjDCxNef<6Vf58M!x@_zzfh} z5$EY~S_5_}q44XTQE#g21g94iFQ=&fdz1G9K8RIn#6gSQu=ApUU3%4Zf1)Red|o$;cn&Hq}HVMQO{LiIIhlluP3Lu*?Cxii!o3 zP=EvMTM3jEv2pl&MhUCpzT<>ZjxiaClbz}s=S=Qblm~BWbp3%@1R??+wXAOrKrC0Y zt;w+rzJI{Yu?OGml@8lPEH7FRwHoy-25cb+Fp=PfeDWD^=&ERb4I!rgPnrR#{5d6l zRH6MBfPBWp%Rcj+0>UP;%-xI*w~L|$4Xees4tx;i(e}uv+u)rhSQcP*yhLmW@S&1r zVWutrdPQeZ;%M#20Jca}v0VWq=x!D4;rcSRhoxCt29;`e`!KQ!#^xd>d8V@-+UO0N z8~k7$SlFIGCAr7ZsU0;evypBGN7FSR1E@`P=kS%?4POyuhHNGXdQ+gq;;-zo} zeujA25Y;FtP8aeIDEY#$h!Yo_gVF?V7jOeP&K;{9LL^B3(vTuuK4rN^7GGB^Q0j*L zHB>PgfiGDHJ=hcj7JYEBK9NvNRO4Seb+ZpN0kRHA&8*NigRKH|mVX|jgl5({#_Gru zozf&4fqaU=g|2O4eRS_-PmR2~&xKCVI*A<}3TJwxCEf_|6BVz3 zd}%H7PBx1MZnN*2mA44n+%u9p`!S?_x|0I0erIvA6U7!ME;rr@-i2uhb=iY#)#ds# zE%GF6`LY1JLyw&(z-V}gn;TpuAiw_msFUg6(5^5iE&l)QhgjTDQ7zZBb6bf7%;NHH zKq+;OH+#jev&0O%i^zy5s^R^@KJ}t&6U`mQ&IMUrnPZlVaa`pGN3xNqi4Q#ECwU;5 zEzLQVz$=8i4?acG{@kc=6emZBydZwon8w%@6XlTLAqj$$+aywNdf9;3^AcRk4@ddd z2~m|0D)1@#{xfq%EUi0-bi)w z7*8VPZQkER(xH&w7W`Ev^T$c>4sL6vj)e-|GJcIXqCM}dHq}gU{=zibs&K59pVbM4 z<94=sh|e6CWU4nyTkt0Ch(Y_iNeg}Pvp;f4-V^jPeVN;pO4)J$pBE|%WK-gD33Pyy z@^0*asTRD#b0Q&l9hh-}s}NP?V- z%$IyS0@Ch+99i?HLxjsA?}rkn3z<`hn94r?#3y>FEEqLZas>=~ZlG1FR z*$Sy0X9XiWf5BtNWaN*MlG6R(CaJxp-4G6&FHhg{vF860;}h}1bZ7$mr89x#cebcZ6k7T(J zaW#7y()}iB_Mv5laZ`5BP5gX?P?RTChG}woKi-TtPaZo!N=_?@jmHd{*%&ZeDDjru z2Gxg{x3+q`U|+y&`x8fAZmi<1C9hugz5_kKYUHRxCN zy*2|RW`BCIMxv_@B~luETMe9FuVwzbr)JFMv{I3s>CWIW0iY&hFk*t^vQSEq*voRt zkQpgKf_-JgE~`Me{MDvoq7b1O6TO#z+3oq|lkd{a=YTokAw$}~)~M38-$rJN))5w% zYl%&t93fhxHHvhQLO(p!AX2@tWog)Dsr7MAVhD z#`aKBLdX?nWslFAf(9R^9X{VkoqATM#HgB|Tsl%oB4k$|7& zCxFHtFTD5D@HQFVCl)1UsY&_sxfj5G;OC}!2aiWehAJAuYv2kv;)z5S;8$3bCl9Xo zb)ggQ2qjnBb31`=MIoxAgV-Kct z4UBCBJ$DO;qv`8ziTmwa*T2gL^pD5q>TS&E|%VC93$)F)rbb$>} z&+yX&`A3LQVPQHI)$5&nQ>|GK9^66K0zMXz*aN!XmeUfpCkCBaOHL1N=~y8;*4ZhO zR(Xix$JzsIzcDfi3k}TzKpD#;jm1E4n6EsF_%MPWSC%~X5TLm400w$oEa4)By9VP? zZYCkV-PTjqUR4`xW#l&-D=l%bH@KOZs@X&wJaY!of-(Q|&NwQY$cOICFF+L(gSFr{ z{I(q`FfSwOIVRdtjU@O&pYMD)8L|VEj{WeOmheIhp$(B$8D=nwOgh(#qva|0SfyDZ%ivF!$(WUATsg>6k-xrOoY|{2z_Y{LJvZOpnt(9o4 zj<3u*9i%2Q?RG zAIro)*(;mrc0>&0x5_>&$O%L{qoV;eVzR3bT)=|2!@&J(4|dPiUQk7YK7`J{@;3Sd zOvTIbX$DRr$Pht{cv63GCTJ>%%#0eZOQ%ANa3NIkjfxrxj@00q3#Y?*fq+=o34+23 z1&+60-Wh&t4~iQhj#D(Rf%@{rSR%1QM@b`G#u_35q3=@zI0zQB!)F>4X6X=lpcx3I zADm7>i?1DajLIt<*7+!~yPTN6Nx{CPaS7O{3YVI{fuf%Ie}8=y4^>k7E{kpd$7Mlq z&NB3W_vKMzHYyrCW{lCWxCC1Y#lv)pVbo(k!Jn!u&s59pxH(qc+R}zc+R0G{hobeW zh5sM5s|V1{*k@BfOs}bnwa7V?0P7~vQU>|hBIS>)#rX~Pq@?j8>R}O<7oiEulz^+% zK1G(ma73J7+bw@UII82ID@}Su;RZ2jcgY`o43@nSHQ||)Bv9+gm!?UubtVJa!n0e% z^=E=DJD)mZ`zeb2;&wQENYOQ0x9lcCrLh13%9*WA0y|^=-Nqkg;*ygCT4+gUmZW@? z5NDO6?^*0l3bcJFaB63G!{Vm$_T2mY2sqsqEL8xA&ELbKl(WpsSUrrWNN*%4Bq=po z_Pe6-ww6M+wz`i!@CGOB5`c`@^T2Bvy&t5hxU7RdJI5yj2!oE(g~=H6-ZMYnxc(j_ zhx$IDqUBRJ1@d#dxFXW=C9|e!udc?1!N}mGriGp9-f#+-l4$62Z|Vh z^bLa*l~G9=U>pbpNdXL`Qx!_L5i%AEc4yrzXZMcnZe1{eB(a?9rK(M?=~hIO{C@r` z>cW>6?nlhkam)Uh1+3l`p@d-|YlJTQA(Md{Mtkah3rNC%to_T1 zK|_+zV6mqOb72Jw$-3e2K$aG_CjB_(Rq+QushX{t37B{GghWt7xY;M+d7Yw)037GT zpDP9rOu$9yStxCJt`cr)3k-h(em_^q4K8_o0AT?Rnt9FRoW$9B*)poaIhhB2j<^o_Ya{X@@@6Ii$dFQy zzsI2+%%p$~cZ$OGLeG$jrR6~ifN|7mq-8OG@9y&{4w{oDTPz<23PC#2)cyWzzuZA6 zm-PE+1@XEAYj(IzXhi6_U*wcH=dI*+6#=Q*5$MPahSpJjbP%m2UIXXmOL`=l{TFa>K`Lo!xohdz+-}G65Od9z{m) z3x8zqC>cs!bct_y-M4XS=xNs)4x@c(ATy(FN$ij&R%X7 zu%C)#4}+5StFj=XPxNsmwDVcLS;<7hryaCB%NW0d?vfw_8|UIJuhT`Gq1rN*veO{X zwz*C3;sX7=PU!cx%`{elg->ffMT=!Cnox)#te{yvJAu`LZCmzfC8Vt>2Wl4!zxRGD z=gS8^RDdv=uyzW`vE|B!Q|iZ?RZgw5hg{OG$8}g|!K`Kc#(Ofaj=N;^^>*s{cd&@< zVX@r@(QRa^;l@^TPho?|3L?M((E}}PchGBv{MqxmD8644#4bccN&!{!cSvV9%8wE?#Q^|# z-68*Rv|QF;a2u)FB0y)ntA>u8LCm;hw!6-sFki{09AyRi$_15sJA5y}Quu4nrHbHL zsKkof_(F^Pzyv#xN4grP4*W8+;o^W!<_BrMj%uy>x|YD#%T3@Qr(aMJOyjoO>Tr32 zT6&&2KC`cUaTzJsARY@&Bht0l0D||A=*EaSrtoV5vX>f4soa$tZ} zT??D7A;{73w5@Hn-ieJ&IuXxZu_iG-@sQ=paAE0$rrAFwfn@)rXqzbd(#w{&8{8Z% zJmUckvP!L0O^JbxhlNwxQht2;Mptb>`~bnEad3p3D+!rfN~zuRjzIFBJe=Q$St#ODL5Af;?N>sb8O4y1N@M4-Qwt!JcatM^4iXmV>r~ zW*B=h>v*S>4C^PS{S{LW?dq-kN2KCg1q=38n*io>&X0nvrY z3Xmdr_6A^&L!3VIy97d>o89LP%NlO*Ko|`9fs5!Mg2=&2x;k;_gBqSIE!AD-7YD4~ z?S1Ss`_1f_WyYtO0oN*IpyYwVl=Ft5cpszbg)4#z7*`6qg^E!0ZM`5CGI1FZ)J0v>#3bzO?A)kZ{ol59#!yXPJzrXu5nV>{y%h z%Hg4Dyn6#isnTl%Zx2gSSvgA&@mU97Av3bmENvI#SSwtFW2=xd*R>ssA|h9%?J9T^ zgy2;yDx`FMuJTSBkDP$t4wB*`!^vo7o|GoC+%+;T)8FH+_uLVxO6cypTK8u`!rs?2 zunb*k^ts%!Elt18@TM3Vi?$xiL}bU!{F79FR%D;>90HYCNRB|vw89UosAvbi|2IrEdXjpQRN+48iHnkvR z5HqyE8%^%fP-&KmuYd}>0G@ILO!+|$FAD%7ttGCuC!#xxA^0{w?=Ak6^8yUV9>I~x z!QSu5dgv57m#Uw3N3%q5j6aWy%DcOm8KR(iTCebmkjv`_T$s~;`e~i7_qLk`NJlGT z9%Qs4h-4<7`A8PYeK&hf)Clm&cv_P-NT8ThbJ)d2vAC;{{6SMt*l3m*hSGw#H~>3V zVOlyFltAUcj^n|!c(M>Z8};rTgRMB>n%U#}%?H$Hy%l_*2D@(;kH?P8;8^YJ8=~x z=b`n81aH$!``o&xk7|t9xadA2I09GCVawQG_TnU;4t7&r87i3#U7=f-ANX}Stp9$NSou~)V3sNP2*+=8Oo`*2eoIc*l0r3I=VxD4f;AF@R?StA6} zF!JVMOKY%*s!Chh?f`8?x-7MAR&8>T;0@~R`gqM&5Xx`bs8m;1W{aFuKS^!RAy*9w z!}F}Y*Ro~5=h`{kZ--4ie6k93AW=M}-!e9MmUEruz_5WQ&TxbNdQE~!VHc+v?EPn2 z4jXrbr}&`(lOV_Cze+z1U}9DEwyuwo-^KMP+Lg zOfzSvFrQ#UlR#Tt2fdCDK#daxg-EOw?${7uTQOBVMymDlKaij>y9R4d`rLwPp` zSqT2WeAp#$$b!-<6~%RmUo+gneRkY-!D`(sRi)r>&s{dAK37-Ybd=HLdqlkm7>~aE z&4t`7dh!3mhnC$YP%{%Ty5||PSL0cP<6||HbD-j859kpE9Od*#g8TKX38Et5tz=8+ z6gQc`<0xsrXT_^zp5VnMwj*E_Wh5?b&qu% znwyx*;`Hb|GQJOJ`2ktL>QudqXrlN%@D)OL`$Lw^W_4|c@4KchM9J8z{N8P;QS=>^ z^)W8#y_0xYwmju!cuH zjCPdDm;skv_srmeUF46|N&~ER1KjuqMPlL&Nl7Js6ZF>gn~(d}(tfj1m*tX=nVCZz zc!Qf9=MUj3GE`IdGYE`7;^&l|JJi`bUfo?IzhWvIv&G>w?(>WyTgu%U9$y(=&hWb{ zOvu{m8epsm()997DF}Bza;~0tYDZG*K_K-+5KVo~TV{4C-rU8V?*yjJGoh?d(*gVD zB?3eaW+q2S==H%i2a#^?lp6wpw=Mx_CFuIyBkNrKT64`TO&UpNAM1rsL05f0=?-aa`&0?*#OroaAi5W8cs^r8S6^buc;%QvwX&HO1CZozrer~O*I5CASNUi z;+U7V_SucVqR&Key-w~cRz2wT2ssWxBcrn_!IU~c|7VUYw1^+8h08l-7UcP+d3w}_ z>HST9g|TbM@@Zq@4=BSpKzV#guJ!6&fGSNqXVNC}v?OH(dIjPD-^=iH7FAz>o&1fKSe;M~kx;`) zjV-|0dsfw5d%^&Vw#v{73}Y~xu+wxZ0x}@DEExV5Pd$b(6f3%2dxM~5!8Mmt#a{?_ zH^a1|b$^3&5bS;lD6}|nL@%yvuwfBm7Xo<6C3i8AXo=3AYV^WCPkjzcn$IdHF@679 z*$qu!%nDDyTxAHz%xHCcBaf&MA=l(xBY*(SK3G+qlfKmF3WTFnw}nBa223}%LEgCC zluw3TOi1i_l1Gz?*zDyC^M8Z$On_3%2uy5Pp_=!HLf-Trh3mlE#UnU2r((>ZLux{h zZU3-hu5V?n5EF)1VsUA^(>GY^C{=SJR*Rc5G^^fucYx{;h4}yJeoz5>pVeWI+=CK# zYa3$~4L^0~b*un{Ub@e~T?Yk>qC+kmENsB>`p8B)Xo9c8o|_Gv3>zC$na=`QHiob6 zdO<&=)EsLqaGDNug`W&e{3L+r9Ob5;>v~Ude!z?qKfmi8m$rqLrb7M9nf}qryDgE< z{m;k{4h#SpnW)rhMh1sENLVFkF+6*+gmN}fY`7KjlFWhXy>6|b|1wc1xp5uebK}^9 zHF^20WoA4W;rGc<7zOYhbfP|Ft+zWLO%kfNC-PBt!PpLOuy zz450dD8ja|6WuRD4=t?Nz z%R%_QX~@wEN1(e=wu){>4`9u>BZA#1-!MzC|AYFzj#i!L0cHvspxO$i<`+pk-LSlw z3v%wJ=G)Xbgu}~y5P`2V)jAtmDZ+en;*kxD=J%82<8{(f`iD)5tI*xU+IQQW?x^YD z#-pPwZVdMnj6^*1Zz*c43g__Qv-CXU#)J3l)c1ExFbi)ot4dhCkudQ+F00612{~#;YTN*KJ3ys$!K6_-{nccb8}+;a zsp4RO2gp-Q>1JP^=OLA53`-QF0AoV38-N`uQH=2w|NW+F`_Nd1iY1 zU-sUc#!zR)f%t>o%EdgLnMciC($AJb#zh+X>mXXLHV}h`Mpv}iA3-u*75wxZ21d^{ z=-HK0ThCRD43z>1SL6iWO2@YYE|rjrfg;tZAIRhas+Fe?LL$^rXb@3px^gndSXUGI zpRDj40R0*hL~rq?5Rv%XMtMHe%f~M1cAMv~s!WS-oeM&nQjH58&^omCC14m zvfoxoJg+!>rifvkm8A&vk+D+=dsX>$1*)zm2(6d;;`aKM)bf{w-MMIXUq!``R)X>& zE{b|*z6N!wl};F&uw;j}UZ3BZRO4_8MPZ${_?~b3W4wx}%Q>9kc+rm55-B2oBu4)` z-`#p~J&P4eYwS#2<)-&RG}GArs7ls8i^me`By zo9PH(lo5g61k_a{y`$v1K3;xxqr9ZL;h&q0a)7X@Og3LX-ShFvB9;neTs@k&?f)+^%0-WREY@ z%7yz7Ec3_X4)e{bqhK-1gH2~;|7k)M%i<)?g$ZB*L%fkzTA7XX9 zyO4oI0A2@~UlgSV%!Ihip<_<|{hQ<889>O!TA`dCrC4O^p#>a3dE8IQkL^vmiI3se ztp2Nft@AVjR@@bCBwQ=P9tZ!qr9T$^FO-IKD>mfL=at$un}P&*Fn=XG@~*lDJpuNA!m3#syDXqlF1(xISN&e*^H8u}Y3V(i@wPe^1Bm{Q2%m6TwWpl+l+ z4=az06*0z>C-CtV*u{JW^M%loh(61k@W4vCE0`5^vpF=kXp=Ai4p#^9P>1DVj*?*W z6B`w9v*KWq9NG%HZDu`PA%&H{fc&glEA4qdp=xHmUl#JrP84FMoB<0sR~Ne*Yw)L( z7Zfx$rEdYF{@l(zL-o1U#%uq1i1tD>Cm>i;Bbai^FJJon^iBtHmVrOPw0i7l{YP}(AxOnyucx>9STMpxY?6SlV0wA)R)It% z57k;zO^7b_cxxKxvgFb`6CddO$WH~fqqecKbm9SNACn$4(A!-QW-k|XtV!X+aL+&( z$28**+nMLz>d4eOiG*w-UAhQJ|A|mlQyL1OFsTI!!0*K<5>Xy0Q03M)vsb5C=CBB3 zpmyz5a3Vkg9-tlVrwQ%`qon^IAlEBX$i4t8WB@t9gjkqV^;pJpF*HxTKAg63C%n*< zVKl=Swer0N{EL{t3`$5tCd^*Eno$)lhrTl57#t8k5r2i&mvcYkPtEn zlfMn$)b1YF7fjs#X}ko4IZKVo^|1mHB*UG&ywZ#lKLNICdSg1S0SJxre7y7$Q7mtXn+L+;?ip->DDM1wFi^QP#MHlDiMsH4V5}uw$F=- z`(`|rcQgCE<>tql^Pr=4^htDopLiX1rs8pxvpzGP!BaBzNYgm$ z@{bb1==1wvs`CIyK)1in2R)9a09-0Hen-}n44b<+z9-=&SLg`lW|NhTyviB9vEPkx zt>qMjXujP0K(9#7pf93F{?eU-1INs7d#Wln5{lhByxLcvDHalRv&hdt6)j?0$NTU$ z&;<_LuDvvEC!0vQ)6=Qu4x>zu93-}RP(oYqBS6DdR*s+%Vg%7yXz`&y0<5EfgYhoW z$Y)eorBcVX#yk~pvU0;CJtgwHGi7G&B8Dt8RzviNDPFWb4az>}aCCMu!!avjW{do^ z5zz{1);e zhA`~oT;8Qn|hFu$f zld3!OSu8Ot1PqVE_+UrgHJ}=p>7PV#1RZG)M5!0J*KT*_Ry@i2E@~lp7;u_o`vT>Q zFUK_(=$?W6^V~O|RiWGUtW&;34;9^JQ7iH9m7x1u3IxObqLld* zMR^PQdAWn~(A+D{mzD(K+;&eXWlk-elD?xL(%YEKYa2FfT5YHRFxLZm~ za|a@vV?cJ2jxE$WvrluVtW9uQ!h|%8Df)b=g0lOr{pmub^QlHhvO*`@yi;$+UT<`o z{_LP^b-%#G=aSAx)YkU%VPr?@z_@0-pWdwJd*sHDH97NGWc|Rh0oCe-s+N98SZ5O$ zvv*-5d?fwzq6kwUJkqnrnIlUuunv)V~{_TOl)mJ}<_Mau7Ec1C5a zmGw&-9n&y1Z1eqBKM+-2>Hml2A=+X3LjRv$?(Gc|f>_AnA|srEk!pg)PYoTwShh{C zx9st|`LatQ1FfBppn&3Wi;>=WFA#1bq6pL8K{t$Ob5V<+8zg^?lbhXZE%RCn##SPA z_Y8q3V8b$=#B88~3Z`Dm@=+IDhRA=J3#?)xvwBknR}5D-n&q$90j({;SiM)p^bhrs zGcQpfuF-aSnTqrw0)~tSX%k#1zYz+jZ41K51dG@?Zfu^(^qkAXOHUdEuSB|-fLNOdXz)W!fb3Y$~=L? zD{~u?BUk7cVj>aZ8jKI_#iW)c>wAsbUut%9Pj6~}4;anF)?U7yJxr|SG?mZusZO{7 zE)PAIvdFCA`NmL!`z|@&=0Q5*H9OYC#2>WZGRN)5pqrNMCw(VhVA9onr#P2x z5z|0x&JMeTn!K@>%_i)@R2Jup7?Mu}tWS$Rux<@U>8K*tJ6N8yiy*n{yzGaB^^?o}J(2(sOvbZ4Vg)9U=fb)NDb zv$wC$G+eL;mAJ-?|J+~YJ(ws7=O4RAw7A%^TP&46FyS$?Vz0{H<)Si3wMdIt6b z&%7jf-2yfRCP>ca?E9{w2|isE^T#p`2i`i6nq6_nXk?g7n9m&gs%xWhkC8Av^QF3h z-eTW<{~D&an{&|-%?79%1=6p-m#Rk^y5mc>&cV-uZwc~|SK4d3GQKvnmlOW@v$7Eg zsjGibl(642#S`W4@gsCe_{V+;56}?u2?-4QJaVkzT}>YHwCrb>opXeotlh@=c_(A> zLPbf7-}=*6NG){EY99A_j!@lxxg0Oy}1}s9dT89_TKMifoeY3m!sU5+o-?ej1f03hOufF`3K*}fG zapNFLE*ss_T>>rwe4_JU5oEKMi&$Bg=_dnhE^xG{*7hWG=D`JGKUB1iQ+(>Ad@HNy z>HYgqbwSR9&hc$mbm7l>#7Gqeam8N?NT$5R+qKDeM?UQEXXN6HzrHnROD8{pcj@GG zk`U|z1eF1lXIFWO)UqjZ0 z0rWYudSLCI8Efe!Np2L}kj8p+rnR%za5aJdSJVq}TZUPtFk9s+dK~H~nB4PcWqNRW zuxqMuT+?XT3E;)NGXWYm>v+A9(hH7iD0zby4^xRq4vnDXOzJ>u#5togiuye^#((`< z1{7pxvjwf2lSd4@;W^y*spwU#C7_o-3*shB-0d^~>DgURVUJ_mTNAQs)8P=`XD^l% zEi=Jg`2}}?TT}k zJ`Nj1@)ixhyHX}rDwp#4%AU!oLFP6uPLlh z?jM~uxzs*eA-3csL@GbQi-betH#JoqN?d$A2YM|g(Q6kL#k5$@G)K}%v63Hv0RVoW zjFnuz5To;I`l4_|sljxTaMd)?M9NiPeyU85UAPG2sp@C((U4*hwPB zVs77Qn}w=8Rv&MwcaZ>X0Ke=HjY=K4Kj~Sp@5i>eL%5Pv2{J&uUMCM@E!C7W#Vfi0 zsCQ>gYxec!0#bg8bSJk(_SOgO>kqvO8V<4xei-kF^4{nD(iyC1K47w504@)Io*er_ z9R&$LtpI)(O6=FJ?NzQypFE#m8*njhvya$GM5V)dU1kWf1O+@BTLY4Oh zh;`|}ZAEtHrSIh+xsw$-C^R>gn& z2_V?#aMBRENe4qQEaNv3Y)7lyrLq+2PFb#&1cyH>+J>XaxRwTM_{ji>VHi>KGA+C| z9VPod*AFTz{lr^{eUmXcGC#dZc8!Jyx}|c%J5BHjMeOMUl)FIy$8i7 zu_zwuMF^$^gWK!@yN*efPN8r-sGYO9-og#jGwV{6}w<>@RAf4T``>eR!MkqJxn8bNpv4Yms*=s144NlWf z)?MQAswwdGQmP*(7su=gS13}1m0qiBSR67MVc`zbgbFR;V+W#Uy~ctHRUg7aN- zaUUmC%2pi5wI+cpZefCJbch`Dnva(Q2n{QmURR(qaj{u~RxpOyw$ z<1o0cmdY)si`^a^__R1~b0R*~ zck>&xcaJWfSJwtzaW41IIQx0+RiYVsi}9|@KbH;?IR48SI@$V6UR$nXm7 z<5FG=F+6)TRcPUvHl!=a;a3OH|2k{)=N$9{t}~XVe5d`_$dHw8E)V7MnP`Iw&{pX( zTeZs}BB5g-AE#e2xYmo91%G{59X*B(I5{L*;1)Q2z$BGaT2l^(BJ$&!6bmdL$Q_xx zXgDSZ zf;tE4pcR6(IOWUF(sF_~Xu4)If)>FEHpFV*R`BpANOpSG5Wpe@(D5&vm>H&cep_Kw zQRhc3nb*A#>7M;poFHgfzV7r!p^S(r9(a3p>~7su z@`mC|>S@x9Wz{-R^j{*4#n%YYNgpV5b6ZjN<)=!O>kv!<+Lm8?ybgC=8oSOMC%KgI zm>Ojbm+z&+_vOV1JnwVmwV&3N>B?OI9z|Mraz#OLv|46axeF_(P!G-M%P5Yue>#9~ zH5-&b6|WG-Cx|L;XmVH+{hHm1EjFA*i1qs^9Ib);ucW+`dbQKlxU^VPJFX4wEq?Oo zk+H8LOwkRW&)3C}-7@yIwA(5qlpN@p! zqZr~%BL2ZR3k8Ga0!-2-atyuVu^q&`kShLewU8u;N!DS~Y553=^lz&aqf}+S`|KgQ zd6~}gmKgCUJE^b0Yak!R(&9s={WKl^BPzT2^3U{!hKTDLwnnfAmo+3g-OIM1L&Ga&7A7tkU_w(&vV5XQD5@@@pNu~KZ}yx1n8|J-1s+AY3-H?Z zK`Y#6cjQA*+!(+mV6eujp$O5?ULAy}=hv9DZjOQ@g_S?|k{e`bcv$+B$hwKJtTb46+|H(Puoo#oLddRYV%*>E`_Y~Lg1NHrssgLmzO_NF%5v|;)h{QVJPMIgST#TV-zJ9_zDwgU9|;2hn~ zvR4|7IEkg3ZTvA+@GR!WzuM&#>t-g)` z7~f=tp1kP>U+Zf~Z_8aH>X%b6mxER&kFh?$)ze6C2YEE2Ka`nPC2}>?2RGptNa%Wm z!wg%2#H}6wV$wgutSDd^fCVx><)h2%x#;@oturB(np#ErwaSiTVuaND*H>aD#8`2O! zfY1SUXGrhydHJ%~Z?M$v*m(FANWyBpuK$+jQ=TZ#)P|t;_SR?q@I&XU48Rnc(YcQ( zPOgZ%PKO8FV5mL7 zp&oPdt#)C%WVaX~C_bXThTE0^E2@I&HXO?A6~N;$7GDd9vSRNr>LX0Vsv;SGG{j{CGom;%_YM@BqH!{HB!MX$ zUoX@U)69rZ!S+;z<%n`)o*w8a(SH|6DAJ%1GmfV-$<5oqg$)`k-B6A~{THnak~2@HvQgL zk4&jPq<8`oYVfj2>oXuG_w+4LQ@5Z3$H!PC3S%3XC@M1(dY0$L4iS?4#mqMNH$}&f z?wvHUE+l^s6=DuN_$N6=Zg2%x_cpg^76A<+8ddj9%48)9wOsZ8xpSCKX{emQpXIsp zrYsNktq~bQv7XS7b&E_n|QRNnU6v4r? znIO=}Md_f`V=38PXj68(TWxRf%lH581UBb|t){M%AA zQk^xm&pM`}0$#%0c@bh7CDo{8%#=M%;qsWS8sJXT598(KPC?JHp505`!xA3l+8Iez zqfz9suI?tM!lcRdP-M>BhKZhhd9Fl~)grvGt1?I==sj9#q{B0%HW`p6%)dO$NhQoJ z`zp_-!0W#_;RB{&p1O=IF(x>zu~=5y{sFV}uuSYm4v@wTzB!eJeLZ-m^au~!*cbiQ z9`ZzUvUm}!2oV1Tuf%7H=Ivywcthpxb zdy>AeWWx$qX2Z$54IjO0Er1K3mG(N0X}O{Nl)UQq6zW2Ljsa-UVs;_u+O56;THp`K zHdgv$%x`t(y53~jB~RX+X@gNr9l>2V)HLzt1r{33##on}Z)3Tc zU%)#n$QEn69U>s3v9frI9JDeE!GnlEu`-=b{2D10Deh%qR#~LG_g7%ptMPBRW%(D& z=#%a-$CX83YGY6j{2@Nu_?b3&j?5_90weAqvz{OCR!LaZg-B4N^L?YVf@>Tx3^on5 z+`lzM5PYD}N4`pVzk^9q8f4FFswVY-$a$vUk|p%&vd)E4HJ#}0gMt}Hjk=y(lOjd9otq><@SUdt&)#NHo*(_@; z&<)fy@1k=p%1H3FLPOA}jrtpIxCc)aT&%g>pDib+AI%=$4{pv|m(+&euQe~D(y7Rp4! zsqsSh+n020ry=JXBkZIJm9&G=-tgOL$~^hi&h=NbN=wQrb*%&@g5}NMM5{MC93#S` zkN61+>U=j2mFMwdMgrL}-1!c)m7_URMmW$AwT`Tf6ZjM#>`*XO5~!E$RF~pE%~h07 z9@G_@V3)vmP`t}KhKAtt1z#3J&yN-}Xkh1y(0Eq^PUvTtDt~jJdKk(E3Hc)<{${5{ zvOG?t6g4vmcL;3rXe)5Fb9IVmOZD@+>_QzOwSHX-&y6Q4-rG4+$$ZO-Lh_ z*Bh1%(Qa+6GXcS(yccTW=9ZKILSgzkU2Yv$4+<fs zJS2b#oP?I#BM~htIACga@|bLX83F6L{BIs<24Hm46U@hF-XVc8UhoVn9grEVnqkWm zZ1mjk+)|dv(4)S_%o?hexhZBQq=O@ms^$v9F(Dd_QU&~yIp4!Vl_GM!L8y}jde;`4 z-5@|E>0^I~N@-t7`DR(+yJ=*Oj7#JfUzy`2b|d88k>Dr-te)x!1+JbRq7FJb(U(3! z=A*MPVDtG93^+?dbI~-Q&@=?NKiab|bz{O`W72L21>ep*iG2sqy;&1T1h}l$+gRkQ z`c~p~fS8uRv!^_ek^Eo9d)cSm9qfiOq0D%dHrR}9y9T+qxYkS(1K2FxmJpZfd0Z&0 zFvX6`O?;>+2+nE6v(0=i(Tf+gIM5AFLdb&Z!j5NKVKx$Q{KsDAFdV6qZ~xe@zEN3~ zZk(WeZ}TM$A+3jx!Pnn3m;VOBSx0`RW}`p%FZIbe?{4z7`KsSHl`PLo0onTkgUI+Fv(fJC_UtC zxWuIN=TH}nD}trA@OQ%Rw=7C>xFcr#Ti1qX6Sg|UKione#=`~FI~af0fs_Nf<>XVS zhBWRM~;K}!j zSIEV&r?4MUL1i$C6t%g>_{&-{EPpqLe+!j=Dyy{m-tH_W3^SvQ(|)burzk%z6Ywr` zK&q?}`f4&MjKkUV~;|4J|Nj(jocEAR$v*t2$vN|#HmhJ$=NnKhp(iu=9+mTH*?L7DtX92ikxW=6 z37QshY8tBg5ask}mc6=m6RX{T>js=T{Mt4?fa)xE0A-G~Gjd%&aCp7<&g3kLY zP_tB{uP^(LN@-3e4vj@J(qk43h<=a{%K`o{+g9yF9}J1hOh9q5c_QBiClG`Sm3Dcr zTKYK9+aF?CKhcpso9|aZHpeL5ukW_h#-0uX+MX6sClR!RZ**$-Wb2h9z4UWKHzyZn zfT{ru(Zw^-IO2WK&`x-5_A!pEH+XGmPL*JhU25zuQ>Y8=tmC0vTLsiXdc$gK-$=uQ zQdn$xxGn+N_&*N@$h->E?xYp^a>@)&(??jor^|;k8tOjE&KU-M+hM=45%8NYpPF2| zL3cWR$*3Y`7hl^CbiPMl|G(N@UQl+f$M^srSvR9L;Ktx4M~{WAOO3fLRC z+i0s&Gv~OGo42rw*sRc(*$m2;7JlO6^Hg@>|c6r<7r~MDO1r-*dL{mpBRNkVi7c75A zfxMX{L|l8Ir$mxbygL5++{ujNEvyg41h@Z>2L1@#&;?sP<7VqF2*|D>uWl2l0<$;igal1)`> zt)Cl@p1r&Q60TvT=_dcKwx1UR?$2vIysW>vidRS&N6iN7*YcB@k%n?6&&(I@dgkZw zjRyqEu?jn)Fx3||0wc`MlRex>yFwNbs=Ei1SUryV{YX&**59eh zTNDyk=+_vVgKpT$E(?^xcX)ZpIaA|y-YWaqLYtWZN`;@!_rZcel8lT73L#l;dwl z&#??2!df693}bIMF|yWdH_vgYLxbU0eS5&rxlv7y+Pb?Ik2rZeYL+mSMF1{A_`26q zmd(PDzHzql45bDbZ1NuT25(9Kei%YNJyzO4%sP)hleWpRTBL#JC`>ZK)GnWimN*=~2vOytT{2 z9mb>5&qwlkk{c-p{-Tb9nwz^qtnkNT!#?)6u}LRik@^snyj2n9G?p{4JOm^EqkBqg z%r&fFQ};#9M{%l`9>PN&v~%mLRzvE=U;K+P5}jMYY5g=UD%L(pC`6_q+|3vw;NoEZ z_*P+R4erlS@T^A%xlWk-6Zf#xxG zq~Nx5S#@i4mD5t$xXBUCM^T;#+Gc~XU!$4bQ4%R8Tb9sHk3L6oHrk(S4nc@+Yp8vw zt`f~dZ4&x;UvEy7=NIc1E80JHf;HKawY1PZiDJmJxASe@h1t#kd75N64u%Vu=CIvd0_iv65hCGhOTf9F zMItnOUeR>oYnS|HHNUCMCr<*%6{85|R($4zsK^iT{}SP1NE`RUeI0n!(KMDgNutbr zeZO7%UA$$c-}3luE+nUh_mH-qPHt)ALQan`f2wg6ZRy6u5z7Gh)? z4pY{{$8AZ$0Yvu1G>Gi%x;h5H`v}9mVefqa2y{m&YxheHn;tsa3;lp(Q|%HL5Z*(m zxiBuAm)=`w8G@s|sX%OnBiA2{ciY9|fj-HFXCnq2q`9;CT612D9N--8G~@5*E=)y> z6a8uDvgdn@#bG16KJixn|4nX_GvmbR`wPd7=qv%p%eUDgq$q+|25;Y1n0x9~&;iKf%y@oUIv)8u{L-g*{ z_mSVYh+qyW%j=kZ^%CP7sHc~O`q6YN!ZwZ0q*I_&UD33v6Ncs6eVPOY3+Ze{;vryd zr4F6V3Cz1*L&r8CDkI9tl_YnFwiQ(&t)e_pLAE+lLDQtH=WIp}2Jm_6LM(#!$+*;c zy|EV#qUr(=;HjeqLhiusOVILFT*&)QR3M5~4!=hFvVg=YO2eVyVXdtAa_Yy7qDsl2 zJwIltF$@u;-r3G9Kcl#^8cfXJ0F&K9P@JcQqr5GUAi_aGKv zj^iOue#2MY*m_|t1kn7BrnR6G_Pe<=`ZH2?<6Uv;raNna(9kc7G^e}Gy%P-wY-+v`l3 zi5I1;ZposO;b(EQ$~Gnut;^4>I^weC7r^Ly&rn{V-^l=jJk}&lg(_J0mXnT%r*yw8_qT}#rI4!| zn19O1+(Gr6iG;yBM_8n-#A%=fB|Yr!*Mcwau?7@}MXX`!0!fCymij~pl0+N21Mlu< zc!A?RdW_Q^(*MX4zFI%nPc zG$i-Et|2LrGiykc%`p)E3j7re5V>Rmk6{~d0&H0SaG#i9)EI{JTzxNojV3KP;7pc2 zP81sWYpwtyaoa6|AX1#4(GZ}f>MaPZIAIk0QHzelSV>KTYhj;g0$e;W`pKm9#bq)d zCU~D3kAIT^KjXP;rztI$eVwQJF*4JUF+8<%p;?No7Xkmbx@GC9+bR^Qd}R@E?xgxv zASeJBRYpKvA@UiW^VZ?gkG2KZ7^g-P1>=&kt4k`eOxi~pat|E@1o#_0|CpKc47Y>D z@Id&lqmiF8g~DTGZ|G-B9TSR7P2JpvU@N#D#-P&Fn+&b$l}`F_>Ou2Q0G9Z$voAEj zBlmaN6UNcLn$Ob*WdXaQf2#X{+J0CJQp8;2J{IZ?oUDGngu!>y(JXBQMN(rhn#(sJ z0!0?fOzQ?dDyhf_Lo8g&xvN1>s#&p3GfYSoWQX55r$dB22x?yW`I{U+7~MUe#Q+P= z-*0ZPRM^Q4MN)GfL>KtR_xc9=zt}SFJ!Py28zYkPBo|DjtpZxKs?ymk`j^}^+-H)R zfl7Xp!o!>b05jp9qK0`~NOOyaPR{@i-i_1v@gvrg($w41EBi$~1BagwgLJG#cjiY8 zD4sHf`%HCZCwF|eEc$Fa4#aQwdmj@mn>v0)-$0$JbgRTX*eXD}+eVpP4 zAh6ANCzuz#0TMBz!XeYdrC-_=A5{_5_CU-&k)r#PN_pZq%eG1=k#>wiHE@Wp>Ou+7-JJ*9Wmwk*^`edb4s zZX`$kJXH!r&9a~j{tM$gzQ}RrK3s>Gt=#9J*#Wgohb7QMwrp|4|NA@9Hb2KrBJO)_RC$r47;Y1%BR-Ix#*GqBZ+Z(+ifb7 zKo;kA(wQguDM>uO$&=SjlA@0+ah{E6)0mWz?AtVlmGOTy1TYlw+4g?WZF9;|=aOv# zmQaTn)hCU0C{&@T-`T#DRd6#ul=DS&9{)<#?mK!RmmUDio5hV`!;pCrez6G=9mePn zqpN_>j+eBP)gfyJALKfeY*#@-@>9M9`55R6Hem@S&qeax z^>Wn65?6e!*y2j$Tn3)fLH8zUh|M9>5sNB8qHI|j|Mx=lmQWX|ba4YJo>l2)jI4$@ zNXU%5Koz=<5kPNILF!KESHpRokOA<@J(0yI^OS2p4Q~|~;BdnkT%5|Fbz*Bp zud6$aeS*N3e;c?EBgl{%z!__A$!FkXAb~s5bQ&>}=VULtDCo7(2qWYf<(ifyndb;l^RMY|06~N;#97mv8{& z2Ok4I&k_A=Qem4m=H&+xJS`siMnVz#xVxXtAgT>^&-i#@6x%K>}BGjx*>s6Uo%`BADv#D?6*hec^V(Y^WgBFX42n1YXJ z{#G?>bqOE}{^1HwFp|)c+N-XAdKKdG+PgkYTr`gHnuQ3SMDP+-g*7bJ z<>CZ}&>s#@iYX7Bc_3b`(t%@D1Qjf_4LG(qOb9mc!+*UXPk$JmG=hZsL3@~VP^;sV zpTw6`^w2wOjmWgRK4bipBYk|xA2&5Wsq0kANiPM!IJM^_(=DJ?SJ}3MxbB)|!zFBo zNHTkI*HsGGZi)4mKkYN{o_iQ&>(UxNjkLx7`{546!@=FbXv}6cu&LK!9?nU8cu2C5 zf0VJvqYK;9*A*k9Ozzftq zS%E~QPs&g`{UZvYZD=SXvtCOWykjt^784c;T=bIf9~Z46<59@on!BnlnWw{cIlCu| zKK;kg0;pN@NFWUhWFjG4+0q$ASrs($|4W!yZKnHHugD#!Y}k?YEN>d7NWp%7)l>*y zweD%0uAPIvVsSml`uNYm;*E+Vr@(E%UfJ^v7ps5y>@SJJ%A^u3);z(R-0WjY>;#vz z)=)P3MnSQUy*o2Fe5v`k&jKmZel%^hOxL^o zE!l)Y4q!}D!vh~=XL2mlKw!_jsiyx)bfJjBJoD)^ZREZwf!C)EgiQ90-6kr%>WHJ4 zlNZV~ME^L`BhHHW{xPO#T%x*&JMpS{n%qy>e+Uo+yz(6T%gIN_$%^`CK8eg{lkK?}68NRF8#zT)R&{z@e9_ z?+tS2f8{rYH}6Mo9*HvJSu76++#5xls*x!Op@9aZl-o3N`?7Ld;(m=#kfgGaD2@i$ zC$)Xysw)q`hRHc8!#2Xjrw_+>%U7$#8rv!(=vcZMIyC;o%WX+{!JwTC{YebYwh2RK zny~bIAH&IK@@-dqVaDiJsMXIixMMo0`3RaLLWi$~W3)-kZ>sO-*R)k3egFT3kDCIh zsfP8qLXoVh_g`q1F6}!Q@o&x7QY}vfz-(An-O}JAhk{dQsfpMeBuNrm^wEXp3}X-# zL(TBXqZf_bRaSBTKugF$kYlc1A84&u*mKeMhi%te+htdffS&Md`C?sfpsXL|_y{e9 z#kUw5b^(NVgr>J!mUweV8~#5cBbpSTy#;>T$}9BQR5D4;d2}`GHYuLqKK17H2jbObfGCCwQ=#6uvfla&yW5$tqz@wNsNWr{U{_Pe zplznDnuHMScdvQD(9Y+;85&9j*_G@REBqtYueNJf8CZmYH4WR3?!lNa9vc=gV71G5 zr?EueNvTs~z3?+g(0Hz}_807~B+#oYxU$+IarIDnjQw+9I|J`Bx%|S1I-V+$Eqv*n z35;fjqf7Ls_?yuQ60@fF=^iGm5ybLJVty!ln;UCm#uL9)Obj1} z=gnr5-w+h{6z5%*1a=on%gcA|XlV9=a;dqegOXB$)*LK2DcW1HCvq0J z{KYYF=^+$MbWc)o)TA>TbwL0TuqzL`Pr6fcN$etSCH4N(LAiPpd>1Jgu>5s?FLD3` z+JCX!i$){=W9enMm>h-D>I~^yai1^`)*0wR5*3QdUeVUL3I6RdoVV;R>AzU zcpv>8vjsjLQ^~a7%6e$RN38ORD&V0Uvl>n$0tK9&Mfs&A3EzbEz_44!s+-Gqbi$RZ z_(ph*s9QnBoMaY8BcD&S(8ldIY|(5B_UWbwfMY0ADWu`Y63Ho#pJ5(cpb)z2Kg#dP z{79V3@h-$3^2mzE$c((0)lB{U_Ts*aXeM4vOG4&WGb=`UI8wRbP3e-s&{*Vl^Q__p zG&1_<4LC1=$gmU*GUPUQe)Mts5m537>iW;JGs7*(ffaHI{HxR&1AhV~5@l#!_r6f! z^xQEj`CXpiv(d3t!fOZV7wuyNRsN7cW@KUQoGa%-(KjDKOes>@floRD})XGYfUoRld0P#20?gwc=_GSnpgCCXHRgp_3vEk z)oWj-nF}@<%@G->8J;!K5f?M?`_ud;T&#pASr^#qY{H4bZf>Z)*e7Om+++@_NZ3kk z4wi8n2z&T*=-k6tXt(M7?sQMZGceE@V$n@)`2JoN$Ew++`!-*ec8~|y(zlVASi1JV z4M|g{F)*hhH}VgbFT9}Df*4nf!@D+{Lk(zzVtzB$QgeF_PFmOu>#=9be7lXOF-orr z^*3c{tlu2x+D|{|Vu#e&s}bzQ72>wPDBp@xd=f?g{526_!(pVxka`4RzejO#t zs&ersN~KL+2~HKwj_ZcggY5<5LpCw4&%4o_rPfbX5k;E1E^7v7QrG5C+SI6&5sc|O zL(d$T{h^)P(R#-uPAd2=I-?~h@>%aK^gDqd)gQmjc_+X6a83%N}hmS;gKm2t>&gTs#5>i`>gIJlU9J2jhO-U4}|^JzPZ2afzL7mCCFVeX3iQI9wsBLe3+%W z3GD*_sz28=xah#zK?!C#;DK|_IM)mvjYSUd7KDD&A(xlt)AcB7O(Z5{ykhZ$%0@FI z4R*(K4beByq~GfJhKA7%G+vVbQu1upt6k=t*}kRn@;>~xmW>1hQanhb4sPt5BYX+M z)u`c?3Q0xEDFPEief;Mz8^aXK?SzECB|6&fev;$z4F2pDGO6*6z7G{iExY-Pj5DF; zuW}G8ldyxzQo^%=Jp1zeSTc4cz?FCmr*rt%8 zcBI|IZTZFoasHIheI7l4ly)IVv|gI=&z}=jeTNSTham3Y^wn}*>bBH$6W8MGf9DXz zeF@RZF}>58-ME}=H!%<5o+^v_hc^DnjS7~xz=6bSFF!-KA-TdX?i_HXTFa%vL(JRY|Rz5B&W?OJlnZxStt8kNc8(nrzl?STYmS2zO)R@qC=M-7J=30>pm@XS34 zIh z_yPHf7Ncgm!SfVPlVWwPV3U0JxSwj?hz|UF8fPG z;pO@LnQtcv&L<9G6 zg?RYw<~Y&Xm|)U;`n+4+sToXEcVMIiq^U;}saK;}MSsMwD+S$C4DZ=6E%v$Vq;&dA z(l9rM#Rs49qUin5yp?WyPxB0iWAPqn5SJ>w!~|4{;2}vK!##K+mWXLu?mqw1V|=ux z1)agQ31|Tnacg^kxBB4Y_8|z8F|9jvcUvc^jjTZ0-%39%&wB^(1_-_F&`z2?@^q35 zen%qJ`D?FsBR2&0cx=q4IB;AJD_g-)X8A~M;{g-!WZ7ZMjTQeu87pdK^HtuEXax#P zP++oTRL#*pY22>vH^gYNL>`D~A>4ayqu2>jYj>W>`#+Gy3b79DWc|2jO7|^TY80TB zt&sidUO5Sw*UF(gCcDm(o8Eh_7|Uk$fld9;AO+On81a}v_m(l&4QWMzjCZcbm@Kyp zSi$h3J4bS-*k7J^?JT9$v+>rHSK_vSnty9@;x%~5-Clta_)Rr23~p7pQYGJ(91b?F zN+9=N3h5GK17q}=Lblf5SV0ZjZRqF%-h<^L6*#+FkoQ(Mh~jy^me7j{f?k;ie{do< zQbLn-e6Pb{Ctl0Wn?_C6_Gp2ibb2kuL3{CG*!o?9_tMEU@!ea$&iA1^y)tn;6`CfF zC>GK&k;wszb?3V`U|}tTbOnR6g^G)tjE3CB&T}w8dG{Tz47y17%6o%&&{aM4HnF2h z<>qC5&)wWJSqvk5;2%cV4V^f@x*&RiX^|6g+N^H2OwhT&_h;%d6 zpidK44SWJ|0NJ7LZ$xbHtHIY|L`+2jz_;75j6ZQj(tB?%AVxM7Q}s4sWAIi1{C)?JC# z0+2x}o-hfg1fIQ(ARmXf5bcMf@!#2uUCU5L?qIAYKAx8)4K{v11CO%ZyakCVBk6G3 z(5% zt6by}JXUu^rTHMJ`}$I4=!lNwC` z@@xQB=YW$V<<9i)X25^XVpWg-BW-RCtG~7mUt;OUh!f4Km&=}+BsB@qHw2v&}3U6*Y zi&6K8s&h5uM=H(^2y1)?soR8Zk`RJXqtAD#ceRD?V)3(~E05 z{Ae`xq!QEJkc3#-@7+ToPBG@@;34KyO%pH+gqEyu#?NJJIr>M(mE0*VvWx@G! z_K={<(Ic6k{h*qR*u1&SFytK_xEd2brQ2R=xZrsM6PjSu>ps9?@^zPe7ekKU?gLeL|7N!R??58mnQM$ae6_76JpSoEHy^cD%w@?xjzWH(2~q-(W#)vUU>E^6HUdT$NK{J-kF zj$MLXaU%d)G`=3pUkM|566ll?Yn!xspxbYHPRf7YPX5n=+gkIFaOWlJy0XIhK-I&? zA%o1?`(n+f_xHWL{0|<=JLnOXXkZFG&RP-wOoJA9`ZhCxsX@jhpwFn%# zj29`%!ja_NYI_mf%3mY-vC(q#ttz%Po8%D>pw7M-n;@9S7@a;KLm9;Do=FHyIiWom zM>!w;3abJa;Z zg3K*K^<>D~Heqts4WwYTf$!jG@gC}?y0)SumoY3s&YzO!0k+498T{w0R3EvIb!OGC zF2na`fd%}P9*ILH{~ivU)ZxL-tBp5(%N6VPifpx9I3JdUv5f{QBlOt{BcV>9(hw~p z|Js2r_GL&qhFNjs>f2tOcnb8#H1i1jNa)p;+IyRm@W9#D+{B(VWO zk+}ksUQo(ouD6e9?9s*O~ zJ+L|5x=p8IXy67nQeghVo7U;KY!XvP&I8DI=g(5s}Oqc}Wk zoPdQ$I^BOx-|x2h3xS>+!dfY@M+&3k$zL1R5E|fWdd`qG1`g|`5E_G@l9#YvVn=RT zHI@LX7A2gf+|J$*G!K@64KX9u@S znS8Ck6X&L)bKRt%D@x>k5Z>NP&$6%Q3s9ZMK|xmBQXCoy1&~d*`I)-4L9DeO?sB_b z^)nrN3(#D;qvlcUcEk6h{SI{*dJ~o0SK{fYj>cJFCZF9o-DDz;BazKAmmvuGti^B0 z5cO+0JFKvYM&oFKSDvgZecYj-oVSY~ggH7IURKYVF8#+eDx&)&qF%bJ3}N*9)@v^= zw#HP5*a-Ospk&ES;Z^^NTMyVRW6sIGqpLqWY*rVG)xr^3V-(ETNP0842rb}l*7#A6 z8{pG$&G|4HX@&irz_~BGu4x5S8g{&=Tl=Px5IHo^FiN@&%eeb?0C6oi74=ar(2Uw{ z&x<8NY8P1KdvdZ%rHqh1x2vOxDexTpQb|J#e6Ny~GkBr3ybj3Ov63igCY3j%Lta3( zG!yg(k8f>PFyPC31Eh%XBu zV3fHq%X4uvb%`(6XmhR2QZKy2ll&?kOoq+i0F=Q`Xv=HM%?Q1Duu z;-kaI0EO0WetV~CE#?7wm(8wda(7=JS{N@`obo|Z>ua|^`x=Yv3_0T#4zcsWrK`7+ zk{;uaCGq23$-5-b!j6bq6riJSl5*gDVuMqk+Q#b zFtK!aJ2<9Hss@}ed9Ne=Be^5#GN{BncZ$^;bO>n9S;1UiGy@mf=nMg(RILq8VFjAa zeNlcL(6~PB<>PsnjpN>qZ}-_+VO0sYNFzZT4BXJfJxGD00ZDdp?Gu+fWkxuR-5_!L zJkWz6@t_7TsHbG6eL~KkDn3EB_;d^coM0#|jCDf#4e#$|Y zIkVs$qP>p`{CRvcf&;5P1?(Vr#3ZvTf8wPsc(2VA#h>LA9Uezkj7Ewh+sx5h>*= z7+Ek8=KA+M2M%LHj_{1zPbHpn1U7eHP_^aqlNwD{=i0GG6>bu8bHH=wQyyq zhJ}7tij9J0klXP&mmt|}Q!7a;9N!syaJyK7>$X{mwbI8jy0_83NT5f8I6YB*9vVKJ z)QLpdRV~#@4jGYG!O96s5?UASD-R{QD8kmMj<dr4xN86lRE}?LMIh=Qx zSKa=t6$B>NvnDNt3+^4)@5)gx#3#j*tW%HQ)}jUo#a85Lc_(TTgPt7Js!sY((Yt<_!Y_90Poyp>N#~GvvM7dKXq+D9NPsU}`jxSlQbY2gwmL}r z>MCw2bBPvwYA)iov}UCMiMP1eRujDKDK*BY3AZwp%K@4PI#!+T8uhL#?tS4E(2A-J zi8IUGs*GWhex&x142;L!1rZ?{;Z4>CF+O5|M0cf%RSt;5x(OF|*$n7&Po$=lcN6rT|O9yeS}<7Tn6( z?q|}=28~cvb}0m6^1X0O!7Vo`OTf&f*s^CyGxf8#9vCq#w1rxsvlb0SSc_u>n$cF$ zm2q0!vti}Sp|%`X^e&eLsW!aI+vttM4Nfc&PcZv!_IpOScBc5yr6fHGTqcE?8Z&bQ z688eoX{>5ms(+k(3K3zfY9rK$rQIDQVL_Fu9fNE8m-BrLAn*F){yv5&_;)bB24sU* zx1!x7Z}?CR^Ho49kxXSY5$I&|n}a3y?dj$!Gb4y{RXFzh1a?|}6on*GhgJ?C5q3(R za;ZgPdmhoa`|dh1D*2M4hQRdgvYK98Kl2-K3l_^qG;E!hpSD@OMk?q5=D8 zz2xwP%9CgdL=jSae~}Ao30Zb? z&Z=r`t~AS?PhtS- z#`?0p)aJ;Dru4tiXh6Svl(d3so>Ip__c-qIVpv%*Y^Z4oCkHMwiq%5!<*JvyX}tGR z&bWjqV3(<#Dx(@&fo)7*do`d#yk3L0l@VCja38c7A{r7413wPCJW_OA0(chgAYK73 z*&)4tnh6OFg9E7E&O8h70QR{b6fhH0U87U~0Py?KWu5*_9_ZH}kmSE!$ia|dk@^nM zz}n9lR=_#LyZj>As|%P3kX|)LmhM$=iScLX_lfKqe><^A1LbUD2v#bc z&Xqn^%slg%e-prYzc-IXLZp$C12OpvWIZg|ba9|bgKF{@?Z?0y%|lV+ z+$%CeQGF;HBs%!!(`>U)Cs%uk3Wz@|=Ad#Nk)s{o41T`?@!v9RAF+pva}lz*YyK4F zbdN@Q_z5t3jV%H!AZD#spZRB}aEvC~`gh)X3~=e9eZyi6uOXijtqNlLn4;6;Hs}|a zYyx<$1J2h~_8n>Qwk^L~)7X+629f~`*(3@zAQ(H?^OZe7QwhIo<`yL&?-J^unVflI zgG{H|hNx)XTkEOXb@d$ntt-_mJp$?On=F>Q_s>6oVjQP*m091 z)b458X?jZ~`j~)ReO!l1;@587`93=!SQ-U$eN%*tcu(!+Y<1X|X<*i4C2n-sbV4(y zGgg(OL9Y2ztQsKtd-VcVpa-M2MQU$hlaV*(1rl8<7$UV#s$<@`t{NLgIGicFN_ufX z*>g6zO_>QoPiZ>{;6=7Uiv0({*vG1Iy;w&#NGHldK!F$|6w!KWV?CAk5Q{vrU-cOD zg^YVv8-+|fYDTrV8&})fo(aS)bS8T8{0VLv!^D-xu7BF)$Hy&Gs+LoH{|C8$)LQ^* zK<2Mv_UM&)g+J?OrvR2pTW5njpb?`x!)e1`yY=WP393Yk=5iK7jzsPieGC)P zcWXp;rd5%RB96@jYL&1+x@Xvqh5|cT-BE`CM&bIr2ive~1L;eBI^qMQ!{;hnoW{_#yEisK z2G^xhrL(J-Tlevmk-4x}?3MKkkAR$|%r)K&RlLIa0TtLcI&S&{T4a|5jkaG+wxhRF zS8v9~ut7bO8l$nWSGr9SHJ=;m^*F-aU@jpmI6Q#tcNbtW9wLb$97h{ve|8ORbBjoZ_yY@om#8F3`Au*y`K9G~L(+bWecLXL5k`4(h#L(O0C1f0${e1x z82SQS?M7Q!_jvR=0s?FFXRn6Blv;AjONyz0i3oc(@4pokg{}HP7ssEL%mSiV!BOuH zcOJX@_>pl68q3QfdoW$O7;fGkk4BWFzB!pX)aeBt>FLvHp*5&R)0QnDfli$9g?d|6 z(SDiFn4<;$6@&vUPK_L?Z97^PUhfrG?^N^T`81p~@ByXS}we1eH$#*~?E9?^H7@VDerYUc*guN?Z z-E}Z`JHi=$K;rF6^0Y|M>1~GBA&jr?$Ayoq_=7*@hH@L*sX{|J{(^1aK#D#aEjms# z!#=r76mH><$bK00W!*Y@0q^w6x1_0tvU@Gm7J~$M@8Hl7TdXqj90!rP#HL=wA)G-2 ze)X8ZB5{}(GL4{<0EtWJQ!5st7YaHM)elpp`=#Hsi$yjItZA*?DL#Wp!x|1Z0ZXxa1>x%7QgH~1~e??B5bofwaOd- z_2R!fS1;IjAC>2>BPGST*1b+hQK?UkQ!xC|De?K#>`_YU6JqwUg$qsZuWD*1J%`c< zzsE6d1W`Ig+b`|R_8=n;LKqldkG|AaoYF^2jN02^1vP7IS_-l%H0yokzis53p%`JB z(`irZTAOYx{?qVb>yr-m69{`9Wvz!fawSiNG=8#WNaE#tkVGS?Qe^I23IVZWLZgPB{dloHo08g5g1{9o*q` z^C9TBER7N2?ZtB`##2x|39jg~1kS)o&k(w8JleYdHVhRQuYs8$mD=^M=N>?>p;g<+ zV;ik3D^u&?6_w}S4SW(U92moTu5=BXZ70D!VthL74PV`%??oM=7cdR`c~hDFf-LgmAvySLk4IwxZwq2XWVI;}PwjCh+L3n}Vy*z_06B%_-5? z1M&(OFG$O3geP69;1&&DwhtI!1S=!!<)#bbe9ASaX{m;{xNCO1foeIQZ~agj&o~Fq z@kDnu6X0AmHQnF$Hil8zPQze^8H0M1Ws_okoc4q5zF^u<2o0;sN*KI-th}#I-iqgi z+iPO)R-M=26mm4LgvIN^9dzdT2v5naejFxO8kvT5*Z+5=Pr1WU*n!OmeT($dV0pPN zLOF-H659M|Di~M#S_|%r`S=_O<%9}6tbYimLqvtC%=Hm`Ke1+1O;1HZXyz9|o)p zadA73!0vXL8H8=%=f`?tYZjSgnfQutv$b|HK`b)HQ7%bAsCglIZcY3G&-%JatHVkd z?@bUq|FeWHwpFoytDz!RyBU=94^&-d)nBTHP&PXJesdzE+%U-@5mOyau755cC+}28 zyrUQ5-^r#=bc=dfY|%=G`V=I!eB938;^JhO7NS?b7(S!@C#Rk>wtS0DD%vIbK8aNx zYS-dRZ`u{`FKI&3O`zFUHR-OX9EYW|-{hB;m2D2MalSx&G9uk2&#HU>l~4usE4Le7 z?&VHd%-E&LHh$_l%Z;`Ym*UMJ1HHx20w}=S^&zMVQl$p{Kav-w-d{g1QZkpAF;jNj zU&#@(`eN*~;8sTHJYB$1UhtH29;@>`R^fe&Qj@&lSA7GkaShXPryT*5zx+Tdt1A?Kljt20>Fu!`&ytm%FA5+hHLTIp!T$lH6+iwNz7LgrRjguI)5U zDm*q#a1r)*!f6~1_=FV^R(pmI0MRU;JBB|rLce{jt;8~^7~H%)Vk;M>; zMe8y~wpp6S_)iZp<&XwUGuUn2%6<}{^Zc;ZS;UAci3Cc-s$K?_&5>s?g!%5p9%S)^ z8?k<=+_Wt(()~am#r#=$Xd{QK1Diau{->kv0J2a|%x*L z6cDi~8SUO_sP@Eqz)SS^*;Gj}tl~%F;{1gC7c8c7CNSS~xy2UHCpRU->T#XoHcA?u zSu)Hd{UpMk`=ts#K%Idn2}MOqVIp+;2lJBDR9h2_gy!&}4~9->zIePI1KGXua&&g? z#=8vrA`LRsn(6cHYRp?=r%9V&Zw;S1$CDQrGt#jdPeM{wn7Ek?e zKj;&uk86zM#p??seui}Eq^1K3;AZ@T9!8G zcl|M_TFj)*;LFIGFXdGn^ABNYswc&KC}OKG8<7%R7t4M4okT}7$czJC4HTbqVglA< zN8Un;oMFbfbXSJ$>z6_;^AdDn%NI=r8h#%>s|XGx%aMBfLV?4%6n*RC?fcIs1o!l=ln3+1lp32TbPUT)e$I0LUN` zn0&UC>eIg3o7GIm&C;=PHejl+aq;kM@D9K1#IVf&U^XcT;4h7NWS$zl{1G2RfsJ_+ zH52-L|83<4QwXy=PPK&4nFIrhc|8GJO$c;5>)F6QUSm8r45WHYx#NLm{``y6oMK0= z#79st3i${9l5r9Lm#`uBvpNEPXT!*BggivMMZ~sUqTX3cr0Bbcxo!>n*)Ven1;YV=oEPkE)3T*At4cxM5?l+` zn}NWnP$LW9gu98I3{dRM1FF9fCTQ49=#~K0Y+KYt@@L3*bR6Xj9fGEE_HEFNJ_VkG zo%;!ic$o9R48iR_Cam6SJ3p4Vclrwi2!G-nBIHR@ZoVO6^^yP$etSE*V)j4Aj9+O+ z0ya&&yquMUo^JxZlW>_AKy{;r<}ym{(G58fafUmkhQ7gyeO zo(4VVVCsTF$5>-pD>qaH#gCZQu)20B5A;692AtZ1+MZL;t&M9C_ zV)3WiFj%G?5R=11CjI9goyIv;P5mAlx@vB^HZ3$RQIf9+iIOqp2L5N46{;Vq#7>dH zH=z{q@UAfq%JrX2wNY;Ppn@C-B%csA^gN|ltWT%+r&m4@mokCwzCMpnJsUScbwZgy zCAX>U^KbVmk~hADOu*^*$M}nj1)Ok*kNY&PeE<*)cwdT;_sE{UeHkXVVaReLh?jJ) zmsPo_tM^u*km4v(t$njhFvLCOhIV|6Dlb9#nU#O-weC5Lld{2~p>9Cv2QLuoI8pou zMQHtbbeug@IiekPE?}ULm0_xvZmd0F-^-;xm?>vptdVK%o4~Pym{5`iC{Ik8+S5|A zKmoJlS7T}-E1CJ96l^~9NI_Se3FzfCk8x%_-E~HOaJcz(FG`PVKU|OzZHaM!WpRYJ z29J|?qaupybhsQ82nO|&QmG)fbYDh`A07S`nL^ff10<8Ae*b88`p1M>*{(qHT^Ef@ zs!G+9{!Ux@w~h+t&hV*eSi<3AXdy3;z@8Pl+Hoe;h~5MA|a7~Z#tmzS?T zB_PdTy>~78rxvY{89P|M^2~@SoaKpLCNsmhVb)tu?XToF5Jr^^zDD%{NgO;isR(G4 zu=4*WSHOmGz9~CZzrffeNH*IIJV~#1IWwQsdS!r*a?2Cdmih-?QBAv*YZ;!5qnHw9 z-Bl&UH(Pvy$5N}JA}UaY16D=srm+~Zg%$g^^A!%xp{?O;R$@J_seL~xy)t6iO##^D zSwktr6A5NH21u{w#kZScZ~tC9z>>spaH%5@@@}XnHY(zRQ7UJDJX3B5=*qqaapR#& zX^A=~a^%))5=WsbbZj$gbm4J(fC>iM7$QY&4bb|LViwt!HCUn6QQg1snWQQ|YlWY9 z?7cBpAThKb)i)>Jl2@BN(!_2x>bV?OY5P3BUNCL@S2_SLjdsY2T_pJ7?eoE?v>JD7 zBMuWH`A5x38XWH208iP%%mPL0hfV5N9t}90cta+pixL)3P zYqQ2c(2RcT$SHA=yMNr<1>Xxbx|%99gPY59#M(`l9KATHxIMxGD7Q*+-*YMpkFqJ2 z*LYuE6s=t|{vLON*sYrsnqG{a22rV$(YA)o>wbu%v4@F&Os%aT{ej$RI+OJgc{%lO zfLQ$G^WI^9+E%(+YywPEUT0nkit`&>e3W)ll3RFLqVwf}`QC`NY|NHwBuTYcJFL7* zAD&VlR;edl>kL3Ge#}--QEL1NKD)k9BjuF9 zr^#%cq!+~B;ZI+CWDgFo*bjsvj5#r6!*J(J4UHV(%vO{}&>&E0 z3qi=5&(=LOh1I}kij%;X^Mqi9C=J3L3b8l>T1Mq&JPyZrD*m!ohT^VQqo(MzgF>FE z)zkf<5-4A$v=n=hlm%jgTjrLDOfE<5u$sLsC1*XPbPTYKC2^#9LwQ`2{KI-4&tA{% zo)$WFD#y&@cTvcCf2DNu-IDD6IB#t1hw0Zj^(8a4X?h2kLMv8g z?kID6Cf6U}3$Z>vhW@|?G+me|cgYZcAARO%QM8fS^-pl?PC~0@fPQ8@tQ&7zP`O4R zb|~5&ZI;Q0!Gh_w2J||mw{ulXRz0kFMpw*I6xdSrj zE#U0g%j8S}wt-FgOC0>ltpuiGqk3iFiKf>qmwv7WV(g43Ed!}Ae!PG86#hJD!+6J? z7d%g}&0Tp7jJ3x6wGkBW;n7Ib3qU(FKB18DU`oBz=XF*;Oa+@nG^a)so({?NyWDP~ zpXRIwvp2@bq2iC8$fOkCtL-og5bHZojN^2pv4qTQzuSao;^}`O>yTgC;@(EU%fi9O zoqdv5<wP~W#&|0Uyy(wpgwi=qP(+W5B8f^1 zu$54jPIDYdR5aAlqILgW9JI+KXY(f;x^Ok=(*1O+zaL$)3+l#!4O0 zSJmnS<>0y6J@%h0BYqz-E)xorvI}MP+Tmj4YC$S?o^wd3e1%U~mn6h_KDT?HapUjL z<`M07dWzR??KBqF`*6htEwq-sZSWy(bjndh#CA5ARY z7$)sBl*7XSgStv`y#j~o!%cQDH-r(IU#O)0^3TzSR3mSp!|+Zh@y*pu&cr#rx=|iJ z7)PDJgZ`!cW;5A#yXXL|M#_J)2(^D~$%e<@ZH2&_X&DIxL;)ru3-f;) z?{6c=+{0%(1O?dq9HO$=|6S@Ga~^!eM-T)?af5lImy@SmT0Do-hFHd&azUw{~iT|x3iiz2h@nwtE<6iVl%w(-T z8rP4us2o_6)g#nb?}O+khmi!pcw)J@_;=;&OW67nU;igaXwhk{7!F5qvm+v!K@CRz zZH)NAQB;gsALB0!|9#By@2^b)LOBjw8t1?FGkeOqdrsJC;0P0wdhv(y*vvL=KIK$3 zS-*CG6|xEPwwTO?KeVI!Y%_QDL**MCB{e%v>G8E*gNDy&u>#i3Ez`!~-qL>FY9x#R zEC508)j@xvkK_G~M1@?p$qZnH<_4;W~wxXk+ZcJ|Ks)}}K3c4=CL)8@P?gW67ghckO$ zAPfDA>M*soS;93h(B+(yFx%gTu+zaH2UUw5;n?9(q-DQtB_lKq(^$EyRbaK*|$D?-7CmS;|B`UnpnJpvO;87 z5F11<=7{fPvm4EJe2?vL%dB=yb zpa8^r)4|~$w^S5tPgaG2g<~dXg5kCqpTv#>oxHwt`qIR_miV(dJ5!#kKrozlxu!0W zf?0tZK~(H8>rBL7Bd%@8vi{)CM{N4&J%dc6ay^J^Df+ueC+Ni_tC|yeuS1m1G$Q|e z;t62Bc-{hX#TF_w(`HJDg;kW25K`yxfbMnFq8bO4+b1R&v8U_gU5}4)pcj9F@_Cn+ zGj=YXwsc6Jt=-}4DMhG|0}2YYiyA)iL7k9$v%$s=J-QpVQ}7|v)V zb5f&#xVfy^_?2q!^D=u-IPgrL9qslQvwkT!O8k^=vF7ItL+zSuC}5rq=2^K2>H@EU z@~?U;S+FNsXQ)?~%mBtF>y90ouiHg&+Pi;YnQoa5e`AaB9JOB+GNV{lro0&-T{JK3 z-v=M4iGXX5uJ0SiQ76O|t7+}DQVe3Z=Qy~{1&)Cvan9S>DMreY9_2yHu!>34&8Lg*yz13jq?~8On8bAG|(XM~pTG|<;d^=~lz{TLa(^}s$RKLo76w#}>DbNkxdg%Z{JqZrx%%%i7`^Vcxa zAfN5pmhj6lUL|Sk=N}qHdhC_z=2vng^cf@PQyD!L9u3C=)W{!3^^r;bBUEF!dno~$ zP4W_^I|DPMSJqb-1qe-C5MrlRMwURj(*Bc>;gsy*(O2zr+1;fb5Q%qMck$*WfJ9{Z zVi$-`Fti15Xde!R$?hnn`wonb>miS!x{@MK#C8$Ol>~4{W2%d;drQ!|n=6WBad*Kq zC~Me^N<+vc6o7JOq~1vfC@SjQ`xSdP82T880%F*UE1v5)%K+%zHr|`xgeMDFs`p?Y z$a4Zmngv(qTab~TD#psyfuuzy3!|&mF)X~1V3K{VuT}4;En}~_zW1?|?vQ_vVSgRF zf?0900QfZ~6C|+NH}1a)m6ncu@8v6B;TM0}iUA{Mc-qRy8~e`XO8Kgh`5~mN=f8RL$=O8L;qy_(zKhRhCS_sDY!NV~a%rdtPeXRe!sZ(y? zccAm-7j|*U2bH;CjMwFKjxdOdR5-yRlQ+Csxcc!U%$SqeKjs>e&{$QpeF?WGYk4_4 z^-~Lv&Jay#8#-ffd%W<|ybCzbk)ctR_`Gohni8LH4JC%hHS?x)@xjT@hsCx)lM_kg2Bzue3*xLn2-_n49HClPd|k+q$k%HT_8qsT$F zLQ{DYVGl26p*0EhF>LD{$4sAuYCw9m+Fm5#W01tz?=W){UMMvc-JkB#YdJu^!VV8f z=#<|@Ir8|_FvzmnOmB~I5gFKv0am<5sE`E$6<50uB=hLVap(vlJNUmCcNQ(63MU>s zL6_uEs^$h<4t@(1!CYb}@YO=}FI$GT`-=@$=fQk5wibhjcYBDmRFi(~lW9*}@>cmx zFB&S7^M`nm)`rxk3%dg$y5wW_kw*ovM%fB9R5Owrz&4{lZQ3jg+@t|V{|P^h%>e}L z2VSDQ`i_zY&Np+WQmx}+pPEGla*{Y(Ev;pJza$rQ$6G@Q|K^1W=0(BQK*z0*=*Wq( z=xHl_-`8C;j1CTcFwD}+!i8-Cr}RMrfF3$GuDh6bEbAaVZtvSt7LuSw!iwtrwUyvKq?tSb->X{Vzf(ly;hMP)$w*k0gy4yu3zs1XcUz? zR2338nD!-w9@!jh+(fo5lS!pv-nk6Y}PDn)H;GNJQ=-z$7KmtFlFJW1m z7tT$ylz10ui9_ovWUtd{D_#B$ip(0ylRpu@!*ydck40TY2Nl%&JPx){z<6_bjT~25ko?k`t7I zZog?=Muu2bk|9!54y9?87k1eocpzFWTtN+vfT202PyrUh(hYhVz(d|Vl&;Kc*`K;j z<`VtW3A0%cBEViT?QRO70V&Dl_>%JA`&%`aTuzh-7m7FXbqYr>ITwwq*nd@8esjVc zlw>yXXcSl0c2A4mC1%YYkvwv9s&zihMABcRs7=)UX0sOgb4ID5t`(7y$Ys{&{kzj- z`D-=w2CR=yIFJy`gyC$A-x`A1MMKI89**tNnj?5jx$ds>{UhcCCvQQX_uc%MR%n=q zQjg6IEQ~{&i|2^VREz8SAkIiGzG==fGoQj4rHih;f}xa)BNc21rii-*Tl_;xUR!Fx zRpT1g(nY^F&OiY%PcK4F?eC9(&gXTHsWK|712R{`g;|$YW`N0ga?yce?$wz7CrC+2 zL_@(t3(KRgSq4&AONe=n(?Oco-TEje(yqZ%$pq7T>E<0TbrSHsx`8SxE6M5YeLU}i z@EVmRdo#QzNV%Tt{nq|3%VwbpCA;x?Y&ibi~OeG?`AAQ1ca7Dt$V=wq~pjF1OyDUPDP*sV~d?9m9TtmYZEBH_S zU*NZ7PEIluA7lH9w-X~iYS`{@09WFCc837nKxA>I%$U%kvDLVO1`-upxLX){=j5MB zeSu&^a9TIlt5CEW^1*5lqFyeAXCC&8IFfk?dw+)yFQ(Mf!Ef4%cL&FO70yr<3jkc9 z=d+Vms>2i~2$+AM+s`%c3V@ZeMSNx1W@?&9gETpj9Dr19HWLw6s)QRBnPW#o0y9Sz zVyC0EXq8mzL&Z3BDm!zE$RWMkB!q6`+e|~2seLfByO|zeAl5RG9-~F={$X;qcoyie zPcYUvt_4%<5u;X3{c{#Jxb4*AU*T4cWssA0gkP6a?f2=^Sd{svnxdG>_$H6@vTtRU ztbNK8!%w~psW9>y)cH!uDg0^$nX_t6YcS$qF|iR)aHXW!5)MortwPYtuUX~~PqxZ~ ziEQOVAs*b8!&DS_7Ue6k)a?^RPwcv?N7XFKVla$D&jf0?6`0c)qp{mp(5E*Gc$b~o zq{8&_#uuqQyrd~?sW>H!`vQ(HCjpz;qR8qnZKFeXKjz0c7NWBLQXBHGc7mOJz;Dh5fMH$JU90<8 zqL}w4HHe41f|U`f5A8Cojh6*Zopg5Yos@#!xM#6%2a131z(CjaU`A!wERNbk=l%$? z2-Y<*l|+Df8`hJ&%JRuTk>$;|g0z!Qy*O=4;~n+#TXgQc+h9D*Fo&%l7bGN!or|*a zHi*BCl)+H|=&r#%ik4rTNg{^jBi=>yGU4fx@4K+E^FSF|p_G5L2aESJWjF_Ln#DoR zl7pDs-BRxijsuyMpl^)G4H>ekA>rrvxsctZb6cZ_zyC*xGHd#Ta>&g{1|Wg;X8T7s!&ac97eK)>7gj_*tF%83Da@0_W)8G}*`Ulc)}Lk$ zL}udvjyZ9}0-iAbTYvcxFbO_mH!m8$f~BdvKpA$)&_&QJnK}*r0JmkGscyOK;bjl* zb6=RsNE|z#LLH^P1$ML2&TJEjC_QVXOr8boNd+<*;P&b0ziLe*w`eRC`3kyQ)C z5@_6Ai>JB}Xw3inPE1tA3o2q;=u-U5K5-^Bs}LsTOqp-uQWW(i7ySV?X0Ni4w1WB5 z=&>N4Kg%5rNdwrDNQ7@^jvIUACSb? zYdsoCF*dI&VmDpP$<$z?=@>T>Y|Sn1G8Dk8`UJaYa6UUk51t1lIyiJGtRefF!ju+B zoZ8lsCsiO8^mtcGMg$L;9=6`~U`Wc^o>H$}uemBUP9(@9z}fnkSEbuuN&&W|4?9OP z7tZ=vMMNec5BDYJ;9!WR&C!DONf=v$8|mGlC&0ts1>0b=ZGgCKOhA>R)y&n3I6ax! zU50Yzh0Qjq2B3o8Q@o(Nto5&Y{)@#DAnjwK`SCwrL(p;DYbG*|XZvR4=fN6iG#e51 z`27-2h6@X{l(MLBJwE-OAlI#U>KSkmRQ_G0Em1wk^0M{fwdD%WzPhpJCHzFe^PD93 zlV$~5=yksvUUxu%?+0oNU`OXd}9!`PZTR{|HTzfmq zrgq#=1oLue-m;^AM&|neBecTR`LJPFg!Fg{% zf^t<{VIoNJJp6zJf7l!gp4Ir@#Ro4U$U>I3=V{kZz;BoYn8+a&lG%0)41XkjF{If@ zCsC!(Y~+k)cLYj;Du`vFqExck`9rGPv_S2PwbgUDLJf6_E045VJ+1k-3s8=+2@#*7 z!mMTy3wxQe#=dlCjz9S`mycwns6G}F7Co0}KGS{+VtSJ$!cub!*$3NevRh=_B(u?7 zl^!i#a?Esscz>?vvi^)ID2?B)Yb`XztSbdhwHVlWNmF}@a1Tv#=txJarkvYpZ*hed z@E7S#aAyGx5J>8{smT5L1N9zM#_ZMDf>yPH99n6z0>**Z`9O$EdNcu)Shkiz;b)~> z;2zo(nOhIlVP28`N4KXnL`b(e*Wa zMCeypD}#4)zD1FKa@4yZ{JsOB;<^N1@t}?S*zpA5Z(Dbh@Bicj&Artv@NZpEh*K@5 znSh;Ul4Q8BCpgCGS%%9Bb{nZH-j&oj*KOdz0$!lJhdOK$rTnfXN(YPxU#=NG5g$xK zV+BLHaFlzM?%3YDJi2EhTn>58Nlu6{i(PN*2}5~rHo*ObesYtbRYI)SQ3Sw0ae+9& znSHeA%c#Ggxez|34j6=VMi!_@^khWo)Bu?U3xbb!IHS6M*2bHs2K~jlR*~9LQJF@8 zz2cXE4$XP;4tJae!+Gs zVlww!3EY=%4$=?#u{B*LpJa%v?&Bn}&%dn#+j;t`2O5nO zyxNIbsnw3}{_pq3RqAo1o6$HZ`FdzxPM^V0$EKfVCE>*Uv8&O){wW%^BlAEEab`qh z0%XS?l2U?)+@;k~zl3A7gIR2Mhf?GLOh0LsRvA3V?6QS5@0DXl9(YY@xWOB$NfG#0@4e#QXTx386d^sqM*eGWfR>V%Dy!R z4Y3x$DssKzgl*}5sl!w__zZ2IvA#*1ONgPoP18t^yC(8ZH^H5odO?%6?;TSv%0-aG ziZn+Bi`yg09Z4Ka1NMu$swLKf*a~nppRiUqFF<VR)&wZ@ zsU6Q^mTnie@oDs6ZQ`yTIp+W7+nPq>G>*<@RLojWtYBLPWHO?(!^{^M)c`<1zrR|8 z{lSAN6+&%~BccxUfWPrldgmqY%@pI1HK(AgkY1hL!X|@TYemcvoeuZ_uok-4n)|6sQ-%z=%?Pab*9k-uKDMB$1@+iO$cnsdc0{h z3Ceof%pZ7sKTro@Q?Hy;e!Y3C&nfK{)#!%FTrqT>X{Uw?`L+Q9h1}qw{!?BDXAt60SY3W0PbG=kUe#miE+_HX~fWs^dS?OeW&jiEVjW3Kh9p*hO_=@cPEVlV-aX2 z!>*L@(XH)}@aR3rKmP@wN_xDa?jLj3h3){`$GcP0A3Hhm9^s$~|8e`Q8NkD-U5okE zbay`BBjpMH`xG-;E&ZE0^3vwiVFxRSc_H%pLRW0=`EwZ4zC8azzPvwq{+ZQ&G(L5_ z>6^b@!lP|OM-sWB$;0}Qk6$osU&trKIZ`%+fwNcxL?A`5FWCwy1OVzVsvNn5Uw-YO z(|1z_b~EwQbQVR&x1r*@M>4Pb#Q@R!6ulh0d2zY}Ky);KzrteF7TvACZ(CfjV!733 zxkC^xd&=wk6HJkX=QmM+%;beWV<6g(CCZlP4<}Z5m`2whRflEUS&~*H$J7H%1lx_1!r#I8uD`mFFZ((GL@Nxg3;>uUuF>#MEF%9-o;BgPF$_U+7-c(jloQ7&UBz+;N8C02xT@CdlK`KpCJ^4gk zKTHW{^rV$kWj=^n9cLWz6(-BczprShFK@USQlNpe;VkgUCzZ*9(+M+itc9DmTayXS zTypN#bU37)F?hjfi6u5rLbs$t0!L6Od#k7V9)xGFES`h{pxzcU7I~{Q6-p$?q^wQ6 zJz|lSG3M&!<58XBYdm{+8JV0L>U;&3M)>F~O!mn+Hjj%pbOO?^*=g9VX!tDiIF2qU z2IH7Mk-@H$B0OKlk4T>`2OAPUf7ZK9t>eoVw3K;y0scWY=)N|FGy z0p%JLn;5|_lUi!HstDqH6L2mu@#bRGVibQvd^5%ghe7Xtl%&G3S|?aHGde5~AW}OOdKPL zVNVhq29M;g*5f==Qi_So(4Yli~Xkcdp(AUQO zKOqE0r8r90gJ7q*`~xW#s%3^4B3ueWq6^_X5=(4+%<*zuHuyO&Yj=$MVa+!7sh+k` zJXxVuS;|p-s zF{X2Imfjf3>d?rQ31b1fv&}{@6*j&f25xg6d{k)LCvO}?dP}vjG~y|rVnEwio`WA25e7f>e==0 zK-kSz4||0?!xv|NOAUtk8_EscsZWdGR4*O~BA?N&iucZrp5El`cS}1CtsAJai3j{h zcT;>Nbb<-LvU0F9kQASO6vENpJ4%L7W`EB96pQaZ zhWTlMa<;X!c_EX6UB9n)SuR~V$QSU9O{Q$u-%YV4mb^%?Jy2Uf(&-F_v{Y2#_Y&I^ z3WjSXw|JmB9_eN?l5iAbxDIWk{5sjp?}JyA_8>{SQ=`l{R_oav2*G*eZI*Jjfzz>A8%|%igTAT z^9_eUL6;oxnI;1$BKpQR)+y4R2jh3<&m5Ti{^f^dqb-n)wzdJP&}#C%W6AB*`TvEr zJ2ro*ctAy_=;}q2;^%b`!!4hH9@`f}NhSS0yil^Y1p*at!lv<=68!|#V+e`XpX?1a zV-DKEl?Mp79nD>;Y%rl7{&>pGb%r`BY&+P7(!~l9V#~OUI_ue+hv~pSr%8v{l0KpO z{mEjG76=-YtrtoWTHj?B3UW^yhKDh8bKDt1=X7T@5B#Wv4BR{Fyxvfe@3%hlH|P(i z^+O8thJCV)!P7ygN1TSV&wvobXJ?+Pc)ye-1Gq9n!U9u@9)ttp$qey8IQ| zYbr4Z!ubvo__jOl6caaZWj!AiEK_j4Y}E*q6&MpQ^1`v1>Y`gBKJw2SUSDp1fc9_c zIJ5f#vj-%K)Vgbln40@&M0Gm$Y82jFU|F-&4& zxR>*)WW=o*jmr1(tXn-uQv*_oHV|+t1_!eNEGW;(awGIHHOh$r_7!-!QZNkYr`1?+ zsk_>Vu(umrHIa*f*Pd8Y6%kZuYX9E*7-XF*zj0?$1DVb8u--KzhhFf~`Z-O=Jic0XY7? zA9lQ`YH`^};jT02k?}e~=sla865V=D_^S*Md^h-hYwdsbuxbh7YnHw7!b&@j+GIL| z#Hh2TXKP!1RpTF$`-2Bjvb~$U=7pN8-8395Nclz9U9DCPrY6gE?_6$jX(E;0{GjXXi6j-`u-o|@1x!cN zPce??YzakOl^&~eREF~ZDab8ZF-{aYU>Uxh<|F&`^dSN(11gf0;HRfURj&rzdDI6Z z;oGUuZS}*(24I((@+C3qDiN80^=pdA#`9-6GlgsYkUXgTtFtD{l$7;T&ShxS%m~DeEco>|5Ui0` z`xsgk@3vv{|87}GGmb33O4vU$^g{RL^8|(*7B{Y2F zI7>zxn?ccpA5#J~m4gx3=B-1hRtuvBEikjfH44QBmqO<~N{c3cZvc>_dy1M;SAW{? z7kagG23eJMHv5^@k$Pel4_RB3pK@4%%~W8xSOrUa)n=2$zo|FoYI zqh6A7*PZxhG^db$E8ZM7A_Idpk1;ZZXe+lYg!4bK071AM!obE{oU#?43(=wk)I7dv zG>xTg6EovZY(>0U627373`;Q_rfP4Fe$G7#Q+?$wE%_b(Bb-St9oKZbB3ZCG%f#eY zBa;T_W7Z~%hQ@!Y3VU~TTE9N{3@4xq@r;tA`G-SbdHzPcP;^s_*BtnC_A z9k6Wu{Vn&h5N}aZ$h6hUZPhvRO=UfbQkXD{Qhch-&`0ZAg(1jQVS8PDJ+f( z9j!#d>G``lB#RpqGx_8OLc$|xJWhOxKYUkVL82Y|wVY?_?hb=Z`G8{TrcN`QOzsE4 zA@VKX9L2g5ViUa=jh-pd%)@=rbl+A9LS$NR3}v?>P4XHe7zMe}Fk>WQOi&f!*XD`^{hRj@{Tdp!RoQA@=yfK%^;=a>!B<1D~Dd}LYxk7Y~z2-k_xsK zKO%*>mF}$gVSbjaRDpX57;MnKj1Y z9Gczx6pp;?3tgED2HMRu$c2({7U#kLXXS;X>_?&`oi3e?68mJ4oMgl83nPkqJrIfo z(wN)0mt?6ibZJ5Fq0>Z%GENM*z&xBKuV@3`Z+`hr!_wy!;Who50Dy+c4~PiWOBeF! z_G`U;@~L)~ON=DadEX51!WvJ&9Nq6L6~v&uUU=V+J+r!`^Y#17AlYX_tBt(;sWn>c+GQ@-CARC$Wf7Q(ag0%1$$s0EH^pcj93B?odcVxrS<}s5|%|Bx48+j{YO-wJaAuezhQ9+9E#L9};I6_&c4N8R=6-{h-K-)C%vl5mtd0n-HC6(YkA{JU zBKJ|!lj4^5SD?s2HXIW1$j}f#tsQvV&&B13l*iR9zO4DJ)Jw}cBVddFWXDRcto!mY zn@T6P9IxsCYXZG+hWA4lLd;Z3_MJ#yEnO43C-88TX!@MqNgBV0a-j;!j#-LKwa%-b z^=T#L9J&2+C(|mY_p1J#7-1C)Cbsw1o0^GFX=*xYdYxD_fYm%A zVf;!6%45oipi?%0ZGZPha9LL6RUS-9$PC~i!Tc9HyI|>LBS$A`oWlK-7`eW*RfMGU z@c3bHu+<2jk)I4Ypt4gBMC_xs*2K9`yP8-_4x|-vvy}r9$8z+a-Y-lo2ClIiA9ZAn zTsI3?Xz7YxaY8umM_)rUcXkCWmT93wzUv;&)=ZGnrw*9<8aBiUj^!rv+(JCmw6U?_ z&vm>>>bA@$oMm&0Y9eXjt^0%kUWK1M%L7JBl2_ z$|tSA9uK6Z{S5f}Kw7X5AB948P##4tIXbeA#~SFoB|nH!Z7MQG#WAvEPO|pS<>hW~ z!YyxV6Mm`Ye!W629bv1JbDxCdF-5d)ytxN3KU`0Y7TU*0Jo4>jyzzrn33Sg5q)|Ap z@D^2L`wAv4D;Zpy!Lo1Tr?z09w6N2FTsSPE!#Hnu)OYRTeP9vOWUFJ0AnDn&N6-J` zWzqpbE&(HvC^kF+(|Kd(zOAmKIOq5LCrTVi?1=mRAoe(&Wx;~X<5PVngCQ=rBPC~| z{+(IZmM)(K6=BKFxn%Aev4az)(3`&@h{75e<|4t_ZrEVcR^;lY3?p_li6%OuDf&EN z*gw;^6t)0V7kl9_f8v#-INNPGh{gB`vhfS)bP}Ns!60`X7Xa$3td8{1xSN#3hrZ5% zP!s-rKYjj&W!OZ&jJum=f)cw4kd4_ASnBUWSU>z9+{x%0h_J% z>*Ip^?#d~YIX0;;(fX=S*@2!>Vpc6M)boqz zF}(A>ZBJbP3}U&vd~%=LrF!9!Et)dy;vZ|6u|2Vz0~+{g&mBVg26@zYb(c02DMkGD zbr(C-pIy9sfonWn^{TVdzxL(kQ=WwpPK1k^xI^1YYMe$vZa*?Dd1Rw_hPCs3PYG#< z!@FzGFeO08)CM zzO7=1PTGqxkw`Edd>tzA?6w4ENxj{s@i4pNq8TR(rcrd6*L$q&!vp9EXfsOqQ5CiQ z%=I3;1#nS(#W8dvU=Lf~J^Yle5SDpT{yVgcX>({lR*c%CmHxM11tkn~5tlLTIq-bq zPnNK6Wd29Ph9gGn4Ig4BE_hak%krz#xOC_ zd1LHCX4-LK;#W7?-lwrSQb4%LdF34Slb{o_;>=1OTPVdgmlMR=N3f3rY6ge~vPRKX zNl6k1U5-NFzWGrm*hZ<4Wg*f-`2K?^_A$BDVKve@x04+j0cLVFqYTT-fXyZrkWbov ztAb-o1YoN~2*NF__qnG*!s>romyh)nGWm8{X3;_Aar#f+n^x+WSOOtK|BjzjDouDI zxEDiEVj?yMQsDB8&a6u_K*3vbQ;s2rQ2Up#PJp3)0EdLnpR@#fo|Wt{di7$Fyj+8S z|6~6gl8b$Zm=Hf}EVO`@oZgN;veo_&FL~r6AE@BfEZl$k9^s`+*{fqlR*C1h*!8=I zJIlS*clq3VOVnL|S)LjY1M>85mVBI0bUX~moklUB+OA}U9^g!=!`dw$ekX;%{J<86 z({1^99Eh21n+XUPK=Z$Vj}wXlTASm=nU>#nGJGAX1WM!|T&5HMUUuP$NT*}ASSM(! zPlj?$bWcewwxGJrMZLnF+LtQ-tTe#-M=+pfF!hC&JsC{lnWC|dLeWI+x1t6ACYXZH z(V)~xXi#j}M<%G^cK6b3?lqsz8UOz`d$SO8@q!U0v3LKQZeCPK9T>(5K*9NjNL_c^T1EX9-B1&P2Mond-qngeS(>poejWe{&3J0Z8?yr%H7~oIx za>gilx-qz8+|K^Sq4D9aL?wtSYbo=hnK?vDH8dznSQp`s^?4$6C|j|yXICEP%`W>VlbbbNYZ6F z%wSjwS%(0`-~s~-AAV7!OS-24A~xSBZapWgMv%yt7}YXM?0`lOdvzA1!Jv)M;BO4h z7F)eqwr(w`6iOFXd?GpQ%ydk}PHybf#*{A}3ED_V{%iE0L3wX`9DcE#3FuVRKN>5& zV+ns9Jkbm((oWg7=tY4}X%h@mkVeUZSX8HPZ(hi~fjGFzNHy?) z4t>TW2+LUc4E?J+j4|@~!;t)zsMfOOF5UYz$nlgG)NAzi+WXic<_`|~YzWfLI*tsI zKsH0!x1jwDVpOxYw5*j}Zv}9XlENTj@!POQ(yoltnn{`eK%bOPsX1nP^U^2(yLcFS z>)*$8cV#vONI1f1z2Wyrw?a}$;rn9@rPRiIQg8XZcd{(%y!Q0~_H0d0cq(9Bp&2@f znWd(Fc}NIU0BjD@RcuI46cr(CfkvsFi%Acc49Uw+QRxWh;(~6a)c%MU*_^Jb+s|)p zmzvqWJ`%UyKTKffp?#+t=THnSRbnhX*8Mg&e$T-z#eJe@f0eNC#H+7*GPIdm(+q-7 z{_AcAlZ#sx@O!g#Fv)|Y#^%aiBkRlD&9EpDSerp(D5#CnMy9FJF3mbRa8G8xUO>k` z9O$p0QqhnK8162UyXI`EuBU7^HV-73P4B_}!E=3;=fE~uXREA39ha6M&#YQ2YRdQ- zyGqsn&KyW%0MX%RVzOnoD9a{5KY#`uy#L-EqoGM*PTh3%s2mFxd;Ztc6C=0%a<8pMp1dOV!94=ASNUI`a@cXI9n0WwT@Rr0Vw>LpP7nRY+GV= z<(hj9f;~7|grflQ%DRYwc0_Px*O@b-wd1JzTLkbdWhT*$4VK!UuGnB8fH%uZ#1_JR zOp4a^?iv8F>qclZ&A}S~7o5*+=8F(fn*!tooy94}F12=vLuy=+j@8@VDlgjNr1(Wv z3)#S|7kY!;ecq3vIQ&}}Ll`J97aE{{)mRjEs_zQC96ShH0%=|Bzscm(i(|uIN@=d| z2zLKF?XpWZ@&QM=QU{>DSTm%-yk;~Zp8$`F>1%-Y4SVN)Ersi)lCxeyue?u##B{sf z4h*>`61Cj$r6$s%2sPp5y$JWj-4FPWpv15(588Jb^d3#zyZtr?#(2DWDUJEEUqud` zKSI?7WhxJ{T6SYmgcl4QGEXQUe!|wI(3jmIO~C4khUPEDoB9dJM)JLC&m`6n`jl#mCR_orHNa>$_<5(07yg1c>tiM_ zZ9B_!54BF#2EfNV?}htiOHL=;u$bpZNo*ClxVu3;#af$Bg%Pb8Yu)&Xjq#x?%70%p z7>*6|OEq&PeODjECZ>u*i3zJJ0avh|HsE6u!%zcn4`%#1TWEc@@MXK%k5HFVhfUt% zSQv9szdkKT#Bul*v4b~goz)2`g>OjnC9O303FU)0R8ms**tof2T)LoGeqYDOBOvie zpNXfZkwfH$)@7Z_r0!?k(h@JL(S3H}aR5$=GXqBS&>56puCzpCzn4dZ0eAjSW-lSZ z*qZBnXL8&lK+>>-lS~s=tNtU3^Uz`(Th|X2i{-6Zz_ac_`6$8<$|54JQq zJ-cH~u!O~;XN^V=BIzOr@FIFG-N51KaNI3jl%k`b#ZB%-Rs81%DV1n`J;hOUc(6h> zYSgN3s99L3Lx2vWB2FA&ouiAo|Bq?fp=uQ3?~Psiaucg~#JBZFZ-ypiF*-vLiIilf zNsr8IVX4u=Z?_^M7G*74c>#AFfX~9@WP0uNzEhi3AdCdjAL$jL^HjI)u#VjZ8eSE{ zZVq_ks1-2&iT2k)av>1y?Sl(8lG}4FQ(EL6U%P;YD2i-;XT5+NA_5S_vSeE~v$#e3 zQ`1OC=DT2pTAMCy`f-rnxm&nfFrLS)Eho&sdwED4Ad*byfU&?cfmZc5g&_Ac=_P%< zm;6xUi74%>rNV8E1m|y0X7mT}W)jwl98tM+!13&M@)Rv$XP{<`GVo4y&6>7Y%E9bS z+`aMUr$^e{2S!sf%4w@h!6?frm>GQ&rTgXL?WNsCxJOC{c}LX~DY3fvXY zC|sMmCpPF>PF*u~jj|iz=|Rh&`NS(rJbrnnrs?1|9pA?(5bwykH|#LWd1vw{v7&ri=Wj#ZoG0Zl) z@D{~i1z*ID)KN-#`U$CVQm$uC|M9B$>^r|G_4BiZ8ag!gb~YAO{2D4h)3>?*gBaRR z4`%&qab)F`XD8wSX8RES%tkJTfcrsNnb8GoP4%p67^+QMn26=u)}v1Dtnpeq4&ztm zXULMZv{)fY9pZ0b#K2tx6ykfY3w5x3o~M)Xy5 zS#7h~(K_UC{3?H})YRL)%3hAH{ob31i*R4Xq<)&D{M_lb!rAa&8N%n%8IzQU*>V}ia(;rpq9r4PnL z#Y?#a*vh_lw{$7_x$e%({J}C<<_+T*HkXWa90HALXV{Tp?tJ~6T|om7EuQUQKwYHC7AhwX+L6bq@SMY>R`%a>Ri~PVz@va`T?Mn4 ziD=+_90d9ur0Eb+&`q)Rt|o}XbuFQBCJR%_g37Ek-=vRN&x~90jE0HMuuM=aXz(uG zT6^ri&oYE|iWp~(6OI&MfuiCmaoK*>_(h)ko&1yU##EzVBfHS_4dhv(c?ey6cLcr-z?j*rBE$=3arklpGqA(_e<@^z~ zjj`k(I2%NWQhL!)(bT*M^^m*&&YM%|&PQ7mF(>BdqvwsohV>8-hYl9vMACrsO{ApR z6`5rYvbLdbbDb03?!50vI!Hi!!*(&z;P^oMerk~#)JoAB%sgiqDN%(-z$oF+r zMTRc7Hc5XH)ezzX3y8?dF-hi#b08bhS_R6oc0MaOU!>-~?-)7^6BKWaKoHvcIVp2n3R+o6^ZX)E>l)lKoOug8RX549KsM$zB@ z-{`jmK3eM(t)U=OA0IRy_!kkRIyr<(vRoh8>_E;LMcc*$K184*u{K+)Jx?m`!p*v+ z(WAW}?te4O-Sq?=b9rDe7c5!G#H3Qn2Wx!(|wd6T{E4l_Uepm1J!{ z*ytR*jK10NBh6RzMI`Z_qgLLD%U8TJhk~V}8D&~0G2OvrN9!lyy=kyL0~EYyvYfiM ztT!kkZgas~jqC3YXbrOblI|B!T)SN3n>e$vxJok8hGD)IDvqvjO;IU%?_2QA3JPW3 zEcxnGKtFF7TooY7S5@^NDYS3qDHXA2$X&PpTdq9TvYS-yu4{c5=!&yQZLzh;Ztq5ha|)5W zgE_hP$Li!xk73{_NLnhiWP5$oY1@jterqwScOCKU<4@NqN>kf~f;z_in={9Yzw=t6 zjM9_R^(^@OfTF{8+HhmH+9tizBXxiz7%-_UHg?r8hR)@lExQ3RC~(YjQMQKAgWL;& zG9i2VHH!%pa@CmA{m0nitE+&nRb^{Ck{Tg_yPxMc$a-}0S)e0hTX>AN0W6` zwz_#e;_E_>W4e;?m3_Ba9xr)0>)*Kk-xXT7Lc!%C$G17T=w7*q5-A>^E7uf!;>reF=cwVukrO*#_% z(MBlN*Ij#Rh;wroiB$OIlB|ZxJanoLy@~S<{01!WxU0jc?0=Ge%q1N$q@JO;vT&QH zhmrpt4-g_fY4TSg+qspDBXbzpC$fe5)B68U>sC+x1^H zs)h#o#R1{5xgTe@qWTI=231S?$NC8baBk6i?j7p-JQP0HMwYv-Z8urp7D<;0lB1bC zh1Sbe%Rf5})Y5e&)hJby;XmUuMcNldNJ4XadXy*V11w{}rRQH!UP3$BQLjF@ptLuP zXIqyBn;>R_6@4Bd>YQ-qOjnwU^hpl;q1-7(DTf*PgN+Upf>CYhSRg$fp_{4rF-<;l zjT`-_U3D$QBopXdeN_1ykw*v|Dhkvm1|A2aW7v1qyYYmI!fXia&J3!dwRz}~&x{EH z3OKtJ(F!Zr4=JY3;jB;JUCwXa#gg0b3T}>{r+ePw06?>nPR3*AK+(a)a!_28n4%5? z){t}`>7I3l9uF`$mwQTIpwkd z*qMPt!dx7EK&6f=nb}uSclZ3tX{iR8&CO~lHXz#?n4s{%=m5$tTU>zO4iBUbFg0&Z z|57jT&J<^I0V}E}%oavtNSBlG5}Jl%)T5_6v13h0V^iy72lQG=3{vU42k;*38QpB& zbG#{U$~3fS+0t7oC>4ntVX*G7`LRW_>F)mV9#%0fYP%?(c_0a&*p$TLK_u6@m~g*qA@ zGNCDo8U4Yd{^esWe{=pD8?X=zY#robLNBm)pWTR2n{%eh--&s6;Ll0FDXJd-Gx|u! zYZuU)i@!skB0B2%_qkrXGEke1&AdAhE#cgQ7$F+GPwninJPE@7M+k#C_|vzc zN;uwsKk{Xub-)qPj@%ZSyF(5T!`L(GsJgK9Ndqm(p%u#=7gZxfNhV9yOUZT``0IPh zYY1AJzfs!+YFrOG0HmT}n)^5NL{+TmoDT(<%dai9FHtIUaIP}JQsl@U~=CL=j z=zTH|R~zJPJC$mcvyBs! ze`3xStZwS<+=2sT@AQ<9m<@M?Fq4TWr8K85&Bjqt)&NGeYcE)47p6(QAC#6>{MXi{ z;u{cpRdH_>xgqY`gn9Zp(@-25OMjT3g{ufY_6?`GpJY8M7hOb(2g^G0QL@ajkRr6K z*COe>jhK;1EDbS`8OIy2x=}<+7=JaQy(n}h9zwz_#d7q=%!$o@vMQ6y$;ArwqDLk+ z0wTF)Ugo!=50*SkE!^wiF74y6U~ejHqWd}VjMAR7q6N$&$HVCcXeHeWN)s6xGBp`#8vSNAI(8A{q8{SJDD6gu>Utd#$<`{suX!|008PCmpNgKo;6iA6UQ^>KnxC zc0DerV0^gVT<^KcBFwe`(4V5NtEC%Ne<#%5EBbG7xHpBd;m|GeN8r%C%mF&LojnzI zhPtofzC`&RY3)7qdeVS1-?u~@XB)f?&Qx@rkklDiju8p(3ur;(HVROX^^ZYdEMn|% z_?0g*aw{Nj0-ZX-7B=o=0FneAjY454(nKp8Dovtj#JGKeZ=2C%<@2+2n1Wy&8!bg4 zkoP2o`e54fyIU3k0AaKMRA9WdpfEXt@EMvTn2~^0?i?ExrYTTKtf^ET8W*ueJfQU= zkc#9T?Utrm?Kj%V?OFCO(Zv?4O}ZvhvH8u`KstfTksAj@r4X(ks3vhSzy#C!AT3=v zJbcG#@TY37o?~Tl<}k}zxr9%vzc&FQ8M=)f4brD{1&y6oFbRACaIG;-~1TUgFCpKn{a49GPeRnu*xO|2pFB(naJXW z7S1~(;v{B4p@QBy8t_^2VdtxdKH)s$>-7fqyX`#T#O!afT#Vic&?uRNyIcZx{``(< zP>==ijj75oVbs5K61II^U9(_roEDyIcYF1Oo))y-aD`Vi*=*=4GT(=Tc$Xle(f^GlHLQ)x->4NcRWB zYOxu3X^EAM6B4a77rMNOY`Ne>>)E7AothCHOBWnQ7V4=Ehx`ZBe$`06=B!n{iXNH+ zTRT;C&N&uG%!voqe&|Zy9MUiGBH0OB$3(Xg2&b-EA3IOd9J7YVmWW{shqIfM>GK=X&ag$ZMjJ< z-Ew>t^;z~}dSyM&11iaX7n)in<78TGnx0M;SM%Lx4q^*x+;*@4^a?Y* zAjb!zSOh>odI>!*VD9^Svf7A{?)C$#*aGF2nk^BKeVBP^Q+t4aWaaLh;f$awblT?m z(+>);Pa+StK3VVVnJc{BF;Rsl>*_f$d3B4PqN1H1_daT3iXVd656c@M3uc+JV)*f| z_^7`4K9t7O%fa%f^4StNCIu?fe~cOOji=`3I3rUXy-wiFU#4}w2L&$p^6z|7(M#wOkZTCm5Xc@dYT{&Kl-n{97S<4OCkoDqt zz`)bD&Z6L;$bh(2!LN}(xGfQ!EG`K+aA39aRp2fg4US61+OF?t&!>>_6CZZO8P<*+5`7HFU2VlY_==wRdRo z6A4W87XdcRc9<|*^QCXncK89o$CUTQOo=VT?Q{^Iv}Ez6rYtWBIOyMS8%oxJmaq0g zCg?3P*lEMkKJF1@MWQzVVINy1q7H$Sr^n%ITN{JQzoBUN?Sp+mE zB4gz`@C{b|UA)lhU}8AMjIKN>LGfjFSPFRrMBTf!wi}V{^07T@ivPKFBK}HF3389^ zt$;7J`hjvV%;d>XH|FO8^XpnCEDL+mr?6;F${En%`H*Cn@DB*jRzdjvdG30)4!ne> zyc{Zn>z#Ss19S@P2z0^J5FDB>19P%XjTo+`>;6#-1M{OVRMBlpyB}p1iiE2iQ=EHk zY;ziTUMt9fu)d2flB(Nx)kw8eQBNuynj=EN zs*=c_#cn2W@u03%E*Q8GAVUnDHF4(&&O@w3zO;8{pbV%YpecXV@`Ur(b5{irtexy? zw!2L5gzKwCx3}$IyISg0w$|}UVOPClvfa%~3zea!A6Eaf?A*BH`ouT$HEXx2Ypx~a>`Zg<`7I)S0>?f>hL^gThP5r zqFSnKo2v3Slsqz{-ZlsoMRv8kT05dH!D{(%tE*hZUAPPpch>6vSC5=iaB?^R9Bob5 z154|4u;@pRspl!Uyg8o^Uf9kqfYp7~cfG33Rz=qS_ps~_5%aWGpjZv}QAifOCQ#!9 zT}_zBq+J(`uC%ryw~39^*Q|MtA9N=lMiZet z4>HmkCmbirCQwo}wFeL2zy4Qjp5P;@jG;z?wp6D}e-xC5ik_j(Pp+qoa_S?bj4?oo^#SH6 z%fG$2m1}@R)xv;2wu(0sPNgDepMl~QEh`>NY-ODWsGnRX*D3BQu@~Em@1EI^yy)WU zhEg-mzAQ#i*)D2F)a7{F@n^Kx$qlfvGVaJa_{W<$HP&+y{^ngzi?X#^CqgM7;CK)- z?EK+tuOoqyCxK6QPY{agaw((Bg3XHIbfImBTP9qFJ$(hE+XqZrlsRzk#A8g#8AM$| zNqAqq8DtE0h(|%&E^|oHc;kDRQiEvzy3Bp=L&zPTfjSdo+jg@DEZgq-robY&tC1S8 z@jdm>A@7T1Hu_;#u$tg4L?PwsqXU}~^PD-%`gE5suWfmtF?Ex>t+XD8Jih>C7g7Ry zpzC$68?cA#Vqmq?;jc-jWNBeg_tcT{wZtnQ(<%d*2TjHhRtxyRW=0cL?K>SQ91zNg z=-a%@LwWORm#*5!T)1N+q`Fg>q*#q+@AK#$96q1Rau-^d)8hJFqEG_Uj@8n|cwJWt z6Np0`J2_y6L?%I#1?Dkro~qNU(2bC!5clv=%Y$@2dn7PNXluTo{Jk;g&q(!uTSQy% zFa)_P4{Et+;PKn_F*$*;p6i7iWUGSIN49i`P^7s4jcj+eg`l(G+O7-uBF~47zMJsR zZ;HJ;2ZYc|70}WJt~P2yJ0;AN<}ad(??3Ff-37pNA@};USjE#px4(#Tn#bQ4?37rV zyT3pA!-Wb|8iRX-CJZM}%1LhQ!51uOg}~F6?|kHs(jd}Z2{FBB7_oN1hG6( z?*Pxzomw1ttev=fxWB)0B$>Q*-cBx98uP0MSxWvYH@1!}kqIouf_`yPQ-#C93S~a9 z>6sNm|9VF4)nw*~WWQMP=3dA&X!V*PevZM!3be4R#1dXn^1}QxYVi(>8k~KfaKo%k zBb|t0lI+(yja28%3qm%lVf6F0g*)D%Rsmi1_FI|dn{zELvt2v`Dp{>_vd7-7VUEM$ zAH~$0Z0JSR`A-(HlD!W_EBI=_lf8Rn**Rj4Lg2j6PJp@c0c6X2R378ZG4e)_-S-3W z%!UFjKDShLy)Qp8HWCs=Rwwc4-no8qV~-hZ=foOOTtWnlLvhGVq0r7Xzzz6zH2{ZC z*LKop;(;qjGPuAvZYsi-YEIKY6+(%0n=)(otEbdYdybQ+E*SoI^sbDUz+fHKF)7tu z5E3Gw%}Tek1}|MsGhF#yQ!I@vxM3Ed5HwIt9!t8tuCRb|I}xDG66#*P&=%ivT7gI5 zA_C|3ywZZG4P2|46YHX%ut4LNGeJ&u4KvDd10RC4cw0kwTY>+>km^l)8KM%GfmmrKBd&UolRI6W zr2Wfo*@f6hX()i%#wBN|heW03I5{~ov^ww}U5*P4+JA*6V{;-CHAF>Uslx{_?jLs6 zz?hxI$H8+ZQ|4lbd)4o^uMDvip&`JAHfW{p8q~K|kdl@}4g$PC5wk8!k1X=j6x7ik z6nxRyx6rEVf#NFt+e}pLllz{Oy=IA2o(Yk`4Mx{4Gh(NKKrx)*4CKPy%`MYy8;E@zL_rmm!|MRF%pXHE1KVG4Hk#OvtLE?KJ-=#8a zDEio*LVvYip znAwWnvUmsC%0|OyO!%0-iMP9REd=-aBc+e$<~}$phE>luBM3vG+;SSYa9+b;8qS(j zs0?qFKmBqfv(S>XnhTVpDPtRW)whU++KcGNskz6+fBA6kqD;;NEvz1S5)7iimTbFZ zPqAmrp6g`>xof31cmCsYIv4E>$|mdVV%u^g6sdv)S(-MQ3Uyat{0p-Pt~P_sah~~i zF_Y2Pwuh|KaLrp+rEdZOQ9&9T%Z+d&wG#|~k;*?sU)6EL6Toqp$y@<9q|FpwPp`Ub z5BN~en3+2n;Ca%j3QM~(h}*kh!48It2YhcIvL0Fw!Wgbk4{7D%mRwYXr0Oy({aow7 zo}E9L3M4_-f=LDXi|=;2cLS%fIH!ww85M1eGYd`AI((WIi}wIKJ)#Al;2h4VF&lkR8G#qaOm zEsr}^jksPBT)6l=ok%K3!$Qdlg6$(=EOJ(jT;&y!q9~xs#rXkv1}%p35r`_VaC>&p zoiD4KSF3c(NM|y~^42xrNr_Yroegi@u0Zxyq%}DEBz_KUe0A;Y2>8=(n*gytnh`dy znJcDTb>tEHvZ@6}wiTE31i9XwJdE9Ao9_`C*(QvXV>F3z=37pTSUAHhKinh28 zMo@i9nzUD54sdQc#9^LvjWK5zuXT~DUuisnIX~`0Z~6^Rbtow2L*AO?gg6r%_eEOh zRU%)8tB_S(SpR%`FoWTGY};~?4-%%u#kf%iJCg(1YRrq--qqzGZFYnYZ&iK(uFsd= z@j#eBG1)!_ZY4pi@7}NFCN0+725WN1+IKw21C7vj& z?WID=PG?T^eWd9Btg}wb0Ly2|baAJkak4SI}2tn=e z8EI7Rjo&+w&^yv?r=vV*311v4!rek|YE3;MyYEHW+9`QE63{XYzn=8DV7&w(D&XF- za?KTQ-d&juP-eo+0<*RUe^pVD<1zwGzE>9k#X{+nwini50fcPiDd9c}kxQxTB{ z#^iR?w&P23Fl$!C>>9E`hXP~h5(var-fkrq^sA$O0!KnWI>ix9Wa~K#vAH(g!JNk4Pp-3jCN(Ia%NL;oKCI3mhTHh7^Ye%+;MeqZ>v@L z>d5rEb01?HLkf9g1Fo=Y!d~K7jW>AEhY~E+@QZ7rt=GHNeK*~!bJ={;d3%?@5IiZK zC=X@jkl2v++2x9TmJ-6Rtl{0)tW0re8gW{&{diNXQZ0QV*K-F9(o$LT1SLfa@K=m!hL zC%_DcNTFd_u;@9cnnypaAF9wAYrY&f?Q2WiCBB`Sc7&}BN7sH}b5iBFh#5usWuCLO zGnmp*Ib#`_T2CUg(N9#WKGoALR4hb)V|G9Q%mq&AcL!R@xeGZU_n#NcBfr3&HUqYp zFSUD?iV>)r3_6HM5Iw<-^rdoQ-5AgLiT5=UR)wRNQ?taexVMNXwX|>eCKy`rGA+St zuc6`7peY_uY7dER4G__r%>V12^FOMVS?hWU9ak$ErK2vkX3eqlqK5d>iN(->os`iz zYwXfdYecqut(|RO+0^kg5!DJ|PXoo+Dd~pu%3J^1&Cg$8g#?vK7{FeInWqsx{bvy17?3d_u%S~h{_sjWQL}5m!Oeh9 zP(Wl+S_!KwmvSPa^=UNy2>TL7(JR{M&Y|M!g4H8JEeT=8969TMlkaDpuV>B6oL{`H zyunLxlDnfDd^OZ7BIjQl1P&RN<U$%9|kqocWj@7Wa&f?|Mj+ZlP$f1uyiQCu|T3 zlKJJzt|L=-7TdW3j&d?`lG`Z*DZ3x3!gm8?k^ZIS#D>3QVQoqhvy>34U-#FD8T>QS zb*%5>??84*auRJ<%2YgCtdYVDW;bVPpzF??l0S}U!P?9qw0$I@R8d-lo2kq3`)>Y~ zUm_mW-nC0G_^-yr^CR#zB^@YTB4LHRbysYvdfaI6 zY<^SgqM?KXW7D_|WUQNCr_OoeaCmNZ~W#@dW>$P3FwP z3K`5DET^czVTs>q(m^`ooGY|3gDeCTu56w9{BZ)Z9a#PxG~VUywYcHRr?w^dm+Uub z3zdcbbht?^bf@M2oSw=Dz`Z?2|Ezsa3q6+dR5ZtFnDCoDkks8fDCjm&_0tayn1ETV zd^@Q!YI5>kWtSEU#{2ebP!U>KT%tCeC%;0qL;FK^;t0N6JW z;lmggRDJIOP{bh3AcZx-`D80^2Bo>_Pf_vmm0Wbh8U^%Zf)X#D_|C+#dj#UdSi60T zCUp5#HfSJ2{q@fB`+axzYZ!@x{YEPX`+n1Zqz^_58LsWPREUD2k12bDy%KvH%^{a} zWJ%tf46EgNO87+U!Tpp9MpIbz&xU|EN?s;*)3nrvBTEY-cF5E9&ae}hhm~C@)=!6r zLDIselkU=)drWr;3w7?yW=%YdqkXyt1!sB$*Q6A&57%A>+x29bO(GopQ#kPk)SPw6 zJosA|BwiU|KR`=6t@SXZ*2(wcQ4t5l;$bH3GF#-+Cjb;vMg6Re9j}>0i%>3BD!C!3 zcTkgTZp9IkQp1}OT{$`CR_y92hBX}o{^5erBTR@VSSg*lJD&XtRd?8hEO$LQW+3q= zGF`tIXwC@8hXn|(25Rl<(|!dDfqAcOC|=5OUu%iQnb{(@GoS(uHR9;*5KWd%xw3RgyW|#nXUIVpQbI-p;@`hd1|`q9Xmgp=H9#B@(nAUli3#&D!Ej zGiJ_GT3yQy{${ZeA;jsBq{TaZSm`bivdhZ3j1485klJ+*o8Q)#Ziy+)*p~^GA`BVa zZx}RFcTc5bAbv#n2IXw@~+O889&tagf^#BOz#T7L@yX!kcw}Ex*#l3HoH6X zY@wB?yw?X-EJxvmS?4kf3Qd4PJO^eE=$rq5YLD{Rt_f5YM}?8YX#QxYA=jtGBxD5& z$qN!uy$`7M$H9smUq-~KIV+x&pX{9Pdj9rTRye)u%5>tY`OQ5^ zk3tsp3mxUK2iN@1v8AxaO5;yD#@_SWCieXNz#FYav)2q6K9hWWI=UeJKM#n+3Vuw| zh~Ymo320i*g5{~)<@@BOUTR%Dwog2NLc=DNlB>-7wNuSkyGd+-tQrVG{*6xY(%WL! z6FFfci7nXWG0f7A!cJE#($E<)qIZm{-%Gk56Ce+#$6gXK`f$qVda-{k>I}TN(eoU( z*L9I}6?Vr*DcIB|qV@2RJz!Ne*DM7qjXU9I!}1E%yk~AM=|S>8;ZqXir)?c$rkP-T zoH=bk5vjezCSQ;xfT&u4Bnd$Em$rvEX+bgHzYiptRxTNz^Q1s`4V52JIG}xjg*TkQX5}R zYl(^WxNfp%-xZj8t=#nFXyCN0r)deepS#R>nXOT#s6rPl1G|n!(wY9GQ}1(*)>;sn zyA-_Lr#+1>jGj)nI?Gsi!MAiAyi0d)eL9>BzE4v&%5qASAW@#7|MeHX1%LF8Z%!;3 z${BPwGLMvZ?NUJN#LI-kr#hH@oL#%S5Q!c~@VP9Nww%pNB=dljcC!uQUJ;d9m8+8h zVt{7B#z2T2SE-HWbADU*b??ukqe+}2$TBub>qrL{BGWx#^v^7^lg%1) zWjOV)v8#f6RPyLOtZId2cTl@BWUX`pr^n(%g68*m_3itu^rx0#bVUJUvUybhPMP-|J; z%!|O^EvuJF+M{Q5gW95tug{9I$Z2-IJ9TCw2^@G!by#9tog+EZb2!UeIX4&b5MrNW zWjcb;d>+*vnu{XNwEYcg$9}(q(L8f_BJzQawmsbx)sJW+XqiSKp(YnU1G3Eepi+b2 z8Gu0jrd|HkYW$>a{I1K~Z(kojH475hk*5CtK9#1-=3eNTDCNzqL*Mk$f~tJ~!c(H=s(4svZ2` zMlUsYsuit#xA&zsIyp@3gX-J!PINS3Cz2hC*7#};=O2G6d zd?tY0Okm1UhEptWVYj97y`mxfLry>mRk8bI&-^@Jf9@^De%+00&q=N2M$hz-;tchu zUQ4PJPx~aIauL6Hvh0pvy=8Lja&)VU6D=V%nz{iU0ln8NJ>BH*dMDXg07jhswGA|X zG_hTP-+JJ)qr^u2TtO^M@;D-S+(D*0GQsG8tWDNegjxm}xQ9D#6Ja^igV*8{rqxd5 z$0KN=eluszW_lWU=k!-TDarGI-8k|45I=ScJ#i1dSuJsANM;|F|ImxGJVpDJ$#cx+ z4p#L|%*$)8AUoUZqqqQ_7TvYR2!JPW?si^+wvxm3tZ<6Gw)vk{aP}KKU!Snpm z<(VHMbXD7w)wat_h=a+XFi3n>O0}U)7~`U9ip^$L7+Kw?h!9MFviDLDA%~>2|IA7z zsWgJhluDT{?{k$qo;*#E)Wy`Yi2ERlBpd@T&B4_x2k?gFh& zRTG38@Oc#IK2|mqv`zKQGMVIZahT%@w0kE79WBAdJ@Tu4i4eutq49*A#_5*dCCM-T zL{x9&BRh(qR{-hFYM=G3l15q@dV)W>Y?mDFJDb~OCmRfm4WF3ErPJiFNR|>zW5~Sa z{UwFCPU6-3<3)vk(u6p^SSexug}p6&bHOtkCjH}jU^Y+-SZKI&4m5cs z``lyah$Iv{iOgRFc~W)zFo{G8abTGZ9i)pNXAIjFjE}Q?Vl2NAmM^m&wUkCy<)i~D zhG6exWcK-L1OvDpm@$;bO36{n*Cn_Y=au?S`V?&lfdGQmN6Ow3Z^L^#cl#NMN+Ff*4zjcpGLh>>I=EPcYyx&orma{AIG1^u z>$!xq3GS=(K#14=XKZ&2Lna=dOr%1tEHVR)RY~Qd1_LNSpIe??Rko$Ok$)<^77XjA zmP8?C7|spngk;nr07~j?kdPJdRF(qE9If7`;fe-?eRZ6YVYS9?IOb3_>ijMjZORW_ zMX`89?o}y)O0moRYM>sD5NhP~;!5^FWMuMUy8*4wwn?(6E$YLGSa_|`=?e(qGQevF z=}Zs}ptmSi{CVwG2%+1VNE#va%((3gi2ZW5yV(eVwA0?(xS>vb@^xCL%@m^L9T7Ha zL!?gvy~3vF)VRiN2xfriftalSQmlChnnG<|+yKquO~4cCL~+7KN%&1Yy?n{1uuCbt zRP4pPaqy(ULd`0}zyRT|iz>7I%y5fgP(ueWXdN!dS{;wLt$KjA1Hcor$bXK~GA%47 zpE4D5*MwZWE1686T5Q|o7Rc%YtNjmnw7wxNvI-;2Kyb{>lH6v%yF36-6L>3zISXEa26qM^i%mLmPP!2EbfBUJo z^rO=Ab)2+eFHAJf5CACG)ALHB#M;bUV|^|(u~6YmvC(N-;)Ttycm0?A(6rV}OPp9t z){p-GF%2PMO4!Bi%Iv7E(}BU*qD&C2hV#>you1aU5Wb3%#g|aDD^@-2SbI3GXO)xW zEu_L5YRPMy{lXWiGD#f(XGH$_=@k2f;8Z{$WGT|Q8|ARkWiJ;^i>f$!Rok}XicLOi z`SAK7n#f!hz@i;hL2v4-U@!+yn;~(xXfy#6z=dBXx2s;K*BmJLQ;(1}=G@K+6 zY7zFw8@ZM#8tl^?^OtlqKT{M}84wtiQH_g1Nz%YGXwLa`EFz`9X=g;#P8|wo=~T9%v=Bm-knD7LPz`n+c(seG`ngFye8j{`} zU6k2*Gr3DC8 z{h&+9&j(x4RMTU+=@??rfOAY>1tj)z@84sE(n>Qpe(sY;OUx}V_ykLhFhVX1BQtX7 zoJ-XHtDPE?J-4t@j)Y30TcE!Eh$#X+Ulkj+zvoUF)TPd%p!2qnJFM9wjsnwaii$kQ zG&)CYfJ3>Nr*vSEGNk=Wp2J?Do+YAS%%CO9Lx>09r6SjMLhX$W${cKh-K(w}rvttx zZUHm4kn+d&)!QgFkm~K=CoY5$&P55)dwEHnRNOtwRAg4G!N{Azq(<{+4#Ku3JHE%t z$s1%#fep$-FVHNVf`bN+M3b`-5QFRmb1<_(`r`aoU*L!ay>7<}8EyfH?YDvDh)_Cz zB#KtznxAMxnV=gA-~kS)vWvxuL~HqodGy*wz=i$sW5rZ6>t_NK%BrA%?~Nz)EY3yEM>1Q?YtW?FUu`--2ek;$?$bYwa} z$FgnCl5YwB$URTH&O5E1wQF9xkZ{p-yZZEZM=YW+CSE&}s>Ur7>NHi;xF7W0x}rOF ztugp_ZyS{7yyBDTwzI5jRj=}v2f&5#JO28V@oFI1Dh>!-xu>`(#aoy6M3Zu=hF_@c zQ}UCP7!U$5h*NAh5mVhgCnP#!kLO03Lp^oG{D%nc5fFq%pB!W-^%2kM9_FkO5x!3Z z`*x?j?5d@cae#8z2@Q6gf zxcoN28ubXRaxP={m+2sN(hCl>JN5+oFfH4oZp}KRNS=1mm`#weJvS)9&dP66-*vi5 zb$gFSf1FzP5?4|a$8IS2k$KOqH{2(117e*+HGVmlcLc6WT%k}LlrRN2z-*L6Re-I* zuBkb2o(7>DSqVdvwB^_QMNcmRV+B}@%EGi|Vc;wK3iQ@{k^KX+^M7mwDq&`?`*xYO zm9<(ih+9?{3DMVX@dcBRef$&@vr&nF;KBuDm&bCKjC3=Nn2v_LyXH^3e?}YfH!bR? zsnAKlZWO4E#lV_@J+g$GWCSchK05v_!6GPRU<13jJ_t;eHuw=iaMA_K!v_x?070*9 zibRl<ih+9`{^(3ZZ0-LS5an|TGmV73ge`^ z88sqCczmQfH1*P3Hoig|Pq`U!gg6pbsHSNS^kUkP{H1D-;d45YL2cnsLrT6hU8HB= z|Bo5Y6DU!TVOVMnlTKi%E%qyYz7_A3)=)ohVcFdjZdvqc*^Hnhci6k!J^|9IZ>2m6 z$Bj$M5-TUAY{0D7ZHZav#uC=AKTc*G&pB_q41F%`mpQ)}Se&q96(gAzY;ht% zEo(=^^1jbQo^+P#m_)*ELLuvGas1D!mV{xLjWG20DeKH4{1w)s$>v;hfYj=!(>F{8 zRsw&W_}_P?mCvXmsaM{z-r6K%HWU4Z%x$bi@fG~#C^CWq`&r#vaqM~*kQOr{e=lVp z80Na!zqj!WfBKttc5#3m=IB|dI})8wZ?hch^5j~7Q?pjq+fFCu&61H!3dqWS#84_q znrf|wlh{UB1MG705ebjy1;XOhzD_Uf2tN0jMxhW{;V78xr!P-^-5OsQHnc$+xS&+^ z^6DCluE&tZLn?fzzXN_Vg$Fz+UP%I8!n{P|BJ3BalR>CBNiws4wVdr~ z2ZQuIV6qxUo2<5OYQs+Gee6hre+J&-sz=DMY1Jfy7p?ll$d$_cpLv$Q=VB+-t`dNc z({z69r>f>g8H?AUl$JS{z_<<}<`T?K_MRs$n%j&v63|v8)6p(6WYTEgwdF?@sbGS)smiVXa98Q|n15oNQVTQNBni zmNb}dAaO1w7GETg^cVfO0qrnzddD?E6a3|PA;e#Ar@1-C^Zkb(qgyo6{RygQAULib z)vNL~=k%zF%47uzno_fuXhv+d!gtwVNpMZGQ<1V|)7(&#sQZsOX>!E^!w{MkR*r@*){_sBq`KH@PBEIc_WJragdbK4&c;IkZ-%s%M+ z|2Z5-DDd`7sY$g*M`&+8>n@Gasa?6l9b^L5J^bu-QX?oNO3o@(GMVlT+ZzGQ%UFH3 zATzvgpHq#CvV2;NVbVPoxn7V6b_rHS(vmoUKak%*B8gy~ys~nu>O8({em>O!cf=r{ zgfNbB<7e9rzYr~p#W=U{O6d8h2RUR&Cii|cyXHp9-ixCGYK}3Ciyz&y{uoB09n|7m zw<>meU6Go(1--Cw-8Vx$gpsf}NZLz<2i#q>=8yAqsh-vONwwzQUTuneDDYpv+xCI5 zz4aCdv51E#s;y|2RWSA0YjnKW>pJ$Ar=MddGe0M<|D7}g*o({V>m854gXRvWj!h&5 zbRC#QR~@OlSs&@b#)F)ApL7X>f2u7K^r;=Al@4Y zU5h5Ro=Cz~q53K0(?_c>bq(sZih>P-x@>OF_?7XMx;-->U{D24n|zXDa$oKc(G4;r z%v2^rx5Zy@&54uxJ5gu_?Okt1g7qzq1?R?ki4lO430=O!4?1_r50sCg%@r(< z7)Hgt{rF=PoSFV!`*r9$@<6_J20kz3Uz7M`Q=bx{b313AS3AIzzn6B5mDV3IIrc^y z%;sr-I}nBBiU&w}yUw#>JEplewkYOTd@l~8cO+Cn#_rdBS_^<-8e*`>V-u-xnOIdXl`C~6y7IfX=~oOS`zkI@6<3pu%B&by zm7LL?Zpp#>@aVsotCor@d7k5`8JDX^^tH4+lF*A|^w)D(%Ym?7~CLsg*;$!G8Gi`f-yHY=MOvI1LyZ~0k6kUeL4W$WfnJ9$ zfl#+opmSCs^D4<4Oee#q!vq+8v1*E|84&*zK+Dk`0VdkQbj;AznGAIk>F00HoX-x$ z@>S`UgH4eDk_>^Jsq>6>)cO=~D(b}>-tF(exXOvF&TTXEf=xL;zInwH%i$7a4JxP- z)N|Cv`R+-IKj)th6D~Aib^cFOo7D#~r=~#|4uPAOJbNv**@r!7I3JoAh0FO8W#F?R z2cbd{oFd`z7sRowyfGTN$jyGgWX?k5k^_oV(Nk)n)h1skm-2jWDqe!_}mpRD^Tdde45du zU|G%=t%B*xe19432g`_WtUavqlA}n&dWxj?T~(Z?0Vbs5uVb6b2$7SvJA^sed?JhG z0~0@rXQJODs|!Ld2{Sd(HZ~4TNQyKoyYl47<2TaltsI8FlRkj?HNJff4%OWD1mbj^ zlSh!`%wdKv3Ok6_LuVToYldxUP*554zKkWI7;;dRm%fQbag)#i^e=n(szVEN+|}6Gd}KWp z3ME*3!2Xvsd(LHd$TctA%fh=D766_26&E(aM!%kJb{$vqW>z)F{nHR4KKrUjzrB1g zu$8^yW_p~xczTWWEq042T#PHYR(;*dU0ILEVw2HK114>JYu%-C%^`f!yOjxCkS#o1 zuWXxN6G13CV|0;~3tT#y_#Nlsxy`jh5l2pnA4jwMgGr6ab|EHH;=X913gGcl$2!iLL+;FpKTX%;AE;jgU4B0gmO9$Jw1A>+pPigRReayPZKu@(0g@$cHW}gZVTukoxG3|y7%!>$d z0oCS@lx~P1=@w>BD_uS=)uln92G0mEgx`$dC-K&}5|0VDxUx^sC^=-+)|@PfqJBsy z)0-Q(`rq?#T|GqY0GrH;TV69V`bNOH@CY1KScYY6Fl@zPvv9YRL9RmB+5pe-?a`e9LRcQ>h)LbE1)BGrI`w%n*aP6v&hbq-8Pzn8-!w$%xJ zp|n^+nXnP5Xk7U9Q08OwRl_{ZX`jLc^PQ2OBH`{gfZ{e-C0q_|)%9kS_DzBp%3|fl zMMUKe36kHm5ugdwawp_;yU1Z8wc1G5!y@z~4rUhRVWFl<7dzP8v}{ zcn(w+Z!;?=`QN>W3M@&m$=|kBg8ULw?D7h?UHM)zXssS*1}RU-V9hVc?}`Q+aBI9R}=qc6$2E zD<+BBOi#B?3j*6Xdw{yKz_$gvk8LSTfX$TriSi+G0rJE+rL9=5BdN`&0>$-+o_wQK$Q zaDCO5MUM@T?&ev04NP8^F?>{7A>`Cp*?S7cn?`!SQg+w1zo@dU{EWqH~xPy*)ZFn56hjhWQS*!G0LE+ZrW zRq9Z%=q00ZCu~RsTTz=6Ejf@8$*^?yEUU3-SR<{|=xfY99nTDCA6s@%-B9vhe?UgC zFhz$nTDki^CCx37f33#{Is!ji_J>&$_M?|4V)wnc(s3sjutdEa!*7lSda4T_26?UH zYVP0xe5qtuVtF77J0^4s73$WFsB~b_Cne+7w%ew`qbrOGx9#vj&%ODAYMmEY83fZb z5h)f-O-RWdnw;0=XSLKN#GdcTQUmXwYB&)zhkKL766P5YPY!mM?r3x|K1H}=x!(OH z$hue)08!c7zmPT6%N&a)YtWl1v9c{B6T~C~z@_IaSqrA5{!D6V5l~P*=60%0>Wf4D zD$TTwTnR_}3kh>xLL@_hRPgWX=iLKQonm2asb?p-7G1t~GDNi#+Gxf1K80|aa(=0F zK@VfjH6FnRU@+`RxTjXpN}}OuHdmcP>(%&4Bi4Nrthj4Y2mLIfIsTdetkb;pu&If- zvCy<>dPelRM-udTZ4S$kwS55Rk;r3xNG3Q+4~_>V&U<}6r?LBqI_K@j*ckvTVBWA9R{-cKyhiZ!o($E|bbiV6gMj$E=>f9(G`fy7! zvOiZaKsvHlk1Qb-hg$>vOk8dqfyf>B_^iWaDTA&GOWgMc6 z3oS#R@>J+Z*>_^tMp3MiiOTKH`$YHSn$v@`cTilJB{FM z!4ja}rB?!>h(HAV4JgPx#mM-`#H>(guPTW6n+pmz1@g``Xgzs=Z1@QA|x+B=I8aEtr7ZZM%FCJS|rF?L*g- z=^&r^-f!XWm#IBdJ<(0v5HbU>K5wS>HU?+$DjY`=}UL|o$ zMn@#M|8DCn@0d?L^AiV5nFEx?r%UZws_Pe-eDL|gCbuF*D#>lR)cF;}fKUNxOvIa` zO1zD4tVUUH=L%f>GEjkj9tSZ0Xc3RsR&E63%V<@!z#MV(YLhL44@+U+%51Zjo#^wn z#2JBibuLLFcV;&AH3pWrn5I*t2$Y~7#g(DPXO`}8rMQYiJ&H(!2OG)YHXu!unE`+I zaSsdYG=)7)4)qug6QpBopw($zc#gG|(r-gpCbg)lI;a0MVNn`@IUxXc=C z&9e+k8V>s?E~Xa|mKnPyUadY$J&b`0y4HlBlOmez~QhBkWZcs)TW|`y!q0Hr$ z3W9J3NRe(8_2Ot3)!L0<&(De}G^JDRR5#fn1Nm662%2G7%{0 zR+gFb3qpR2{zr(pJkP`BhJY7$NJXpaPeq&LHD9RREf0>~m8h8K2b}T25upd-rq=V0 z-6G>CTPzrt#F|m|FxP`+=tNlk`wc}}Qi3af#k9Exi}M1obC zA{}WK?KQT+Ydv~BKht#2-~%!*z#&u48*YUQq8EWyHq`lIxuzyS_yN1N0Bz@Q*_* z*y{gRUx=?$r>J2pCQc*bXc~a|V)u%tn&jx6cDZq2%O`xSyWy{P6&Ls9ZQmACJujE_ ztM~8QqwEOcR@K)VO-}%i!NtW_`-)@+#ta3BRZWMzW|-IR};Q)JGl#Fy;br&<2&ipXq=UykcCREXw#V zN8@Yus8mzQCd9YpD1Pa>q~dDO4v_h2qyF=sX&P(ORt**NrP3z;Jz@hH7Z1okR*iRzb!;u65N8IUFewrnbjPcw{dyS9w=Jo3En!LqteBYP+G7=U<8$fX$Uo#n_Y5&y& z1e9lu?g#y7mX!0&F2YZzN4+(duy7h4rJV$k zsUVM1Bv~9!0^)NW_c9Uhi~gn)k|WB2br#-#^n~THi!eK9vE_88MpomCJ<*owS^}LH zDwPd0RRjW8Aw~WkfNo^N*<`gt=1Hq<2q=y95DZmTA(y|v#zo?Ta%_G{v9h(H-Yypx z(AiRo!Iq>}weIS#)Q=mp$b{>fVcuzsAs&6D!DlS(1}lfY4F6iHk&-ODVd2GT55~n~ zP(A!Hi=f8w5?2}eISwG&Y{um4*VTJF-^Vv|&Z$)Z}2yk*euBc68%8y5`17Of_vwy)cd7JW|XsE<62S z|I?FfBgK)H`G_BDd~A#x%&e39$;_=cedyP@GPmc4@3P2(4SPv7W1EUfU-t38bb%4 zUab=#x|aj@m49B`QD5+Mh$++>{Cfk}VQw9Q7$q~M4>)oAigem6G zqut;(1TDzjRl-hj8Uns1@}eM!z@K#Wb7CHv4*OT|EmS0uFLYm=+XNPM!WUkxbA3I* zII(bM-ciHAUCUMgR2eOI>ZOZBf8yno`0XgZ3_{$6pp)*~n4IqGU!=_5#;jfIS*_!( zQ5r6%r1gtqkDY0dT0M!VYUEfE%cGEWWA`y0CCFE(6bQte1mhYSj2q~n)x!vJyA=Up zI$%yv6NkEUl=Pw%rXRm^hNq9{sj#K@uMW%gr2~?v=sk~1Q*40v)ZQ`JmBuK0cj|ij zato3SeDH(ymTn+c^ci#?Ve8y}*Hg&gO#F(bz)ok(bEe6)u%Cuc|VZwQ8x!_4~L55#6%#&)y}bmcW!o6g(DxTI6OG7qVwcRI(A z^nD1Vf(e3 zPRkRys+|1oR>my_6V+EW1C_ZxgkGP}!r9s5Ep2}vS`LfPx4IV>ri%=M-AIICMOkwH z@kslYe&;{%qx)BUpV_uKPi1>1_f+DN9}`Ncc+S0O;HaMk$s5nPA1_*ue>`>`=NACk zMC`7EQMSP3K_dYCF5O)#<|J7wjL+~L4%knJl}lh2DlI~)Y8q5a;ltr!3hVPF;eDgu zjHcfVEO|~)&NDqUF`oTO_PQXl35h6DXzf{+D}I$db;%X!LAkm;Q)d3jzpBoS!=m?l zaS*_|3r!qurx4u}#kMeQ4kMi1Q49M5nPFl;H-FDRBut9p57nKh?3VC@rFFS^RT}qE zT9s5krW53;>aVI(?$=y!sAJ+&!sQC~Mc-g#T#a*1%Y(k*2|HB*qp32w#*{39%}!G* zpCKo>&uQQ-78>>-Qc~Icvt7c0LVoHD+Kg_% zXtSS^1lb27Zjf7mx(iHsZ9G38PmrNZ@{P3(JGu`#`G0b!!^r4kbA)kx|-!Q z`qN2o{Fi4NMOFeq4&-H#?(XS>uM;#sPGLF2Cm@oE)09LQ4DPur?rk?k80*CI?P91D z(c9bdZFs7;yJ<&KX3i6e+GY*S=71A-n<-P6nkJzC4M7yKMuXjGgk+aeJ&(QwI%6CM z+AA?Q7f7vVDY~Ew7kmjk&I7#BKyT|ATr=l#xU!Tx6>`bAu&k zKxTE+uq|6}g{lOimOD*kXQG!#j|^ylGKE_?GDj$PRn8Px8uEK>71hHMetP0Uoq1$G zELSBltVC+7;`_4Boo+l407pQ$zuxhTu~+l34{Dj}A8ia`PWue<(oP7;1|z)&SPVTT zpv11K0@yPDkEmQ}*#y%?F&{!IZ7U5R)dQ9uHu$Kd=W3Te%lEK#F+9BVxwYV#dWcH# zpA5kj26>lzrmlB0$D0(zI?3*ydN)FzgL^Lc~ z;nYwtT0uttz_obBMZ+h}Wqwtog-Af7fW{5o(q@LeSLt|Q_l4K>DHCpqB2oC3)&UfVFA!B`@SvAv+9NXon#}dXpO;Nxljcg~ zZ{0lOLJe^&m?NFm>d0!|G1vxwtJylPDH)z6BXnXU!BP83kqYBi8Hit=3{69@pR#kI zs7zY>P=I~Fc5k8a1rWZrmmFiDwb5Moe}Pv_)b_v z-!|+Wr$azH_U;u5;;+h z@xB58hBouAtAMCAY_!xDShx1q0ei-KWeSUm#5=)Sma^(|Qx~B%STH+U?O=h;!%&W7-=TDRwL&WEp~G-q3(qWh%tD308+~d(!g5}^U<{4?j={-$0m@GhI_NLCI;gA- zUSo?w*E)e8A;TOlQa~S_{NOMYd*iR}!_uJ9I5wa7Kfn!D!Z7NP{%am||KHQEvlY^TwLi)d>F$(<~Pne{$`}`P7Jo!OJb*>Sf4@x97h=? zWsI7(_vfNrxUPdadSsr8MM=viaV$L!c}YbEX>xk1W9&f>&6h8?+Xk7o z47=FYA>PnO-+`|BFO6ZwMC@?&E#_jNgf!Ukx(b!)i*X8Z3_)gh(Ddp^RdYDZV4$3B zQPA74lXYp4u+yJ#T!x`7YU6{?4=Y@c2c6{F*LqTw-`Q5k$j4R#<-i4wb?G!87ta}P z-V5w?I(qS@iZuAwOUsj-FWdtDw`gToC`L%;6`f^qV6XcMsX)Ih(6~FAzS za*7c%yK#9XK=`?#GR7$#06F4Q;t~C{s{g#GO&IS=f91wILAD_yHeL7w!~xx1-WI)R zpdM+j&K>Y14&J0%eoN$`lSo3%Ah z5et6O4GP*kv-oIPw~tEM{GVDH3tNtHr$3t(~bMA?Vcc4_y^`)EUptMTu(iS-uUA8$8cUlVOb4ya9pM_JsLOptYN0|qX{ zj&Bps(K$Kdv%%ma@cb}ctzs7L)2LzT2b6TPUR;vsi>5(CTnJYriCh4wKhm%h9S%ik zg?Kvg?aEWUjHvGd+vel4w>hMLkwXvV+xLVaq0UOu1ozbd?pHwuSx=b)v>5+`J(j3> zA~wUtP!CkxM6yt$^p-FLFz1m`%ZdZEx5+9>Nw4a0(iacoq@V%HWt)81l?}t1$jGXl zaLouLjB6FdpI7SF1v@A4Vj~?sq3d92K<7$^J09~+d3GQ?*VPW+YeS&2PLqYV)@=q2 z{PCLA%F=^?|A!&ZZvsO#BM%=Oo&q_+)h^CyvNC|Kaet$@z)86nyf{69X89ZV7?_>z zex~*%Y9$W=8Blxy-mOMx@tsWMYw0EOmSu6fpb%DVHVZ@;0vd-U5u8)xL}*PHhoEXv1pOc1Qi%uST5mx zS@D5oMkx5`DgLj0sD4bS?_E`-Hl2sD#OEaor%|cG`Fm@o zR4XP|O2#>qnaWXb=%<&qC=blVv8s)gLC2e`8~W}movd6e8SE81tN6`qrD(T~!eANB zIw_~Lwk7RaXMQ+aCP#sQ@b82pJ$n$$8%ljLm_$yn4OL){O5@vQ zZkp8g9LEmzAHJGFe+2I1eIV_j;+}sSLv3buipZdNaQQMnJb&N|%uHtj$mF;A$UI7g zH0@V$c_BZemRfO( zsW&OBZuFg5IwEbEUCYiCN^@D@5JxSfyGZyhu(hWH4A#t@S*Cp|HM{`X25SM)?vpXC z)pe5x2|sssiRhRmL41d*B5xc={doBLY^P@QQ5O<3+~NHdxNA-Q0khuC^6kLw*(9<# z*}51uu~i^RB;WFY*Vs>=JLlwSFBq6?6uN{2oE92#zVt*Ci&;614dTu4-V~r9Px!*8 z2~7IcvjeZh`gg8JSHU!;X$D?N4+Qs@Tb!2<4Fvddm}9hKgXl#ZeMKj85@!a-C`_Yy4V3#cw=eDwd-k)=F< zNrE1(3NWW5dpZ@FoYE(wYPOm!w=0))(AQx>7{d}S?D2ySI!+DhRooR^ZSe&4&OU_F zT(`$~pY`@sD_k&OJMvpc^0mY!`x>W7(jJosP<(a94CEa-q^5`}I1d5yfSaC?|6-r| zvFm(u(E8o*ar`YCOgGY|LP!EhcQP?*N3$Y@8I-KY#tEcP||)FW4yPfCq1eQ$7DW6v4#SHWyeRYr_uOE znT_Mi!U2R~Bl|vB1c0q{Hv7d>==`3Zcl+uKt${~0wyggvkzwFQ%+|>aa@Q6KVQvYs z#s#YO{tVbG0z%7mck)^y03(3Sfk7d_(!Gau6R85b{N#iDP62D28q1 ziWYnKRmNOCeI8X^KX?}PD3M`&?c%B0?FJH>VTk{n>vT#_{{)W^kJHJi0AgVxS%KZQ z7{I_d;qH_fQy7Gczg`8e!W-hhW$w#4E*N9@o4PWezfIcMx+yJNiBanbayWfPQ(mH6 zmo=`8W)mmbSCHi}*W;&0h);wK*Ag)gO@`ihsh_*n#3yq?b^XCf-< zO|$KSHwX2cY}HKrWW4?kK|IJ!7r+nW^|5)MibX`B7Q_vUgxPl7i8K_7N?rRP;cl%O z#EcWsKuwTd&U;X2y`)qK`@-j+o>zw8>s<6S$!V8wRXoebWkGulY#h?WRqMUrM*GXS zZ@u7&Y7^<~l?TFz`-Cqaf#Rs`?;MvKRe$XM27U1?OVF*Hq$Uubr7lx~9JM;`&r#7!*hKI!RG@i(zwO9O2Vf^U4p`IZPq8vjO z5neH}rS2JURH*8l(S1Pql%hO@fnn1cdP1AuMqb{hO2FZcCFn1mOb9tR;>8d+y#Jt+ z;$N7ftSmVR(-&&L(I?XGQT6Uv@z8ar` zYTxD_XulL4Y)K1JxDpxP>yHsE$p}H{ijWH29DRmy`boaULsXMDWJ*GJ!cPhGfG-r@>2tfo8a5O);iqOhMk0XqNA}S`X^m zn_&ItygT0MjIYAaP5pQ!9R(1S538{T6dC~HN4+@t5LR_aI?B;KXn>7T-*;uxIiSv- z8^Cd9KziUwEON>n=trWvFx$4HVr-s=lmaspmbm6Q^zc-Mvaz$t@1osx2nbfmMKYZ9 z@5&mE7BMJ2*IXCDFmoHnevuuw6}{L2k!@2TG3TcYFw)bbe(PmWa2D{EJ2a%zNd7>{ zzUWlBO8-C!|0kY>C1(66lTQ$4ve_&QR_ZuT))QxjPyb%XALGJ?EWpp}w4V<#X_gu0 z`V&c8iryP_?O^5fb+buoqkVKVYz>6=Lr<4&ern32@>bHLTJZ1H!y!YZrGUou8@vZk z8R~I;z&m#k3eN0>uu(ySJgfA{d4bei$>>IBsbMhHpqN!KB>#M;xi zMQ>9J&}uMh3#{;VkIY|79VrG3p3DfqLeZ`HXwW)UHP=svMGX2zm3`Pjc`rm>h@NwO z<<Gg9PE%WC(|p*GSq;OCS|@}BG_ zE)lhwrH641KNA>Fg`js9gw4IqPLbX>?_mr5w7dY;Ag?oYVF zyS?H*P4_8SQ8igUPsHuaap3k4|qvU}01125E$uJJ71-!_a5=Cin&$ zYs`kq(mxUCs%E8H_ZDQu>Mv&o5uAHw%eZ}FEJZX7?0 z?Fd6GKAXhCjMXSCWj>$#-N{gh&W`6s@k+_-)Yb^`0#|$7yS*@p?8Lo2@XfYfRFx5I z^<|fJa4C+}e`(Hy{l~f?sP+}TYsyhhLkIb*Mbp{CoeCZpy~#)$RDE;0xeE(}5H+2P zc6!EMV*F4N7!O_M_=+uJ7B z{W;h;4H?g8G-PcB+U%pW3Ib|D8@#Cdq!zW`ZJem^$e5VdL*lWwCPpizpK+l!atB63 z=tv!!c76=|x%}Sak$z&nVLV9yf&a1h|kLn-Q#QPk8ZBFWXC#ZNhO97;;qnKi@QiX_l$R^m@r>9Y;(3Oe6 zPv6M2F5k{+1k-wFbmrdi!#2x9N+ckrtJr8O$VH^hwy)$yf2G^arSYyG`;~HE_69k; z>X=KZFDjxoB>vLy>Yu7Wb$6vCdWEAUKl+?$&0tB9Ydm#AE>ys>mR2tuoOY9 zhoqjL630CwqzyI-&ntp4X;pVhm{^H`F?q*s6Ml=2dLu^S7AC<*YEk|>;kQ6NpD9Ld zPPY9j-g>wlNZjyhJ@H4#ZxhB{MOqTF3u;z9(i6xeXu1f< zwB^%p9GVXcU3M*!%fM~3>)mHs(cFaZw)HRsx{FB)s`sh9v9@Z-D{1~xfp_P zt!@m{H1f)I0+DpX>N514=nNCVxYIE5G$MDJm%`UOmb9V496up3Tv}BdKlCELoJdW_l-eyo3y1 zY+6LA6D|Z($efxvD!E*yLKXdph7C91!gaH)18LQv_h*M+%V~6B(?IQFCN6l8Zi>IH z17&5?$GQ89;xwM#FlS2`)X>nvY(^feG=5E?ue%4p-u&>V`I$Pl%1kXC)nW;rV^4IZ ziiilf&)8KdJw6a0pq=Wi&Q8$2U&(Rey+0|cjbUZ`+(7B=@_{{f3apc4`D>p*N;yk$b;~wwU=N+TkX>WQevX6y zq@H0K=7C93Ki$h3J5wBY_8$;g8S^n3M3qUDOEmqRrb!~+nzP{vKf5%7bC~`C8t4#B ztvjzZc{wQI0#%ImpWuy|B?Tln^U0{J^_`yp1POBG_Ip=GT;Si+b@DIdJ+! zDGXw7&UviV@a9_mvNmJMesj`t`E#57XAVVmCl)e` ~rsjR1Y={RR9Y4uYb(MB97 z%hLGXMJ`vl`>LC`@iVrLaxd2xseR5rJ+IB~2BYe<^5k2%W#rjK} znbB#R6s|x$y+{dmKBk@h(8=9BTdP$ELZThERoGGl=l2j94uSdp2+JR$icl*bb}%my zu$?Z8jCC0JK}quQt4Ugy-7VvL;IBf$%d^>%0 zcq#W_g^ip+Ja|iFGJa6>i8&|fMb;DixgW0s@6gLMNTh*?e>$S(=>|fx-I}&Q65Zw6 zJ1SHjs5Ia0OX`oO0Z4>#Iqn4d`A;3W3^D^>lk0rr_>#d2mumOQI~ANqV|oL%3bT zwf`|0=!QoS9*?@4_lG23bmMh)qxlYrzEgM&1759Kn~?0@Fo}7XA=ph+(-Kssa#aU% z)Fe#(zV_D{Ox(X-KL!U-a^W5>rh4EhEOhJUY9gp|+>5XUP@xw`r4$|&9=tXSbnt`l zP5>?hdtTrAZpWy8nYlShTRyn*%8?zre!46g@lP|$#2CkXAU?@HYBU_lk>{jpC2{($ z`l$+wWwS%xI@`nOan)ni44ZDy{-UmBo!&s1up&YSxOO0+>Eqxguq|+ zxr6SL=c$Xd9oD%YyQnP1{Q)Atyc8ZYA^~4+z*dZ$%-X=Q*^-v(i%>W-h%4#;g8ruF zwOa2y(k;bA|46mSW&6~$+xY8UG-}s*5m$O$GOk5Piuc6BLKya>(`SgzXo9XH9DGRJr4><3 zzY@Y@u(~d`6Q}#!>D#MN`uK>D{0y-@ouqJ4Qz+YR4%5rSboNn0TbGcH= zzi*^>NzOm*x(v{`O8fWXl$Rz4M<8*8Qpg&JN$c6SH?!l!b(H?q?EgiBM`=!RJ2H!0 zzi4{yK}-%eqc5^bg$om;c5{5?OYdI7qZNEVNUyLbq9r&`UuD5%TpA{SZr^gk3S&S9 zO)3UR+mzyKK*|h@bdRgEZsuu$}Ah309q znpHKZvEt9xZQM@S*faj6)W$xkR&@QOM9%B)-xJF^f5>JR!9_%7u<}uEnkd`22^=XL z1E(FPsQ63>C-npd6<|hg@}M=sRLz|>6yl}7#!t4u15GtgSg1sZI8;$ps2rrfoN9Tb ziwZU+CsC!D+p3)ecPpGr>hn0|aL3#)EI*;q>uE4HxE-}9XLA7}+vhgDuv8>IN z9sW_h$&uhrf2>(TqNRpRxw++$TCxe&xDZnMg!U~5TT9w=M1+1*=-ta{F`re3l#dI^ zA10EF3US7FgOZ8|RsxD{Q z{Vx=GVF-2w@pbTPs+aKfFmpx;@wJ8{VcDP)YzPLQ&Y=alYTQ>^89O z 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
nBsOdxPKhR3D|7Sy4c01y$g~vrS7#X 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 diff --git a/public/promo_loading_image_full_bot.avif b/public/promo_loading_image_full_bot.avif deleted file mode 100644 index 49813140acf6621c4298c0ba1a1a508dbba64ab0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 243804 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 JhWU?oB+nLE##{(3ooTxZR; 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

    dtAuD>MXK3EqE@CxRt%6xoCFXx(Hv_Xxa89 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⋘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⪙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%0Ab`6NsvUZJuY$}EV*WEhyY*RuED*x*;NWb@e_0` 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 KbxAu0Z_BM+}JN-d2sC7TDK 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

    {`yWQ;rAYcyUc}aod0Gr z6?qR9-_O~GyFEP0t+PuprkWn2tfi63Q|&K~lA_-{EL<;K^G@9>vD%YdI5Ni|fiA|7 zwJ*+RY>2R}C;w5tn)TV`!4b$Va+QF2%|Wu&1irHJlgG#WOR0Pf1xDU7f*@Pqi^~X$ zTXZPduk-XD)d)U6H7GuRXoRY^=N7y$7*)~;>&M^7^V1-L=AdMNfYL`Dc{-^l>Q+TY znwK?Ax4v*w$UHh3f8CA;b>cd0sWD{>5y@tl3d-vq4HFpvy`rRZ1gdx+hFBo$EsXow z*%#PvwtptJJI9MmQ9LQ9xZqQwuC$SNUOQZ7++13CxI9-7IkY>+8Xj2G>-%N#Kro^@ z1ns7sK+ow43WRDTiE-ZwMUPKs!n$O<)8LJV()`hx?nlDmyHN}^E04k3aj@hHL zv^3y3trt>ox(KNx<^7wsQe#&0g27uV=nHW4dEBy#4*08>iQ#yFM(e0V1(sxuGD!2I zWvu+3N+pV5|4tPz=Pv$(_fP)Ky|Fk?hDh68Hrag$9BxHhrgPfg_P_OgS{&CH_3Z3dD!Zgnk)_=D*ugKn-=a@Xo?p=9VeL6(d${ z>697gzBox`P4GFvp2oV~N$fjkh0+i7M;i`_?n>7egj}c2!l^lJhL&(-7IwWGVnQ&4 zi}N_3SaZogl}N%LeN-g#)w4|$**QUzRfvI(kb^&_K>XK)(>U^B-(a6JWL$}baT7t}Aa*^CD-QlJnxvG{|uGjfp6m4zx3oj7}HiG;P`~2?YP!e#I4eruuL8eD@ zf%U-k0C+|zQE&R=D6y{~FZLr49myM88uM)RiQQpP z;HSx2v}=MJMol>NaA@z+U9kKW9(hMP}Eo;?$x1)JDA zT=u`LX!OL6p9{%d^UpJ$K@T@8#FEm+WJ6CKakFC#QfbR|tJm~t7xqxTl2r+W(KN?v zmI7C%F6cQCfNI3oI+1JC3m1;MrO!!VJ%LkBGxx$@D1PuEQfw}eY=XiY+7|e^Y z8rXqc|C?j)RqhbY@-bebi96qR6rKlh6+>nQyjDL#?nQtjG+||J-K731Ud-yw*Q0uH76yQKWgCOJmpg3U6=PQLKbe!2E$MZY3rCcXj&vYpZZyfXac_({<3p1dlF zO+Hg`W%VqVm&Xm+HlVwzb^t@V-g4ghXr6`tq0$|(H;h#0K)5%2AWDj0 zEmp#_d#liJ&{0e4TIFb2o_#Dw3gpCh)YjLPH`;CbU9qtuSCo(BO zfo}s^&Qxd`7*^1+R3$ce(j3 zcies(@XfIr*#X^Jpo$O~6heb{tfh>d^TTu831(IHC|W|x53_F%Ir{@HC1(FyA^#+y|nTWZ4vp%b^I1MH;Y7Ip_YR1dIHsCIzsRTQ-DWm$61Lj7!vux&G@T zfzFetT2<@xAwK|t0_dLDiPF3AOMV^bhZ9SfxFSZ71uW*y`Vim0AgLDyAc{&)_VdB( zc1F`hHo+x&-&ne1$(z7>|)sB?;;b6B+XvS#E zZ)1T+L&t+ao9$n!>4w|;osV*dwB*rktuQmmnhiYQi9P1c4-NNHas$-!ur|Y*H(b+Z zylws?#*LTyBz86y**h}1+n6m(z?}IOm%w!$Qbn9X>Bh%PuxFU5B*GK0cI(X$9cb~$ z{mha6l=Tjf%*M)3vw*Y}6z%g~%L&pntZXmp4|~sxSAaRr>?lxg3;KA}G~J1OEGYuP zA@&b6o*1zaJw*Ie#qWnu=p5p8Xlm(SwO`?H)7j&i+$aNy3=5GLKlSu_rq1}8+t+OH zb!C8(*7yx3e!{{p8FI>0MxxBWOj~A{z^q^1a0FG1`aYG;Bcos;wOd#u<;O!{Ty~8! z91L~a02?4ewj{~+jc310fH-HX@A2@t0V_5wf97W4vwFOqcCYI^*r5JfQVVvs8)kcO mVy0i$mbHM6mch01LbNVRN!kJIl}z*(1>a From ca75d18e41e604d7a3e5b2d182b41e209283f39e Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 13:12:35 -0700 Subject: [PATCH 19/83] fix coverage --- .../seeders/demo_account_seeder_spec.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 spec/mutations/devices/seeders/demo_account_seeder_spec.rb diff --git a/spec/mutations/devices/seeders/demo_account_seeder_spec.rb b/spec/mutations/devices/seeders/demo_account_seeder_spec.rb new file mode 100644 index 0000000000..f558836aea --- /dev/null +++ b/spec/mutations/devices/seeders/demo_account_seeder_spec.rb @@ -0,0 +1,19 @@ +require "spec_helper" + +describe Devices::Seeders::DemoAccountSeeder do + let(:device) { FactoryBot.create(:device) } + + it "uses stress data for stress demo product lines" do + stress_data = instance_double(Devices::Seeders::StressData) + + expect(Devices::Seeders::StressData) + .to receive(:new) + .with(device, 250) + .and_return(stress_data) + expect(stress_data).to receive(:seed!) + + described_class + .new(device) + .after_product_line_seeder("genesis_xl_1.8_stress_250") + end +end From aeec9e7002a531e6839ad358a0c9e639fbd9cc0a Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 24 Apr 2026 13:26:34 -0700 Subject: [PATCH 20/83] increase ci fps check time --- scripts/fps.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/fps.js b/scripts/fps.js index b503de4dca..8e3b4514de 100644 --- a/scripts/fps.js +++ b/scripts/fps.js @@ -25,8 +25,8 @@ async function main() { }); await page.waitForFunction(() => typeof window.__fps !== 'undefined'); - const samples = 10; - const takeSample = 5; + const samples = 20; + const takeSample = 15; let lastSample = 0; let validCount = 0; for (let i = 0; i < samples; i++) { From 1d76da42384af9ab53ff17f712aae9fe0542c027 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 14:28:00 -0700 Subject: [PATCH 21/83] Improve 3D designer perf instrumentation --- .../__tests__/three_d_garden_map_test.tsx | 11 ++ frontend/farm_designer/three_d_garden_map.tsx | 5 + .../__tests__/plant_inventory_item_test.tsx | 15 ++ frontend/plants/plant_inventory_item.tsx | 9 +- .../__tests__/point_inventory_item_test.tsx | 15 +- frontend/points/point_inventory_item.tsx | 8 +- .../three_d_garden/__tests__/index_test.tsx | 14 ++ frontend/three_d_garden/index.tsx | 3 + .../__tests__/weed_inventory_item_test.tsx | 10 ++ frontend/weeds/weed_inventory_item.tsx | 8 +- scripts/perf/stress_1000_3d.js | 137 +++++++++++++++++- 11 files changed, 219 insertions(+), 16 deletions(-) diff --git a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx index 2253080d27..bdc2a45c0d 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -20,6 +20,8 @@ let threeDGardenSpy: jest.SpyInstance; let getPositionSpy: jest.SpyInstance; beforeEach(() => { + window.localStorage.clear(); + delete window.__fbPerf; threeDGardenSpy = jest.spyOn(threeDGarden, "ThreeDGarden") .mockImplementation(jest.fn(() =>

    ) as never); getPositionSpy = jest.spyOn(suncalc, "getPosition").mockReturnValue({ @@ -29,6 +31,8 @@ beforeEach(() => { }); afterEach(() => { + window.localStorage.clear(); + delete window.__fbPerf; threeDGardenSpy.mockRestore(); getPositionSpy.mockRestore(); }); @@ -171,6 +175,13 @@ describe("", () => { })); }); + it("counts benchmark renders", () => { + window.localStorage.setItem("FB_PERF_BENCHMARK", "true"); + render(); + expect(window.__fbPerf?.counts["render.ThreeDGardenMap"]).toEqual(1); + expect(window.__fbPerf?.marks.three_d_map_mounted.length).toEqual(1); + }); + it("keeps garden config stable across bot position updates", () => { const p = fakeProps(); const { rerender } = render(); diff --git a/frontend/farm_designer/three_d_garden_map.tsx b/frontend/farm_designer/three_d_garden_map.tsx index 3f9ebe548f..14d9d55095 100644 --- a/frontend/farm_designer/three_d_garden_map.tsx +++ b/frontend/farm_designer/three_d_garden_map.tsx @@ -26,6 +26,7 @@ import { parseCalibrationData } from "./map/layers/images/map_image"; import { fetchInterpolationOptions } from "./map/layers/points/interpolation_map"; import { unpackUUID } from "../util"; import { isTopDown } from "../three_d_garden/helpers"; +import { perfMark, usePerfRenderCount } from "../performance/perf"; export interface ThreeDGardenMapProps { botSize: BotSize; @@ -57,6 +58,10 @@ export interface ThreeDGardenMapProps { } export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { + usePerfRenderCount("ThreeDGardenMap"); + React.useEffect(() => { + perfMark("three_d_map_mounted"); + }, []); const { gridSize } = props.mapTransformProps; const getValue = props.get3DConfigValue; const { designer } = props; diff --git a/frontend/plants/__tests__/plant_inventory_item_test.tsx b/frontend/plants/__tests__/plant_inventory_item_test.tsx index 84ab0ee241..03e17c568d 100644 --- a/frontend/plants/__tests__/plant_inventory_item_test.tsx +++ b/frontend/plants/__tests__/plant_inventory_item_test.tsx @@ -24,6 +24,8 @@ describe("", () => { let selectPointSpy: jest.SpyInstance; beforeEach(() => { + window.localStorage.clear(); + delete window.__fbPerf; mapPointClickActionSpy = jest.spyOn(mapActions, "mapPointClickAction") .mockImplementation(jest.fn(() => jest.fn())); setHoveredPlantSpy = jest.spyOn(mapActions, "setHoveredPlant") @@ -33,6 +35,8 @@ describe("", () => { }); afterEach(() => { + window.localStorage.clear(); + delete window.__fbPerf; mapPointClickActionSpy.mockRestore(); setHoveredPlantSpy.mockRestore(); selectPointSpy.mockRestore(); @@ -76,6 +80,14 @@ describe("", () => { expect(item(container).classList.contains("hovered")).toBeTruthy(); }); + it("skips unchanged benchmark rerenders", () => { + window.localStorage.setItem("FB_PERF_BENCHMARK", "true"); + const p = fakeProps(); + const { rerender } = render(); + rerender(); + expect(window.__fbPerf?.counts["render.PlantInventoryItem"]).toEqual(1); + }); + it("hover begin", () => { const p = fakeProps(); const { container } = render(); @@ -90,6 +102,7 @@ describe("", () => { }); it("selects plant", () => { + window.localStorage.setItem("FB_PERF_BENCHMARK", "true"); location.pathname = Path.mock(Path.plants()); const p = fakeProps(); const { container } = render(); @@ -97,6 +110,8 @@ describe("", () => { expect(mapPointClickAction).not.toHaveBeenCalled(); expect(selectPoint).toHaveBeenCalledWith([p.plant.uuid]); expect(mockNavigate).toHaveBeenCalledWith(Path.plants(p.plant.body.id)); + expect(window.__fbPerf?.marks.plant_inventory_item_click.length) + .toEqual(1); }); it("handles missing plant id", () => { diff --git a/frontend/plants/plant_inventory_item.tsx b/frontend/plants/plant_inventory_item.tsx index ca66d86ddb..c81c0864ba 100644 --- a/frontend/plants/plant_inventory_item.tsx +++ b/frontend/plants/plant_inventory_item.tsx @@ -11,7 +11,7 @@ import { isUndefined, round } from "lodash"; import { Path } from "../internal_urls"; import { useNavigate } from "react-router"; import { findIcon } from "../crops/find"; -import { usePerfRenderCount } from "../performance/perf"; +import { perfMark, usePerfRenderCount } from "../performance/perf"; export interface PlantInventoryItemProps { plant: TaggedPlant; @@ -21,7 +21,7 @@ export interface PlantInventoryItemProps { } // The individual plants that show up in the farm designer sub nav. -export const PlantInventoryItem = (props: PlantInventoryItemProps) => { +export const PlantInventoryItem = React.memo((props: PlantInventoryItemProps) => { usePerfRenderCount("PlantInventoryItem"); const navigate = useNavigate(); @@ -35,6 +35,7 @@ export const PlantInventoryItem = (props: PlantInventoryItemProps) => { }; const click = () => { + perfMark("plant_inventory_item_click"); if (getMode() == Mode.boxSelect) { mapPointClickAction(navigate, dispatch, plant.uuid)(); toggle("leave"); @@ -72,7 +73,9 @@ export const PlantInventoryItem = (props: PlantInventoryItemProps) => { : daysOldText(plantAgeAndStage(plant))}
    ; -}; +}); + +PlantInventoryItem.displayName = "PlantInventoryItem"; export const daysOldText = ({ age, stage }: PlantStageAndAge) => { if (isUndefined(age)) { return "" + stage; } diff --git a/frontend/points/__tests__/point_inventory_item_test.tsx b/frontend/points/__tests__/point_inventory_item_test.tsx index cf86932118..8e97698dfd 100644 --- a/frontend/points/__tests__/point_inventory_item_test.tsx +++ b/frontend/points/__tests__/point_inventory_item_test.tsx @@ -13,6 +13,8 @@ import { Path } from "../../internal_urls"; import * as devSupport from "../../settings/dev/dev_support"; beforeEach(() => { + window.localStorage.clear(); + delete window.__fbPerf; jest.spyOn(mapActions, "mapPointClickAction") .mockImplementation(jest.fn(() => jest.fn())); jest.spyOn(devSupport.DevSettings, "quickDeleteEnabled") @@ -20,6 +22,12 @@ beforeEach(() => { jest.spyOn(crud, "destroy").mockImplementation(jest.fn()); }); +afterEach(() => { + window.localStorage.clear(); + delete window.__fbPerf; + mockDelMode = false; +}); + describe(" />", () => { const fakeProps = (): PointInventoryItemProps => ({ @@ -75,6 +83,7 @@ describe(" />", () => { }); it("navigates to point", () => { + window.localStorage.setItem("FB_PERF_BENCHMARK", "true"); location.pathname = Path.mock(Path.points()); const p = fakeProps(); p.tpp.body.id = 1; @@ -86,6 +95,8 @@ describe(" />", () => { type: Actions.TOGGLE_HOVERED_POINT, payload: p.tpp.uuid, }); + expect(window.__fbPerf?.marks.point_inventory_item_click.length) + .toEqual(1); }); it("navigates to point without id", () => { @@ -132,8 +143,8 @@ describe(" />", () => { const p = fakeProps(); p.hovered = true; const { container } = render(); - expect(container.querySelector(".point-search-item")?.classList.contains("hovered")) - .toBeTruthy(); + const item = container.querySelector(".point-search-item"); + expect(item?.classList.contains("hovered")).toBeTruthy(); }); it("un-hovers point", () => { diff --git a/frontend/points/point_inventory_item.tsx b/frontend/points/point_inventory_item.tsx index 551cb85852..6ae29f359c 100644 --- a/frontend/points/point_inventory_item.tsx +++ b/frontend/points/point_inventory_item.tsx @@ -11,6 +11,7 @@ import { destroy } from "../api/crud"; import { isUndefined, round } from "lodash"; import { Path } from "../internal_urls"; import { svgToUrl } from "../point_groups/point_group_item"; +import { perfMark } from "../performance/perf"; export interface PointInventoryItemProps { tpp: TaggedGenericPointer; @@ -42,7 +43,7 @@ const PointGraphic = ({ hovered, colorOverride, color }: PointGraphicProps) => { }; // The individual points that show up in the farm designer sub nav. -export const PointInventoryItem = (props: PointInventoryItemProps) => { +export const PointInventoryItem = React.memo((props: PointInventoryItemProps) => { const navigate = useNavigate(); const point = props.tpp.body; const color = point.meta.color || "green"; @@ -58,6 +59,7 @@ export const PointInventoryItem = (props: PointInventoryItemProps) => { }; const click = () => { + perfMark("point_inventory_item_click"); if (DevSettings.quickDeleteEnabled()) { dispatch(destroy(tpp.uuid, true)); return; @@ -89,4 +91,6 @@ export const PointInventoryItem = (props: PointInventoryItemProps) => { {` ${round(props.distance)}mm ${t("away")}`}}

; -}; +}); + +PointInventoryItem.displayName = "PointInventoryItem"; diff --git a/frontend/three_d_garden/__tests__/index_test.tsx b/frontend/three_d_garden/__tests__/index_test.tsx index 22eb2d6391..bc4472cca9 100644 --- a/frontend/three_d_garden/__tests__/index_test.tsx +++ b/frontend/three_d_garden/__tests__/index_test.tsx @@ -15,12 +15,19 @@ import { BooleanSetting } from "../../session_keys"; import { fakeDevice } from "../../__test_support__/resource_index_builder"; beforeEach(() => { + window.localStorage.clear(); + delete window.__fbPerf; jest.spyOn(configStorageActions, "getWebAppConfigValue") .mockImplementation(() => () => false); jest.spyOn(configStorageActions, "setWebAppConfigValue") .mockImplementation(jest.fn()); }); +afterEach(() => { + window.localStorage.clear(); + delete window.__fbPerf; +}); + describe("", () => { const fakeProps = (): ThreeDGardenProps => ({ config: clone(INITIAL), @@ -46,6 +53,13 @@ describe("", () => { undefined); canvasSpy.mockRestore(); }); + + it("counts benchmark renders", () => { + window.localStorage.setItem("FB_PERF_BENCHMARK", "true"); + render(); + expect(window.__fbPerf?.counts["render.ThreeDGarden"]).toEqual(1); + expect(window.__fbPerf?.marks.three_d_garden_mounted.length).toEqual(1); + }); }); describe("", () => { diff --git a/frontend/three_d_garden/index.tsx b/frontend/three_d_garden/index.tsx index eaf23aeed9..7aeb769d2e 100644 --- a/frontend/three_d_garden/index.tsx +++ b/frontend/three_d_garden/index.tsx @@ -47,6 +47,9 @@ export interface ThreeDGardenProps { export const ThreeDGarden = (props: ThreeDGardenProps) => { usePerfRenderCount("ThreeDGarden"); + React.useEffect(() => { + perfMark("three_d_garden_mounted"); + }, []); return
{ + window.localStorage.clear(); + delete window.__fbPerf; jest.clearAllMocks(); location.pathname = Path.mock(Path.weeds()); jest.spyOn(mapActions, "mapPointClickAction") @@ -22,6 +24,11 @@ beforeEach(() => { jest.spyOn(crud, "save").mockImplementation(jest.fn()); }); +afterEach(() => { + window.localStorage.clear(); + delete window.__fbPerf; +}); + describe(" />", () => { const fakeProps = (): WeedInventoryItemProps => ({ @@ -46,6 +53,7 @@ describe(" />", () => { }); it("navigates to weed", () => { + window.localStorage.setItem("FB_PERF_BENCHMARK", "true"); const p = fakeProps(); p.tpp.body.id = 1; const { container } = render(); @@ -56,6 +64,8 @@ describe(" />", () => { type: Actions.TOGGLE_HOVERED_POINT, payload: p.tpp.uuid, }); + expect(window.__fbPerf?.marks.weed_inventory_item_click.length) + .toEqual(1); }); it("navigates to weed without id", () => { diff --git a/frontend/weeds/weed_inventory_item.tsx b/frontend/weeds/weed_inventory_item.tsx index d3da001ae9..b80062300a 100644 --- a/frontend/weeds/weed_inventory_item.tsx +++ b/frontend/weeds/weed_inventory_item.tsx @@ -10,6 +10,7 @@ import { mapPointClickAction, selectPoint } from "../farm_designer/map/actions"; import { round } from "lodash"; import { edit, save, destroy } from "../api/crud"; import { FilePath, Path } from "../internal_urls"; +import { perfMark } from "../performance/perf"; export interface WeedInventoryItemProps { tpp: TaggedWeedPointer; @@ -19,7 +20,7 @@ export interface WeedInventoryItemProps { maxSize?: number; } -export const WeedInventoryItem = (props: WeedInventoryItemProps) => { +export const WeedInventoryItem = React.memo((props: WeedInventoryItemProps) => { const navigate = useNavigate(); const weed = props.tpp.body; const { tpp, dispatch } = props; @@ -37,6 +38,7 @@ export const WeedInventoryItem = (props: WeedInventoryItemProps) => { }; const click = () => { + perfMark("weed_inventory_item_click"); if (getMode() == Mode.boxSelect) { mapPointClickAction(navigate, dispatch, tpp.uuid)(); toggle("leave"); @@ -88,4 +90,6 @@ export const WeedInventoryItem = (props: WeedInventoryItemProps) => { {`(${round(weed.x)}, ${round(weed.y)}) r${round(weed.radius)}`}

; -}; +}); + +WeedInventoryItem.displayName = "WeedInventoryItem"; diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js index 75b62b336e..4e668c1f63 100644 --- a/scripts/perf/stress_1000_3d.js +++ b/scripts/perf/stress_1000_3d.js @@ -7,6 +7,8 @@ const DEFAULT_URL = "http://localhost:3000"; const PRODUCT_LINE = "genesis_xl_1.8_stress_1000"; const DEMO_USER = "farmbot_demo"; const TIMEOUT = 180_000; +const DEFAULT_VIEWPORT = { width: 3840, height: 2160 }; +const DEFAULT_SAMPLE_MS = 12_000; const parseArgs = () => { const [command = "run", ...rest] = process.argv.slice(2); @@ -32,6 +34,7 @@ const percentile = (values, p) => { const summary = runs => { const metric = key => median(runs.map(run => run[key])); return { + pageReadyMs: metric("pageReadyMs"), coreReadyMs: metric("coreReadyMs"), fullReadyMs: metric("fullReadyMs"), fpsMedian: metric("fpsMedian"), @@ -39,6 +42,15 @@ const summary = runs => { navPlantMs: metric("navPlantMs"), navPointMs: metric("navPointMs"), navWeedMs: metric("navWeedMs"), + togglePlantsMs: metric("togglePlantsMs"), + togglePointsMs: metric("togglePointsMs"), + toggleWeedsMs: metric("toggleWeedsMs"), + toggleSpreadMs: metric("toggleSpreadMs"), + toggleFarmbotMs: metric("toggleFarmbotMs"), + jsEncodedBytes: metric("jsEncodedBytes"), + jsTransferBytes: metric("jsTransferBytes"), + jsResourceCount: metric("jsResourceCount"), + threeDGardenMapRenders: metric("threeDGardenMapRenders"), gardenModelRenders: metric("gardenModelRenders"), threeDGardenRenders: metric("threeDGardenRenders"), plantInventoryItemRenders: metric("plantInventoryItemRenders"), @@ -59,6 +71,34 @@ const maxMark = (marks, names) => { return values.length > 0 ? Math.max(...values) : undefined; }; +const nextPaint = page => + page.evaluate(() => new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)))); + +const resourceSummary = async page => page.evaluate(() => { + const jsResources = performance.getEntriesByType("resource") + .filter(entry => entry.name.match(/\.js(\?|$)/)); + const sum = key => jsResources + .reduce((total, entry) => total + (entry[key] || 0), 0); + const largestJs = jsResources + .map(entry => ({ + name: entry.name.split("/").pop(), + transferSize: entry.transferSize || 0, + encodedBodySize: entry.encodedBodySize || 0, + decodedBodySize: entry.decodedBodySize || 0, + duration: entry.duration || 0, + })) + .sort((a, b) => b.encodedBodySize - a.encodedBodySize) + .slice(0, 10); + return { + jsResourceCount: jsResources.length, + jsTransferBytes: sum("transferSize"), + jsEncodedBytes: sum("encodedBodySize"), + jsDecodedBytes: sum("decodedBodySize"), + largestJs, + }; +}); + const createDemoSession = async (browser, baseUrl) => { const secret = crypto.randomUUID().replaceAll("-", ""); const page = await browser.newPage(); @@ -148,11 +188,39 @@ const waitFor3D = async page => { }); }; -const clickAndMeasure = async (page, route, itemSelector, panelSelector) => { +const waitForGardenRender = async (page, beforeRenderCount) => { + await page.waitForFunction(before => { + const count = window.__fbPerf?.counts?.["render.GardenModel"] || 0; + return count > before; + }, beforeRenderCount, { timeout: 10_000 }).catch(() => undefined); + await nextPaint(page); +}; + +const openSoilHeightSection = async page => { + const section = page.locator(".points-section-header") + .filter({ hasText: "Soil Height" }) + .first(); + if (await section.count() == 0) { return; } + await section.click(); + await page.locator(".point-search-item").first() + .waitFor({ timeout: 10_000 }).catch(() => undefined); +}; + +const clickAndMeasure = async ( + page, + route, + itemSelector, + panelSelector, + prepare, +) => { await page.goto(route, { waitUntil: "domcontentloaded" }); await waitFor3D(page); const item = page.locator(itemSelector).first(); - const count = await item.count(); + let count = await item.count(); + if (count == 0 && prepare) { + await prepare(page); + count = await item.count(); + } if (count == 0) { return undefined; } const startedAt = await page.evaluate(() => performance.now()); await item.click(); @@ -160,9 +228,30 @@ const clickAndMeasure = async (page, route, itemSelector, panelSelector) => { return page.evaluate(start => performance.now() - start, startedAt); }; -const collectRun = async (browser, baseUrl, session, runIndex) => { +const measureLayerToggle = async (page, labelText) => { + const toggle = page.locator("fieldset") + .filter({ hasText: labelText }) + .locator(".fb-layer-toggle") + .first(); + const count = await toggle.count(); + if (count == 0) { return undefined; } + const beforeRenderCount = await page.evaluate(() => + window.__fbPerf?.counts?.["render.GardenModel"] || 0); + const startedAt = await page.evaluate(() => performance.now()); + await toggle.click(); + await waitForGardenRender(page, beforeRenderCount); + const elapsed = await page.evaluate(start => performance.now() - start, + startedAt); + const beforeRestoreRenderCount = await page.evaluate(() => + window.__fbPerf?.counts?.["render.GardenModel"] || 0); + await toggle.click(); + await waitForGardenRender(page, beforeRestoreRenderCount); + return elapsed; +}; + +const collectRun = async (browser, baseUrl, session, runIndex, options) => { const context = await browser.newContext({ - viewport: { width: 1440, height: 1000 }, + viewport: options.viewport, }); await context.addInitScript(value => { window.localStorage.setItem("session", value.session); @@ -174,13 +263,20 @@ const collectRun = async (browser, baseUrl, session, runIndex) => { const appUrl = `${baseUrl}/app/designer/plants?fb_perf=1`; await page.goto(appUrl, { waitUntil: "domcontentloaded" }); await waitFor3D(page); - await page.waitForTimeout(12_000); + await nextPaint(page); + const resources = await resourceSummary(page); + await page.waitForTimeout(options.sampleMs); const perf = await page.evaluate(() => window.__fbPerf); const marks = perf?.marks || {}; const samples = perf?.samples || {}; const counts = perf?.counts || {}; const fpsSamples = samples.fps || []; const frameSamples = samples.frame_ms || []; + const togglePlantsMs = await measureLayerToggle(page, "Plants"); + const togglePointsMs = await measureLayerToggle(page, "Points"); + const toggleWeedsMs = await measureLayerToggle(page, "Weeds"); + const toggleSpreadMs = await measureLayerToggle(page, "Spread"); + const toggleFarmbotMs = await measureLayerToggle(page, "FarmBot"); const navPlantMs = await clickAndMeasure( page, `${baseUrl}/app/designer/plants?fb_perf=1`, @@ -192,6 +288,7 @@ const collectRun = async (browser, baseUrl, session, runIndex) => { `${baseUrl}/app/designer/points?fb_perf=1`, ".point-search-item", ".point-info-panel", + openSoilHeightSection, ); const navWeedMs = await clickAndMeasure( page, @@ -202,6 +299,11 @@ const collectRun = async (browser, baseUrl, session, runIndex) => { await context.close(); return { runIndex, + pageReadyMs: firstMark( + marks, + "three_d_map_mounted", + "three_d_garden_mounted", + ), coreReadyMs: firstMark( marks, "three_d_core_ready", @@ -210,8 +312,10 @@ const collectRun = async (browser, baseUrl, session, runIndex) => { fullReadyMs: maxMark(marks, [ "three_d_bot_ready", "three_d_bed_ready", - "three_d_clouds_ready", + "three_d_grid_ready", + "three_d_core_ready", "three_d_decorations_ready", + "three_d_details_ready", "three_d_visualizations_ready", "three_d_camera_ui_ready", "three_d_debug_ready", @@ -225,6 +329,13 @@ const collectRun = async (browser, baseUrl, session, runIndex) => { navPlantMs, navPointMs, navWeedMs, + togglePlantsMs, + togglePointsMs, + toggleWeedsMs, + toggleSpreadMs, + toggleFarmbotMs, + ...resources, + threeDGardenMapRenders: counts["render.ThreeDGardenMap"], gardenModelRenders: counts["render.GardenModel"], threeDGardenRenders: counts["render.ThreeDGarden"], plantInventoryItemRenders: counts["render.PlantInventoryItem"], @@ -237,12 +348,19 @@ const runBenchmark = async args => { const warmups = Number(args.warmups || 1); const out = args.out || "tmp/perf/stress_1000_3d.json"; const lowDetail = ["1", "true"].includes(args["low-detail"]); + const viewport = { + width: Number(args.width || DEFAULT_VIEWPORT.width), + height: Number(args.height || DEFAULT_VIEWPORT.height), + }; + const sampleMs = Number(args["sample-ms"] || DEFAULT_SAMPLE_MS); const browser = await chromium.launch({ headless: true, args: [ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", + "--disable-frame-rate-limit", + "--disable-gpu-vsync", "--enable-gpu", ], }); @@ -253,13 +371,18 @@ const runBenchmark = async args => { } const measuredRuns = []; for (let i = 0; i < warmups + runs; i++) { - const run = await collectRun(browser, baseUrl, session, i); + const run = await collectRun(browser, baseUrl, session, i, { + viewport, + sampleMs, + }); console.log(`${i < warmups ? "warmup" : "run"} ${i + 1}`, run); if (i >= warmups) { measuredRuns.push(run); } } const result = { productLine: PRODUCT_LINE, createdAt: new Date().toISOString(), + viewport, + sampleMs, runs: measuredRuns, summary: summary(measuredRuns), }; From 6319975c7449890bfeb93da1fae35a45960b3154 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 24 Apr 2026 14:11:36 -0700 Subject: [PATCH 22/83] add fps plot to ci --- .circleci/config.yml | 22 +++++-- scripts/fps.js | 82 ++++++++++++++++++++------ scripts/fps_plot.js | 135 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 scripts/fps_plot.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 84d5090eb8..96517414fa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -309,7 +309,7 @@ commands: continue fi - if ! fps_output=$(sudo docker compose exec -e PLAYWRIGHT_BROWSERS_PATH=0 web bun scripts/fps.js "${url}" "tmp/promo.png"); then + if ! fps_output=$(sudo docker compose exec -e PLAYWRIGHT_BROWSERS_PATH=0 web bun scripts/fps.js "${url}" "tmp/promo.png" "tmp/fps_plot.png"); then echo "${fps_output}" continue fi @@ -376,6 +376,9 @@ commands: - store_artifacts: path: tmp/promo.png destination: promo_xl.png + - store_artifacts: + path: tmp/fps_plot.png + destination: fps_plot.png - store_artifacts: path: scene_metrics.csv destination: scene_metrics.csv @@ -388,13 +391,24 @@ commands: artifacts_json=$(curl -fsS \ -H "Accept: application/json" \ "https://circleci.com/api/v2/project/${project_slug}/${CIRCLE_BUILD_NUM}/artifacts") - artifact_url=$(printf "%s" "$artifacts_json" | python -c 'import json,sys; data=json.load(sys.stdin); items=data.get("items") or []; print(next((item.get("url","") for item in items if item.get("url","").endswith(".png")), ""))') - echo "export ARTIFACT_URL=$artifact_url" >> "$BASH_ENV" + mapfile -t artifact_urls < <(printf "%s" "$artifacts_json" | python -c 'import json,sys; print("\n".join(item.get("url", "") for item in (json.load(sys.stdin).get("items") or [])))') + url_for() { + for url in "${artifact_urls[@]}"; do [[ "$url" == *$1 ]] && { echo "$url"; return; }; done + } + screenshot_url="$(url_for "promo*.png")" + fps_plot_url="$(url_for "fps_plot.png")" + echo $screenshot_url + echo $fps_plot_url + echo "export SCREENSHOT_URL='$screenshot_url'" >> "$BASH_ENV" + echo "export FPS_PLOT_URL='$fps_plot_url'" >> "$BASH_ENV" - send-notification: message: "CI run succeeded" when: on_success - send-notification: - message: "<$ARTIFACT_URL|screenshot>" + message: "<$SCREENSHOT_URL|screenshot>" + when: on_success + - send-notification: + message: "<$FPS_PLOT_URL|fps>" when: on_success - send-notification: message: "CI run failed" diff --git a/scripts/fps.js b/scripts/fps.js index 8e3b4514de..a8f3df744b 100644 --- a/scripts/fps.js +++ b/scripts/fps.js @@ -1,9 +1,29 @@ const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); +const { saveFpsPlot } = require('./fps_plot'); const url = process.argv[2]; const screenshotPath = process.argv[3] || 'tmp/fps.png'; +const plotPath = process.argv[4] || 'tmp/fps_plot.png'; +const maxLoadingSamples = 240; +const postLoadSamples = 10; +const chosenPostLoadSample = 5; +const sampleIntervalMs = 1000; + +const pageIsLoading = page => + page.evaluate(() => Boolean(document.querySelector('.three-d-load-progress'))); + +function printUsage() { + console.log([ + 'Usage: bun scripts/fps.js [screenshot_path] [fps_plot_path]', + '', + 'Arguments:', + ' url Page URL that exposes window.__fps and window.__scene_metrics.', + ' screenshot_path Optional full-page screenshot PNG path. Default: tmp/fps.png', + ' fps_plot_path Optional FPS plot PNG path. Default: tmp/fps_plot.png', + ].join('\n')); +} async function main() { const browser = await chromium.launch({ @@ -19,25 +39,44 @@ async function main() { page.setDefaultTimeout(120_000); try { await page.goto(url, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => { - const canvas = document.querySelector('.garden-bed-3d-model canvas'); - return Boolean(canvas && typeof canvas.dataset.engine === 'string'); - }); await page.waitForFunction(() => typeof window.__fps !== 'undefined'); - const samples = 20; - const takeSample = 15; let lastSample = 0; - let validCount = 0; - for (let i = 0; i < samples; i++) { + let lastSampleIndex = -1; + let loadingSampleCount = 0; + let postLoadCount = 0; + const sampleValues = []; + let loading = true; + const maxSamples = maxLoadingSamples + postLoadSamples; + const startMs = Date.now(); + for (let i = 0; i < maxSamples; i++) { const v = await page.evaluate(() => window.__fps); const n = Number(v); - if (Number.isFinite(n) && validCount <= takeSample) { - lastSample = n; - validCount++; + const elapsedSeconds = (Date.now() - startMs) / 1000; + loading = await pageIsLoading(page); + sampleValues.push({ fps: n, elapsedSeconds, loading }); + if (loading) { + loadingSampleCount++; + } else { + postLoadCount++; + if (postLoadCount === chosenPostLoadSample) { + lastSample = n; + lastSampleIndex = i; + } } - console.log(`Sample ${i + 1}/${samples}: ${n}`); - await page.waitForTimeout(1000); + const status = loading + ? 'loading' + : `loaded ${postLoadCount}/${postLoadSamples}`; + console.log(`Sample ${i + 1} (${status}, ${elapsedSeconds.toFixed(2)}s): ${n.toFixed(2)}fps`); + if (postLoadCount >= postLoadSamples) { break; } + if (loadingSampleCount >= maxLoadingSamples) { break; } + await page.waitForTimeout(sampleIntervalMs); + } + if (loading) { + throw new Error(`3D load did not finish after ${sampleValues.length} samples`); + } + if (!Number.isFinite(lastSample)) { + throw new Error(`Post-load sample ${chosenPostLoadSample} was not a valid FPS value`); } console.log(`FPS_VALUE=${lastSample.toFixed(2)}`); const data = await page.evaluate(() => window.__scene_metrics); @@ -49,6 +88,10 @@ async function main() { timeout: 60_000, }); console.log(`FPS_SCREENSHOT=${screenshotPath}`); + await saveFpsPlot(browser, sampleValues, plotPath, { + highlightIndex: lastSampleIndex, + }); + console.log(`FPS_PLOT=${plotPath}`); } catch (err) { console.error('Failed to read window.__fps:', err.message || err); process.exitCode = 1; @@ -57,7 +100,12 @@ async function main() { } } -main().catch((err) => { - console.error('Unexpected error:', err); - process.exitCode = 1; -}); +if (!url || url === '-h' || url === '--help') { + printUsage(); + process.exitCode = url ? 0 : 1; +} else { + main().catch((err) => { + console.error('Unexpected error:', err); + process.exitCode = 1; + }); +} diff --git a/scripts/fps_plot.js b/scripts/fps_plot.js new file mode 100644 index 0000000000..965dcb8f6f --- /dev/null +++ b/scripts/fps_plot.js @@ -0,0 +1,135 @@ +const fs = require('fs'); +const path = require('path'); + +const formatStat = value => value.toFixed(2); +const formatPoint = value => Number(value.toFixed(2)); +const finiteSamples = samples => samples + .map((sample, index) => { + const value = typeof sample === 'object' ? sample.fps : sample; + const elapsedSeconds = typeof sample === 'object' + ? Number(sample.elapsedSeconds) + : index; + return { + value: Number(value), + elapsedSeconds, + index, + loading: typeof sample === 'object' ? sample.loading : undefined, + }; + }) + .filter(({ value }) => Number.isFinite(value)); + +function buildFpsPlotSvg(samples, options = {}) { + const width = options.width || 640; + const height = options.height || 320; + const highlightIndex = options.highlightIndex; + const margin = { top: 52, right: 24, bottom: 44, left: 54 }; + const plotWidth = width - margin.left - margin.right; + const plotHeight = height - margin.top - margin.bottom; + const finite = finiteSamples(samples); + const values = finite.map(({ value }) => value); + const times = finite + .map(({ elapsedSeconds }) => elapsedSeconds) + .filter(value => Number.isFinite(value)); + const minSample = values.length ? Math.min(...values) : 0; + const maxSample = values.length ? Math.max(...values) : 0; + const avgSample = values.length + ? values.reduce((total, value) => total + value, 0) / values.length + : 0; + const lastSample = values.length ? values[values.length - 1] : 0; + const minValue = Math.min(0, minSample); + const maxValue = Math.max(1, maxSample); + const valueRange = maxValue - minValue || 1; + const minTime = 0; + const maxTime = Math.max(1, times.length ? Math.max(...times) : samples.length - 1); + const timeRange = maxTime - minTime || 1; + const xFor = elapsedSeconds => margin.left + ( + (elapsedSeconds - minTime) / timeRange + ) * plotWidth; + const yFor = value => margin.top + ((maxValue - value) / valueRange) * plotHeight; + const points = finite + .map(({ value, elapsedSeconds }) => + `${formatPoint(xFor(elapsedSeconds))},${formatPoint(yFor(value))}`) + .join(' '); + const circles = finite + .map(({ value, elapsedSeconds, index }) => { + const highlighted = index === highlightIndex; + return [ + ``, + ].join(''); + }) + .join(''); + const gridValues = [maxValue, (maxValue + minValue) / 2, minValue]; + const grid = gridValues + .map(value => { + const y = formatPoint(yFor(value)); + return [ + ``, + `${formatStat(value)}`, + ].join(''); + }) + .join(''); + const tickInterval = maxTime <= 10 ? 1 : maxTime <= 100 ? 10 : 100; + const xTicks = Array.from({ length: Math.floor(maxTime / tickInterval) + 1 }, + (_value, index) => { + const elapsedSeconds = index * tickInterval; + const x = formatPoint(xFor(elapsedSeconds)); + return [ + ``, + `${elapsedSeconds}`, + ].join(''); + }) + .join(''); + const firstLoaded = finite.find(({ loading }) => loading === false); + const loadedMarker = firstLoaded + ? [ + ``, + `Loaded`, + ].join('') + : ''; + const stats = values.length + ? `min ${formatStat(minSample)} avg ${formatStat(avgSample)} max ${formatStat(maxSample)} last ${formatStat(lastSample)}` + : 'No valid samples'; + + return ` + + + + + + + + + FPS samples + ${stats} + ${grid} + + + ${xTicks} + ${loadedMarker} + ${points ? `` : ''} + ${circles} + Seconds + + +`; +} + +async function saveFpsPlot(browser, samples, destination, options = {}) { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const plotPage = await browser.newPage({ viewport: { width: 640, height: 320 } }); + try { + await plotPage.setContent(buildFpsPlotSvg(samples, options), { waitUntil: 'load' }); + await plotPage.locator('svg').screenshot({ + path: destination, + timeout: 60_000, + }); + } finally { + await plotPage.close(); + } +} + +module.exports = { + buildFpsPlotSvg, + saveFpsPlot, +}; From 3cd958a271354e29e480992fb62b85a43ca2df24 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 20:37:51 -0700 Subject: [PATCH 23/83] Zoom beacon camera animations --- .../css/farm_designer/three_d_garden.scss | 198 +++++- frontend/promo/__tests__/promo_test.tsx | 56 +- frontend/promo/promo.tsx | 96 ++- .../__tests__/config_overlays_test.tsx | 8 + .../three_d_garden/__tests__/config_test.ts | 4 + .../__tests__/focus_transition_test.tsx | 196 ++++++ .../__tests__/garden_model_test.tsx | 77 ++- .../__tests__/progressive_load_test.tsx | 13 +- .../__tests__/zoom_beacons_constants_test.tsx | 19 +- frontend/three_d_garden/bed/bed.tsx | 6 +- .../bed/objects/utilities_post.tsx | 6 +- .../three_d_garden/bot/__tests__/bot_test.tsx | 13 + frontend/three_d_garden/bot/bot.tsx | 6 +- frontend/three_d_garden/config.ts | 2 +- frontend/three_d_garden/config_overlays.tsx | 52 +- frontend/three_d_garden/focus_transition.tsx | 590 ++++++++++++++++++ .../garden/__tests__/clouds_test.tsx | 7 + .../garden/__tests__/grid_test.tsx | 23 +- .../garden/__tests__/plant_instances_test.tsx | 22 + .../garden/__tests__/plants_test.tsx | 11 + .../garden/__tests__/solar_test.tsx | 47 ++ .../garden/__tests__/zoom_beacons_test.tsx | 62 +- frontend/three_d_garden/garden/clouds.tsx | 15 +- frontend/three_d_garden/garden/grid.tsx | 16 +- .../three_d_garden/garden/plant_instances.tsx | 20 +- frontend/three_d_garden/garden/plants.tsx | 15 +- frontend/three_d_garden/garden/solar.tsx | 114 +++- .../three_d_garden/garden/zoom_beacons.tsx | 203 ++++-- frontend/three_d_garden/garden_model.tsx | 509 +++++++++------ frontend/three_d_garden/progressive_load.tsx | 110 +++- frontend/three_d_garden/scenes/greenhouse.tsx | 5 +- frontend/three_d_garden/scenes/props/desk.tsx | 5 +- .../three_d_garden/scenes/props/people.tsx | 5 +- .../three_d_garden/zoom_beacons_constants.tsx | 6 +- 34 files changed, 2141 insertions(+), 396 deletions(-) create mode 100644 frontend/three_d_garden/__tests__/focus_transition_test.tsx create mode 100644 frontend/three_d_garden/focus_transition.tsx diff --git a/frontend/css/farm_designer/three_d_garden.scss b/frontend/css/farm_designer/three_d_garden.scss index ec138f077b..bfef5ba222 100644 --- a/frontend/css/farm_designer/three_d_garden.scss +++ b/frontend/css/farm_designer/three_d_garden.scss @@ -53,6 +53,12 @@ color: $off_white; text-align: center; text-shadow: 0 0 1rem $black; + opacity: 1; + transition: opacity 300ms ease; + + &.three-d-load-progress-complete { + opacity: 0; + } .three-d-load-progress-bar { height: 0.7rem; @@ -116,6 +122,23 @@ background: linear-gradient(0deg, $translucent5, transparent); scrollbar-width: none; + .settings-bar-content { + display: flex; + gap: 1.25rem; + opacity: 0; + transform: translateY(1.5rem); + transition: + opacity 450ms ease, + transform 450ms ease; + } + + &.settings-bar-loaded { + .settings-bar-content { + opacity: 1; + transform: translateY(0); + } + } + @media screen and (max-width: 768px) { justify-content: left; pointer-events: all; @@ -136,17 +159,29 @@ .row { display: flex; + position: relative; margin: 0; - background: rgba(255, 255, 255, 0.6); + overflow: hidden; + background: transparent; box-shadow: $translucent2 0px 0px 1rem; border-radius: 2.5rem; height: 3.1rem; padding: 0.3rem; justify-content: space-evenly; - backdrop-filter: blur(5px); gap: 0.5rem; - &:after, - &:before { + + &::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + border-radius: inherit; + pointer-events: none; + background: rgba(255, 255, 255, 0.6); + backdrop-filter: blur(5px); + } + + &:after { content: unset; display: unset; clear: unset; @@ -154,6 +189,8 @@ } button { + position: relative; + z-index: 1; padding: 0 0.85rem; border-radius: 2.5rem; font-weight: bold; @@ -353,6 +390,41 @@ } } + .focus-transition-dom { + opacity: 1; + transform: scale(1); + transition: + opacity 900ms cubic-bezier(0.65, 0, 0.35, 1), + transform 900ms cubic-bezier(0.65, 0, 0.35, 1); + will-change: opacity, transform; + + &.focus-transition-hidden { + opacity: 0; + pointer-events: none; + transform: scale(0.98); + } + + &.focus-transition-visible { + opacity: 1; + transform: scale(1); + } + } + + .focus-transition-opacity { + opacity: 1; + transition: opacity 900ms cubic-bezier(0.65, 0, 0.35, 1); + will-change: opacity; + + &.focus-transition-hidden { + opacity: 0; + pointer-events: none; + } + + &.focus-transition-visible { + opacity: 1; + } + } + .promo-info { display: grid; position: absolute; @@ -365,6 +437,25 @@ gap: 1rem; justify-items: right; + .title, + .description { + opacity: 0; + animation: promo-info-load-in 500ms ease forwards; + } + + .title, + .description { + transform: translateY(1rem); + } + + .title { + animation-delay: 0ms; + } + + .description { + animation-delay: 150ms; + } + .title { margin: 0; font-family: $inknut; @@ -395,11 +486,12 @@ .buy-button { display: flex; + position: relative; + overflow: hidden; pointer-events: all; - background: #00a579e0; + background: transparent; border-radius: 7px; box-shadow: $translucent2 0px 0px 10px; - backdrop-filter: blur(5px); padding: 0.1rem 1.75rem; text-shadow: none; text-transform: uppercase; @@ -407,7 +499,23 @@ gap: 0.4rem; align-items: center; + &::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + border-radius: inherit; + pointer-events: none; + background: #00a579e0; + backdrop-filter: blur(5px); + animation: buy-button-backdrop-load-in 500ms ease 275ms both; + } + p { + position: relative; + z-index: 1; + opacity: 0; + animation: promo-info-fade-in 500ms ease 275ms forwards; margin: 0; color: $off_white; font-size: 1.5rem; @@ -429,21 +537,54 @@ } &:hover { - background: #00bb89e5; + &::before { + background: #00bb89e5; + } } } } + .settings-bar.focus-transition-dom { + transform-origin: 50% 100%; + transform: none; + transition: opacity 900ms cubic-bezier(0.65, 0, 0.35, 1); + will-change: auto; + + &.focus-transition-hidden { + transform: none; + } + + &.focus-transition-visible { + transform: none; + } + } + + .promo-info.focus-transition-dom { + transform-origin: 100% 0; + transform: none; + transition: opacity 900ms cubic-bezier(0.65, 0, 0.35, 1); + will-change: auto; + + &.focus-transition-hidden { + transform: none; + } + + &.focus-transition-visible { + transform: none; + } + } + .beacon-info { width: 34rem; - background: $translucent8_white; + background: rgba(255, 255, 255, 0.6); color: $black; box-shadow: $translucent2 0px 0px 10px; border-radius: 0.5rem; padding: 1.5rem; - backdrop-filter: blur(5px); + backdrop-filter: blur(20px); text-align: left; - user-select: none; + cursor: default; + user-select: text; p { margin-bottom: 0; margin-top: 1rem; @@ -487,6 +628,7 @@ text-align: center; background: rgba(255, 255, 255, 0.4); box-shadow: $translucent2 0px 0px 0.75rem; + user-select: none; &:hover { cursor: pointer; background: rgba(255, 255, 255, 0.6); @@ -495,6 +637,10 @@ } } + .beacon-info.focus-transition-dom { + transform-origin: 50% 50%; + } + @media screen and (max-width: 768px) { .beacon-info-wrapper { display: grid; @@ -580,3 +726,35 @@ position: absolute; top: 3rem; } + +@keyframes promo-info-load-in { + from { + opacity: 0; + transform: translateY(1rem); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes promo-info-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes buy-button-backdrop-load-in { + from { + background: rgba(0, 165, 121, 0); + } + + to { + background: #00a579e0; + } +} diff --git a/frontend/promo/__tests__/promo_test.tsx b/frontend/promo/__tests__/promo_test.tsx index 2f30f3f813..e97cdee6dd 100644 --- a/frontend/promo/__tests__/promo_test.tsx +++ b/frontend/promo/__tests__/promo_test.tsx @@ -1,14 +1,17 @@ import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { getSeasonTimings, Promo } from "../promo"; +import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { getPromoPlantCapacities, getSeasonTimings, Promo } from "../promo"; import * as reactThreeFiber from "@react-three/fiber"; import * as gardenModelModule from "../../three_d_garden/garden_model"; +import { INITIAL, PRESETS } from "../../three_d_garden/config"; +import { calculatePlantPositions } from "../plants"; describe("", () => { const originalSearch = window.location.search; const originalConsoleError = console.error; let canvasSpy: jest.SpyInstance; let gardenModelSpy: jest.SpyInstance; + let pushStateSpy: jest.SpyInstance; beforeEach(() => { canvasSpy = jest.spyOn(reactThreeFiber, "Canvas") @@ -17,20 +20,40 @@ describe("", () => { gardenModelSpy = jest.spyOn(gardenModelModule, "GardenModel") .mockImplementation(({ config }: { config: { promoSpread?: boolean } }) =>
{config.promoSpread ? "spread" : "garden-model"}
); + pushStateSpy = jest.spyOn(history, "pushState") + .mockImplementation(jest.fn()); }); afterEach(() => { - window.location.search = originalSearch; + history.replaceState(undefined, "", `/${originalSearch}`); jest.useRealTimers(); console.error = originalConsoleError; canvasSpy.mockRestore(); gardenModelSpy.mockRestore(); + pushStateSpy.mockRestore(); }); it("renders", () => { console.error = jest.fn(); const { container, unmount } = render(); expect(container).toContainHTML("three-d-garden"); + expect(gardenModelSpy.mock.calls[0][0]).toEqual( + expect.objectContaining({ + plantIconCapacities: expect.any(Object), + plantInstanceCapacity: expect.any(Number), + smoothFocusTransitions: true, + }), + ); + unmount(); + }); + + it("loads settings bar after the 3D scene is ready", () => { + const { container, unmount } = render(); + expect(container.querySelector(".settings-bar-loaded")).toBeFalsy(); + act(() => { + gardenModelSpy.mock.calls[0][0].onLoadComplete(); + }); + expect(container.querySelector(".settings-bar-loaded")).toBeTruthy(); unmount(); }); @@ -64,6 +87,16 @@ describe("", () => { expect(container).toContainHTML("spread"); unmount(); }); + + it("clears active focus on Escape", async () => { + window.location.search = "?focus=What%20you%20can%20grow"; + const { unmount } = render(); + fireEvent.keyDown(window, { key: "Escape" }); + await waitFor(() => expect(pushStateSpy).toHaveBeenCalled()); + const nextUrl = pushStateSpy.mock.calls[0][2] as string; + expect(nextUrl).not.toContain("focus="); + unmount(); + }); }); describe("getSeasonTimings()", () => { @@ -72,3 +105,20 @@ describe("getSeasonTimings()", () => { expect(getSeasonTimings("Random").season).toEqual("Spring"); }); }); + +describe("getPromoPlantCapacities()", () => { + it("keeps capacity at least as large as the current layout", () => { + const config = { + ...INITIAL, + ...PRESETS["Genesis"], + plants: "Spring", + }; + const capacities = getPromoPlantCapacities(config); + const plants = calculatePlantPositions(config); + expect(capacities.plantInstanceCapacity).toBeGreaterThanOrEqual( + plants.length, + ); + plants.map(plant => + expect(capacities.iconCapacities[plant.icon]).toBeGreaterThan(0)); + }); +}); diff --git a/frontend/promo/promo.tsx b/frontend/promo/promo.tsx index 487a256432..982351d495 100644 --- a/frontend/promo/promo.tsx +++ b/frontend/promo/promo.tsx @@ -9,7 +9,9 @@ import { PrivateOverlay, PublicOverlay, ToolTip, } from "../three_d_garden/config_overlays"; import { ASSETS } from "../three_d_garden/constants"; -import { getFocusFromUrlParams } from "../three_d_garden/zoom_beacons_constants"; +import { + getFocusFromUrlParams, setUrlParam, +} from "../three_d_garden/zoom_beacons_constants"; import { MemoryRouter } from "react-router"; import { calculatePlantPositions } from "./plants"; import { ThreeDGardenPlant } from "../three_d_garden/garden"; @@ -17,6 +19,7 @@ import { TaggedGenericPointer } from "farmbot"; import { calculatePointPositions } from "./points"; import { SEASON_TIMINGS, SEASONS } from "./constants"; import { isMobile } from "../screen_size"; +import { FocusTransitionProvider } from "../three_d_garden/focus_transition"; const PROMO_BED_SIZES = [ { @@ -31,6 +34,10 @@ const PROMO_BED_SIZES = [ type ThreeDPlantsCache = Record; const PLANTS_CACHE: ThreeDPlantsCache = {}; +interface PromoPlantCapacities { + iconCapacities: Record; + plantInstanceCapacity: number; +} const calcCacheKey = (config: Config): string => `${config.bedLengthOuter}x${config.bedWidthOuter}: ${config.plants}`; @@ -73,6 +80,27 @@ const getCachedPlants = (config: Config) => { return PLANTS_CACHE[cacheKey] || []; }; +export const getPromoPlantCapacities = (config: Config): PromoPlantCapacities => { + const iconCapacities: Record = {}; + let plantInstanceCapacity = 0; + PROMO_BED_SIZES.map(({ length, width }) => { + const plants = getCachedPlants({ + ...config, + bedLengthOuter: length, + bedWidthOuter: width, + }); + plantInstanceCapacity = Math.max(plantInstanceCapacity, plants.length); + const iconCounts: Record = {}; + plants.map(plant => { + iconCounts[plant.icon] = (iconCounts[plant.icon] || 0) + 1; + }); + Object.entries(iconCounts).map(([icon, count]) => { + iconCapacities[icon] = Math.max(iconCapacities[icon] || 0, count); + }); + }); + return { iconCapacities, plantInstanceCapacity }; +}; + prewarmPlantsCache(); export const getSeasonTimings = (currentSeason: string, step = 0) => { @@ -96,6 +124,9 @@ export const Promo = () => { const [toolTip, setToolTip] = React.useState({ timeoutId: 0, text: "" }); const [activeFocus, setActiveFocus] = React.useState(() => getFocusFromUrlParams()); + const [threeDLoaded, setThreeDLoaded] = React.useState(false); + const handleThreeDLoadComplete = React.useCallback(() => + setThreeDLoaded(true), []); const common = { config, setConfig, toolTip, setToolTip, @@ -132,6 +163,17 @@ export const Promo = () => { startTimeRef.current = performance.now() / 1000; }, []); + React.useEffect(() => { + if (!activeFocus) { return; } + const onKeyDown = (e: KeyboardEvent) => { + if (e.key != "Escape") { return; } + setActiveFocus(""); + setUrlParam("focus", ""); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [activeFocus]); + const plants = React.useMemo(() => { return getCachedPlants(config); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -142,31 +184,45 @@ export const Promo = () => { ? plants.map(plant => ({ ...plant, id: 0 })) : plants; }, [plants, config.promoSpread]); + const plantCapacityConfig = React.useMemo(() => ({ + ...INITIAL, + plants: config.plants, + }), [config.plants]); + const plantCapacities = React.useMemo(() => + getPromoPlantCapacities(plantCapacityConfig), [plantCapacityConfig]); return
- - { - gl.localClippingEnabled = true; - }}> - - - - - {!config.config && + + + { + gl.localClippingEnabled = true; + }}> + + + + + {!config.config && setConfig({ ...config, config: true })} />} - {config.config && + {config.config && } - + +
; }; diff --git a/frontend/three_d_garden/__tests__/config_overlays_test.tsx b/frontend/three_d_garden/__tests__/config_overlays_test.tsx index 38473186e7..0f52b7fbd5 100644 --- a/frontend/three_d_garden/__tests__/config_overlays_test.tsx +++ b/frontend/three_d_garden/__tests__/config_overlays_test.tsx @@ -34,6 +34,14 @@ describe("", () => { expect(container.innerHTML).toContain("settings-bar"); }); + it("marks settings bar as loaded", () => { + const p = fakeProps(); + p.loadComplete = true; + const { container } = render(); + expect(container.querySelector(".settings-bar-loaded")).toBeTruthy(); + expect(container.querySelector(".settings-bar-content")).toBeTruthy(); + }); + it("changes preset", () => { const p = fakeProps(); const { container } = render(); diff --git a/frontend/three_d_garden/__tests__/config_test.ts b/frontend/three_d_garden/__tests__/config_test.ts index acc38bbc81..c2aac35f32 100644 --- a/frontend/three_d_garden/__tests__/config_test.ts +++ b/frontend/three_d_garden/__tests__/config_test.ts @@ -5,6 +5,10 @@ import { } from "../config"; describe("modifyConfig()", () => { + it("enables labels on hover by default", () => { + expect(INITIAL.labelsOnHover).toEqual(true); + }); + it("modifies config: lab", () => { const initial = clone(INITIAL); const result = modifyConfig(initial, { scene: "Lab" }); diff --git a/frontend/three_d_garden/__tests__/focus_transition_test.tsx b/frontend/three_d_garden/__tests__/focus_transition_test.tsx new file mode 100644 index 0000000000..c3aa6d0baf --- /dev/null +++ b/frontend/three_d_garden/__tests__/focus_transition_test.tsx @@ -0,0 +1,196 @@ +import React from "react"; +import { act, render, screen } from "@testing-library/react"; +import { + applyFocusMaterialOpacity, + applySmoothCameraState, + cameraTransitionValue, + createFocusMaterialBinding, + FOCUS_TRANSITION_MS, + FocusTransitionProvider, + FocusVisibilityDiv, + interpolateCameraState, + readSmoothCameraState, + shouldUnmountFocusVisibilityGroup, +} from "../focus_transition"; +import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D } from "three"; + +describe("focus transitions", () => { + it("keeps exiting DOM content mounted until the fade completes", () => { + jest.useFakeTimers(); + const { rerender } = render( + + + panel + + , + ); + rerender( + + + panel + + , + ); + expect(screen.queryByText("panel")).not.toBeNull(); + act(() => { + jest.advanceTimersByTime(FOCUS_TRANSITION_MS - 1); + }); + expect(screen.queryByText("panel")).not.toBeNull(); + act(() => { + jest.advanceTimersByTime(1); + }); + expect(screen.queryByText("panel")).toBeNull(); + jest.useRealTimers(); + }); + + it("keeps expensive groups mounted when requested", () => { + expect(shouldUnmountFocusVisibilityGroup(false, false, true)) + .toEqual(false); + expect(shouldUnmountFocusVisibilityGroup(false, false, false)) + .toEqual(true); + expect(shouldUnmountFocusVisibilityGroup(false, true, false)) + .toEqual(false); + expect(shouldUnmountFocusVisibilityGroup(true, false, false)) + .toEqual(false); + }); + + it("isolates material opacity changes and restores originals", () => { + const root = new Object3D(); + const material = new MeshBasicMaterial({ + opacity: 0.5, + transparent: false, + depthWrite: true, + }); + const mesh = new Mesh(new BoxGeometry(), material); + root.add(mesh); + + const binding = createFocusMaterialBinding(root); + const clone = mesh.material; + expect(clone).not.toBe(material); + + binding.apply(0.25); + expect(clone.opacity).toEqual(0.125); + expect(clone.transparent).toEqual(true); + expect(clone.depthWrite).toEqual(false); + + binding.apply(1); + expect(clone.opacity).toEqual(0.5); + expect(clone.transparent).toEqual(false); + expect(clone.depthWrite).toEqual(true); + + binding.restore(); + expect(mesh.material).toBe(material); + }); + + it("applies material opacity without destroying original material state", () => { + const material = new MeshBasicMaterial({ + opacity: 0.4, + transparent: true, + depthWrite: true, + }); + applyFocusMaterialOpacity(material, { + opacity: 0.4, + transparent: true, + depthWrite: true, + }, 0.5); + expect(material.opacity).toEqual(0.2); + expect(material.transparent).toEqual(true); + expect(material.depthWrite).toEqual(false); + }); + + it("can preserve depth writing while fading material opacity", () => { + const material = new MeshBasicMaterial({ + opacity: 0.4, + transparent: true, + depthWrite: true, + }); + applyFocusMaterialOpacity(material, { + opacity: 0.4, + transparent: true, + depthWrite: true, + }, 0.5, true); + expect(material.opacity).toEqual(0.2); + expect(material.transparent).toEqual(true); + expect(material.depthWrite).toEqual(true); + }); + + it("keeps final camera coordinates unchanged", () => { + const fallback = { + position: [1, 2, 3] as [number, number, number], + target: [4, 5, 6] as [number, number, number], + zoom: 1, + }; + const value = cameraTransitionValue({ + position: [7, 8, 9], + target: [10, 11, 12], + zoom: 2, + }, fallback); + expect(value).toEqual({ + position: [7, 8, 9], + target: [10, 11, 12], + zoom: 2, + }); + }); + + it("interpolates camera state", () => { + const from = { + position: [0, 0, 0] as [number, number, number], + target: [10, 20, 30] as [number, number, number], + zoom: 1, + }; + const to = { + position: [10, 20, 30] as [number, number, number], + target: [20, 40, 60] as [number, number, number], + zoom: 3, + }; + expect(interpolateCameraState(from, to, 0.5)).toEqual({ + position: [5, 10, 15], + target: [15, 30, 45], + zoom: 2, + }); + }); + + it("applies camera state to camera and controls objects", () => { + const camera = { + position: { set: jest.fn() }, + zoom: 1, + lookAt: jest.fn(), + updateProjectionMatrix: jest.fn(), + }; + const controls = { + target: { set: jest.fn() }, + update: jest.fn(), + }; + applySmoothCameraState({ + position: [1, 2, 3], + target: [4, 5, 6], + zoom: 2, + }, camera, controls); + expect(camera.position.set).toHaveBeenCalledWith(1, 2, 3); + expect(camera.zoom).toEqual(2); + expect(camera.lookAt).toHaveBeenCalledWith(4, 5, 6); + expect(camera.updateProjectionMatrix).toHaveBeenCalled(); + expect(controls.target.set).toHaveBeenCalledWith(4, 5, 6); + expect(controls.update).toHaveBeenCalled(); + }); + + it("reads live camera and controls state", () => { + const fallback = { + position: [1, 2, 3] as [number, number, number], + target: [4, 5, 6] as [number, number, number], + zoom: 1, + }; + const camera = { + position: { x: 7, y: 8, z: 9, set: jest.fn() }, + zoom: 2, + }; + const controls = { + target: { x: 10, y: 11, z: 12, set: jest.fn() }, + }; + expect(readSmoothCameraState(fallback, camera, controls)).toEqual({ + position: [7, 8, 9], + target: [10, 11, 12], + zoom: 2, + }); + }); +}); diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 1eff35070b..71722b77de 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -3,7 +3,10 @@ let mockIsMobile = false; import React from "react"; import { OrbitControls, useTexture } from "@react-three/drei"; -import { GardenModelProps, GardenModel } from "../garden_model"; +import { + GardenModelProps, GardenModel, SMOOTH_XL_CAMERA_BED_SCALE, + SMOOTH_XL_CAMERA_HEIGHT_SCALE, +} from "../garden_model"; import { clone } from "lodash"; import { INITIAL, INITIAL_POSITION, SurfaceDebugOption } from "../config"; import { render, waitFor } from "@testing-library/react"; @@ -21,6 +24,8 @@ import { unmountRenderer, } from "../../__test_support__/test_renderer"; import { PLANT_ICON_ATLAS } from "../garden/plant_icon_atlas"; +import { cameraInit } from "../camera"; +import { getCamera } from "../zoom_beacons_constants"; let isDesktopSpy: jest.SpyInstance; let isMobileSpy: jest.SpyInstance; @@ -75,9 +80,10 @@ describe("", () => { return wrapper; }; - it("renders", () => { + it("renders", async () => { const { container } = render(); - expect(container.innerHTML).toContain("zoom-beacons"); + await waitFor(() => + expect(container.innerHTML).toContain("zoom-beacons")); expect(container.innerHTML).not.toContain("stats"); expect(container.innerHTML).toContain("darkgreen"); expect(container.innerHTML).toContain("bed-load-in"); @@ -128,6 +134,53 @@ describe("", () => { expect(camera?.props.zoom).toEqual(0.5); }); + it("keeps focused camera coordinates with smooth transitions disabled", () => { + const p = fakeProps(); + p.activeFocus = "What you can grow"; + p.smoothFocusTransitions = true; + p.config.animate = false; + const wrapper = createWrapper(p); + const camera = wrapper.root.findAll(node => node.props.name == "camera")[0]; + const defaultCamera = cameraInit({ + topDown: p.config.topDown, + viewpointHeading: p.config.viewpointHeading, + bedSize: { x: p.config.bedLengthOuter, y: p.config.bedWidthOuter }, + }); + const expectedCamera = getCamera( + p.config, + p.configPosition, + p.activeFocus, + defaultCamera, + ); + expect(camera?.props.position).toEqual(expectedCamera.position); + expect(wrapper.root.findByType(OrbitControls).props.target) + .toEqual(expectedCamera.target); + }); + + it("moves the smooth XL default camera back and higher", () => { + const p = fakeProps(); + p.smoothFocusTransitions = true; + p.config.animate = false; + p.config.sizePreset = "Genesis XL"; + p.config.bedLengthOuter = 6000; + p.config.bedWidthOuter = 2860; + const wrapper = createWrapper(p); + const camera = wrapper.root.findAll(node => node.props.name == "camera")[0]; + const expectedCamera = cameraInit({ + topDown: p.config.topDown, + viewpointHeading: p.config.viewpointHeading, + bedSize: { + x: p.config.bedLengthOuter * SMOOTH_XL_CAMERA_BED_SCALE, + y: p.config.bedWidthOuter * SMOOTH_XL_CAMERA_BED_SCALE, + }, + }); + expect(camera?.props.position).toEqual([ + expectedCamera.position[0], + expectedCamera.position[1], + expectedCamera.position[2] * SMOOTH_XL_CAMERA_HEIGHT_SCALE, + ]); + }); + it("renders camera selection view", async () => { const p = fakeProps(); p.config.cameraSelectionView = true; @@ -149,12 +202,26 @@ describe("", () => { const p = fakeProps(); const plant = fakePlant(); plant.body.name = "Beet"; + p.config.labels = true; + p.config.labelsOnHover = false; p.threeDPlants = convertPlants(p.config, [plant]); const { queryAllByText } = render(); const plantLabels = queryAllByText("Beet"); expect(plantLabels.length).toEqual(1); }); + it("doesn't build plant label nodes when labels are disabled", () => { + const p = fakeProps(); + const plant = fakePlant(); + plant.body.name = "Beet"; + p.config.labels = false; + p.config.labelsOnHover = false; + p.threeDPlants = convertPlants(p.config, [plant]); + const { queryAllByText } = render(); + const plantLabels = queryAllByText("Beet"); + expect(plantLabels.length).toEqual(0); + }); + it("preloads the atlas texture for mapped plant icons", () => { PLANT_ICON_ATLAS["/crops/icons/beet.avif"] = { atlasUrl: "/crops/icons/atlas.avif", @@ -242,7 +309,7 @@ describe("", () => { expect(container.innerHTML).not.toContain('name="bot"'); }); - it("renders other options", () => { + it("renders other options", async () => { mockIsDesktop = false; const p = fakeProps(); p.config.perspective = false; @@ -260,7 +327,7 @@ describe("", () => { p.addPlantProps = undefined; const { container } = render(); expect(container.innerHTML).toContain("gray"); - expect(container.innerHTML).toContain("stats"); + await waitFor(() => expect(container.innerHTML).toContain("stats")); }); it("renders debug options", () => { diff --git a/frontend/three_d_garden/__tests__/progressive_load_test.tsx b/frontend/three_d_garden/__tests__/progressive_load_test.tsx index 063eeb9ceb..ad2c6747f8 100644 --- a/frontend/three_d_garden/__tests__/progressive_load_test.tsx +++ b/frontend/three_d_garden/__tests__/progressive_load_test.tsx @@ -1,8 +1,9 @@ import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { FallInGroup, GridRevealGroup, LoadStepReady, PopInGroup, - THREE_D_LOAD_STEPS, ThreeDLoadProgressOverlay, useThreeDLoadProgress, + THREE_D_LOAD_PROGRESS_FADE_MS, THREE_D_LOAD_STEPS, + ThreeDLoadProgressOverlay, useThreeDLoadProgress, } from "../progressive_load"; describe("", () => { @@ -88,6 +89,7 @@ describe("3D load progress", () => { }; it("marks ready steps and hides the progress bar when complete", () => { + jest.useFakeTimers(); const consoleLog = jest.spyOn(console, "log").mockImplementation(jest.fn()); render(); @@ -107,9 +109,16 @@ describe("3D load progress", () => { }); expect(screen.getByTestId("current-step").textContent).toEqual("complete"); + expect(screen.getByText("Enjoy!")).toBeTruthy(); + expect(document.querySelector(".three-d-load-progress-complete")) + .toBeTruthy(); + act(() => { + jest.advanceTimersByTime(THREE_D_LOAD_PROGRESS_FADE_MS); + }); expect(document.querySelector(".three-d-load-progress")).toBeFalsy(); expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining("Total")); consoleLog.mockRestore(); + jest.useRealTimers(); }); it("allows steps as soon as their dependencies are ready", () => { diff --git a/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx b/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx index 06099b9c6c..f84a679b3f 100644 --- a/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx +++ b/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx @@ -55,7 +55,10 @@ describe("getCameraOffset()", () => { const config = clone(INITIAL); const configPosition = clone(INITIAL_POSITION); const focus = FOCI(config, configPosition)[0]; - expect(getCameraOffset(focus).position[1]).toEqual(0); + expect(getCameraOffset(focus).position[1]) + .toEqual(getCameraOffset(focus).target[1] - 25); + expect(getCameraOffset(focus).position[0]) + .toEqual(getCameraOffset(focus).target[0]); }); it("returns camera offset: narrow", () => { @@ -63,7 +66,10 @@ describe("getCameraOffset()", () => { const config = clone(INITIAL); const configPosition = clone(INITIAL_POSITION); const focus = FOCI(config, configPosition)[0]; - expect(getCameraOffset(focus).position[1]).toEqual(-1000); + expect(getCameraOffset(focus).position[1]) + .toEqual(getCameraOffset(focus).target[1] - 25); + expect(getCameraOffset(focus).position[0]) + .toEqual(getCameraOffset(focus).target[0]); }); }); @@ -75,8 +81,13 @@ describe("getCamera()", () => { position: [0, 0, 0], target: [0, 0, 0], }; - expect(getCamera(config, configPosition, "What you can grow", fallback).position[0]) - .toEqual(0); + const camera = getCamera( + config, + configPosition, + "What you can grow", + fallback, + ); + expect(camera.position[1]).not.toEqual(camera.target[1]); }); }); diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 5a6b47bd3b..e30086a8bf 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -46,6 +46,7 @@ import { VertexNormalsHelper } from "three/examples/jsm/Addons.js"; import { MoistureSurface } from "../garden/moisture_texture"; import { HeightMaterial } from "../garden/height_material"; import { soilSurfaceExtents } from "../triangles"; +import { FocusVisibilityGroup } from "../focus_transition"; const soil = ( Type: typeof LinePath | typeof Shape, @@ -382,7 +383,8 @@ export const Bed = (props: BedProps) => { ]}> - { y: threeSpace(bedWidthOuter, bedWidthOuter), z: groundZ, }} /> - + { return texture; }, [postWoodTextureBase]); - return { - ; + ; }; diff --git a/frontend/three_d_garden/bot/__tests__/bot_test.tsx b/frontend/three_d_garden/bot/__tests__/bot_test.tsx index 6f388a1c69..a4d9d85707 100644 --- a/frontend/three_d_garden/bot/__tests__/bot_test.tsx +++ b/frontend/three_d_garden/bot/__tests__/bot_test.tsx @@ -4,6 +4,10 @@ import { Bot, FarmbotModelProps } from "../bot"; import { INITIAL, INITIAL_POSITION } from "../../config"; import { clone } from "lodash"; import { SVGLoader } from "three/examples/jsm/Addons.js"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; describe("", () => { afterEach(() => { @@ -65,6 +69,15 @@ describe("", () => { expect(container.querySelectorAll("[name='button-group']").length).toEqual(3); }); + it("hides FarmBot in Planter bed focus", () => { + const p = fakeProps(); + p.activeFocus = "Planter bed"; + const wrapper = createRenderer(); + const bot = wrapper.root.findAll(node => node.props.name == "bot")[0]; + expect(bot.props.visible).toEqual(false); + unmountRenderer(wrapper); + }); + it("renders watering animation", () => { const p = fakeProps(); p.config.waterFlow = true; diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index fa55adae8c..59cb391e07 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -34,6 +34,7 @@ import { } from "./components"; import { SlotWithTool } from "../../resources/interfaces"; import { WateringAnimations } from "./components/watering_animations"; +import { FocusVisibilityGroup } from "../focus_transition"; export const extrusionWidth = 20; const utmRadius = 35; @@ -249,7 +250,8 @@ export const Bot = (props: FarmbotModelProps) => { zZero - zDir * z - 140 + zGantryOffset + 20, ); - return {[0 - extrusionWidth, bedWidthOuter].map((y, index) => { const bedColumnYOffset = @@ -655,5 +657,5 @@ export const Bot = (props: FarmbotModelProps) => { - ; + ; }; diff --git a/frontend/three_d_garden/config.ts b/frontend/three_d_garden/config.ts index 84b538c40d..f7066d3a0b 100644 --- a/frontend/three_d_garden/config.ts +++ b/frontend/three_d_garden/config.ts @@ -158,7 +158,7 @@ export const INITIAL: ConfigWithPosition = { showSoilPoints: false, plants: "Spring", labels: false, - labelsOnHover: false, + labelsOnHover: true, ground: true, grid: true, axes: false, diff --git a/frontend/three_d_garden/config_overlays.tsx b/frontend/three_d_garden/config_overlays.tsx index 9c19cfd961..6198e1ad3f 100644 --- a/frontend/three_d_garden/config_overlays.tsx +++ b/frontend/three_d_garden/config_overlays.tsx @@ -2,6 +2,7 @@ import React from "react"; import { ConfigWithPosition, modifyConfig } from "./config"; import { setUrlParam } from "./zoom_beacons_constants"; import { ExternalUrl } from "../external_urls"; +import { FocusVisibilityDiv } from "./focus_transition"; export interface ToolTip { timeoutId: number; @@ -15,6 +16,7 @@ export interface OverlayProps { setToolTip(tooltip: ToolTip): void; activeFocus: string; setActiveFocus(focus: string): void; + loadComplete?: boolean; startTimeRef?: React.RefObject; } @@ -74,10 +76,16 @@ const PublicOverlaySection = (props: SectionProps) => { export const PublicOverlay = (props: OverlayProps) => { const { config, setConfig, toolTip, setToolTip } = props; const commonSectionProps = { config, setConfig, toolTip, setToolTip }; + const settingsBarClassName = [ + "settings-bar", + props.loadComplete ? "settings-bar-loaded" : "", + ].join(" "); return
- {config.settingsBar && !props.activeFocus && -
+ +
{ "lab": "Lab", "greenhouse": "Greenhouse", }} /> -
} - {config.promoInfo && !props.activeFocus && +
+ + } + kitVersion={config.kitVersion} /> +
; }; @@ -129,7 +141,7 @@ interface PromoInfoProps { const PromoInfo = (props: PromoInfoProps) => { const { isGenesis, kitVersion } = props; - return
; + + ; }; interface ConfigRowProps { diff --git a/frontend/three_d_garden/focus_transition.tsx b/frontend/three_d_garden/focus_transition.tsx new file mode 100644 index 0000000000..eb3a8091f0 --- /dev/null +++ b/frontend/three_d_garden/focus_transition.tsx @@ -0,0 +1,590 @@ +import React from "react"; +import { useSpring } from "@react-spring/three"; +import { Material, Object3D } from "three"; +import { Group } from "./components"; +import { Camera, VectorXyz } from "./zoom_beacons_constants"; + +export const FOCUS_TRANSITION_MS = 900; + +export const easeInOutCubic = (t: number) => + t < 0.5 + ? 4 * t * t * t + : 1 - Math.pow(-2 * t + 2, 3) / 2; + +interface FocusTransitionContextValue { + enabled: boolean; + duration: number; +} + +const FocusTransitionContext = + React.createContext({ + enabled: false, + duration: FOCUS_TRANSITION_MS, + }); + +export interface FocusTransitionProviderProps { + enabled: boolean; + duration?: number; + children: React.ReactNode; +} + +export const FocusTransitionProvider = + (props: FocusTransitionProviderProps) => { + const parent = React.useContext(FocusTransitionContext); + const value = React.useMemo(() => ({ + enabled: props.enabled, + duration: props.duration || parent.duration || FOCUS_TRANSITION_MS, + }), [parent.duration, props.duration, props.enabled]); + return + {props.children} + ; + }; + +export const useFocusTransition = () => + React.useContext(FocusTransitionContext); + +interface MaterialState { + opacity: number; + transparent: boolean; + depthWrite: boolean; +} + +type MaterialSlot = Material | Material[]; + +interface MaterialOwner extends Object3D { + material?: MaterialSlot; +} + +interface MaterialRecord { + owner: MaterialOwner; + original: MaterialSlot; + clones: MaterialSlot; + states: MaterialState[]; +} + +const isMaterial = (value: unknown): value is Material => + !!value + && typeof value == "object" + && typeof (value as Material).clone == "function"; + +const materialState = (material: Material): MaterialState => ({ + opacity: material.opacity, + transparent: material.transparent, + depthWrite: material.depthWrite, +}); + +const cloneSlot = (slot: MaterialSlot): { + clones: MaterialSlot; + states: MaterialState[]; +} | undefined => { + if (Array.isArray(slot)) { + const clones = slot.map(material => material.clone()); + return { clones, states: clones.map(materialState) }; + } + if (!isMaterial(slot)) { return undefined; } + const clone = slot.clone(); + return { clones: clone, states: [materialState(clone)] }; +}; + +const forEachMaterial = + (slot: MaterialSlot, callback: (material: Material, index: number) => void) => { + if (Array.isArray(slot)) { + slot.map(callback); + } else if (isMaterial(slot)) { + callback(slot, 0); + } + }; + +export const applyFocusMaterialOpacity = ( + material: Material, + state: MaterialState, + opacity: number, + preserveDepthWrite = false, +) => { + material.opacity = state.opacity * opacity; + material.transparent = state.transparent || opacity < 1; + material.depthWrite = preserveDepthWrite || opacity >= 1 + ? state.depthWrite + : false; + material.needsUpdate = true; +}; + +interface CreateFocusMaterialBindingOptions { + preserveDepthWrite?: boolean; +} + +export const createFocusMaterialBinding = ( + root: Object3D, + options: CreateFocusMaterialBindingOptions = {}, +) => { + const records: MaterialRecord[] = []; + root.traverse(child => { + const owner = child as MaterialOwner; + if (!owner.material) { return; } + const clone = cloneSlot(owner.material); + if (!clone) { return; } + records.push({ + owner, + original: owner.material, + clones: clone.clones, + states: clone.states, + }); + owner.material = clone.clones; + }); + + return { + apply: (opacity: number) => { + records.map(record => + forEachMaterial(record.clones, (material, index) => + applyFocusMaterialOpacity( + material, + record.states[index], + opacity, + !!options.preserveDepthWrite, + ))); + }, + restore: () => { + records.map(record => { + record.owner.material = record.original; + forEachMaterial(record.clones, material => material.dispose()); + }); + }, + }; +}; + +const canTraverse = (value: unknown): value is Object3D => + !!value + && typeof value == "object" + && typeof (value as Object3D).traverse == "function"; + +const setVector = ( + vector: { set(x: number, y: number, z: number): void }, + values: VectorXyz, +) => vector.set(values[0], values[1], values[2]); + +export type FocusVisibilityGroupProps = + React.ComponentProps & { + visible: boolean; + keepMounted?: boolean; + materialBindingKey?: React.Key; + preserveDepthWrite?: boolean; + }; + +export const shouldUnmountFocusVisibilityGroup = ( + rendered: boolean, + visible: boolean, + keepMounted?: boolean, +) => !rendered && !visible && !keepMounted; + +export const FocusVisibilityGroup = + React.forwardRef((props, forwardedRef) => { + const { + visible, keepMounted, materialBindingKey, preserveDepthWrite, children, + ...groupProps + } = props; + const transition = useFocusTransition(); + const enabled = transition.enabled; + const [rendered, setRendered] = React.useState(visible || !!keepMounted); + const [groupVisible, setGroupVisible] = React.useState(visible); + const groupRef = React.useRef(undefined); + const opacityRef = React.useRef(visible ? 1 : 0); + const materialBinding = React.useRef | undefined>(undefined); + + const restoreMaterialBinding = React.useCallback(() => { + materialBinding.current?.restore(); + materialBinding.current = undefined; + }, []); + + const refreshMaterialBinding = React.useCallback(() => { + if (!enabled || !canTraverse(groupRef.current)) { return; } + restoreMaterialBinding(); + materialBinding.current = createFocusMaterialBinding(groupRef.current, { + preserveDepthWrite, + }); + materialBinding.current.apply(opacityRef.current); + }, [enabled, preserveDepthWrite, restoreMaterialBinding]); + + const setRef = React.useCallback((value: Object3D | null) => { + if (!value) { + groupRef.current = undefined; + restoreMaterialBinding(); + return; + } + groupRef.current = value; + if (enabled && canTraverse(value)) { + materialBinding.current ||= createFocusMaterialBinding(value, { + preserveDepthWrite, + }); + materialBinding.current.apply(opacityRef.current); + } + if (typeof forwardedRef == "function") { + forwardedRef(value); + } else if (forwardedRef) { + forwardedRef.current = value; + } + }, [enabled, forwardedRef, preserveDepthWrite, restoreMaterialBinding]); + + const applyOpacity = React.useCallback((opacity: number) => { + opacityRef.current = opacity; + if (!enabled || !canTraverse(groupRef.current)) { return; } + materialBinding.current ||= createFocusMaterialBinding(groupRef.current, { + preserveDepthWrite, + }); + materialBinding.current.apply(opacity); + }, [enabled, preserveDepthWrite]); + + React.useEffect(() => { + if (enabled && visible) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setRendered(true); + // eslint-disable-next-line react-hooks/set-state-in-effect + setGroupVisible(true); + } + }, [enabled, visible]); + + React.useEffect(() => { + if (materialBindingKey === undefined) { return; } + refreshMaterialBinding(); + }, [materialBindingKey, refreshMaterialBinding]); + + useSpring({ + opacity: visible ? 1 : 0, + immediate: !enabled, + config: { + duration: transition.duration, + easing: easeInOutCubic, + }, + onChange: result => { + const value = result.value as { opacity?: number }; + applyOpacity(value.opacity ?? (visible ? 1 : 0)); + }, + onRest: () => { + applyOpacity(visible ? 1 : 0); + if (enabled && !visible) { + if (keepMounted) { + setGroupVisible(false); + } else { + setRendered(false); + } + } + }, + }); + + React.useEffect(() => restoreMaterialBinding, [restoreMaterialBinding]); + + if (!enabled) { + return + {children} + ; + } + + if (shouldUnmountFocusVisibilityGroup(rendered, visible, keepMounted)) { + return undefined; + } + + return + {children} + ; + }); + +export const useFocusTransitionMount = (visible: boolean) => { + const transition = useFocusTransition(); + const [mounted, setMounted] = React.useState(visible); + + React.useEffect(() => { + if (transition.enabled && visible) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setMounted(true); + return; + } + if (!transition.enabled) { return; } + const timeout = window.setTimeout(() => + setMounted(false), transition.duration); + return () => window.clearTimeout(timeout); + }, [transition.duration, transition.enabled, visible]); + + return { + ...transition, + mounted: transition.enabled ? mounted || visible : visible, + }; +}; + +export const useFocusVisibilityClass = (visible: boolean) => { + const transition = useFocusTransitionMount(visible); + const [shown, setShown] = React.useState(false); + + React.useEffect(() => { + if (!transition.enabled) { return; } + if (!transition.mounted) { return; } + if (!visible) { + const frame = window.requestAnimationFrame(() => setShown(false)); + return () => window.cancelAnimationFrame(frame); + } + let secondFrame = 0; + const firstFrame = window.requestAnimationFrame(() => { + secondFrame = window.requestAnimationFrame(() => setShown(true)); + }); + return () => { + window.cancelAnimationFrame(firstFrame); + window.cancelAnimationFrame(secondFrame); + }; + }, [ + transition.enabled, + transition.mounted, + visible, + ]); + + const visibleClass = transition.enabled + ? visible && shown + : visible; + return { + ...transition, + className: visibleClass + ? "focus-transition-visible" + : "focus-transition-hidden", + }; +}; + +export interface FocusVisibilityDivProps + extends React.HTMLAttributes { + visible: boolean; + className: string; + children: React.ReactNode; +} + +export const FocusVisibilityDiv = (props: FocusVisibilityDivProps) => { + const { + visible, className: incomingClassName, children, ...divProps + } = props; + const transition = useFocusVisibilityClass(visible); + + if (!transition.mounted) { return undefined; } + const className = [ + incomingClassName, + "focus-transition-dom", + transition.className, + ].join(" "); + return
+ {children} +
; +}; + +export interface SmoothCameraState extends Camera { + zoom: number; +} + +const vectorFromSpring = (value: unknown, fallback: VectorXyz): VectorXyz => + Array.isArray(value) && value.length == 3 + ? [Number(value[0]), Number(value[1]), Number(value[2])] + : fallback; + +export const cameraTransitionValue = ( + value: Partial>, + fallback: SmoothCameraState, +): SmoothCameraState => ({ + position: vectorFromSpring(value.position, fallback.position), + target: vectorFromSpring(value.target, fallback.target), + zoom: typeof value.zoom == "number" ? value.zoom : fallback.zoom, +}); + +export const interpolateCameraState = ( + from: SmoothCameraState, + to: SmoothCameraState, + progress: number, +): SmoothCameraState => { + const lerp = (start: number, end: number) => + start + (end - start) * progress; + return { + position: [ + lerp(from.position[0], to.position[0]), + lerp(from.position[1], to.position[1]), + lerp(from.position[2], to.position[2]), + ], + target: [ + lerp(from.target[0], to.target[0]), + lerp(from.target[1], to.target[1]), + lerp(from.target[2], to.target[2]), + ], + zoom: lerp(from.zoom, to.zoom), + }; +}; + +const cameraKey = (state: SmoothCameraState) => + [ + ...state.position, + ...state.target, + state.zoom, + ].join(","); + +export interface SmoothCameraObject { + position: { + x?: number; + y?: number; + z?: number; + set(x: number, y: number, z: number): void; + toArray?(): number[]; + }; + zoom: number; + lookAt?(x: number, y: number, z: number): void; + updateProjectionMatrix?(): void; +} + +export interface SmoothCameraControls { + target: { + x?: number; + y?: number; + z?: number; + set(x: number, y: number, z: number): void; + toArray?(): number[]; + }; + update?(): void; +} + +const readVector = ( + vector: SmoothCameraObject["position"] | SmoothCameraControls["target"] | undefined, + fallback: VectorXyz, +): VectorXyz => { + const values = vector?.toArray?.(); + if (values && values.length >= 3) { + return [values[0], values[1], values[2]]; + } + if ( + typeof vector?.x == "number" + && typeof vector.y == "number" + && typeof vector.z == "number" + ) { + return [vector.x, vector.y, vector.z]; + } + return fallback; +}; + +export const readSmoothCameraState = ( + fallback: SmoothCameraState, + cameraObject?: SmoothCameraObject | null, + controls?: SmoothCameraControls | null, +): SmoothCameraState => ({ + position: readVector(cameraObject?.position, fallback.position), + target: readVector(controls?.target, fallback.target), + zoom: typeof cameraObject?.zoom == "number" + ? cameraObject.zoom + : fallback.zoom, +}); + +export const applySmoothCameraState = ( + state: SmoothCameraState, + cameraObject?: SmoothCameraObject | null, + controls?: SmoothCameraControls | null, +) => { + if (cameraObject && typeof cameraObject.position?.set == "function") { + setVector(cameraObject.position, state.position); + cameraObject.zoom = state.zoom; + cameraObject.updateProjectionMatrix?.(); + cameraObject.lookAt?.(...state.target); + } + if (controls && typeof controls.target?.set == "function") { + setVector(controls.target, state.target); + controls.update?.(); + } +}; + +export interface UseSmoothCameraProps { + camera: Camera; + zoom: number; + enabled: boolean; + cameraObject?: SmoothCameraObject | null; + controls?: SmoothCameraControls | null; +} + +export const useSmoothCamera = (props: UseSmoothCameraProps) => { + const transition = useFocusTransition(); + const [positionX, positionY, positionZ] = props.camera.position; + const [targetX, targetY, targetZ] = props.camera.target; + const target = React.useMemo(() => ({ + position: [positionX, positionY, positionZ], + target: [targetX, targetY, targetZ], + zoom: props.zoom, + }), [ + positionX, + positionY, + positionZ, + props.zoom, + targetX, + targetY, + targetZ, + ]); + const [displayCamera, setDisplayCamera] = React.useState(target); + const displayRef = React.useRef(displayCamera); + const key = cameraKey(target); + + React.useEffect(() => { + displayRef.current = displayCamera; + }, [displayCamera]); + + const appliedCamera = props.enabled ? displayCamera : target; + React.useLayoutEffect(() => { + applySmoothCameraState( + appliedCamera, + props.cameraObject, + props.controls, + ); + }, [ + appliedCamera, + props.cameraObject, + props.controls, + ]); + + React.useEffect(() => { + if (!props.enabled) { + displayRef.current = target; + applySmoothCameraState( + target, + props.cameraObject, + props.controls, + ); + return; + } + const from = readSmoothCameraState( + displayRef.current, + props.cameraObject, + props.controls, + ); + const startedAt = performance.now(); + let frame = 0; + const tick = () => { + const elapsed = performance.now() - startedAt; + const progress = Math.min(elapsed / transition.duration, 1); + const next = interpolateCameraState( + from, + target, + easeInOutCubic(progress), + ); + displayRef.current = next; + setDisplayCamera(next); + applySmoothCameraState(next, props.cameraObject, props.controls); + if (progress < 1) { + frame = window.requestAnimationFrame(tick); + } else { + displayRef.current = target; + setDisplayCamera(target); + applySmoothCameraState( + target, + props.cameraObject, + props.controls, + ); + } + }; + frame = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(frame); + }, [ + key, + props.cameraObject, + props.controls, + props.enabled, + target, + transition.duration, + ]); + + return props.enabled ? displayCamera : target; +}; diff --git a/frontend/three_d_garden/garden/__tests__/clouds_test.tsx b/frontend/three_d_garden/garden/__tests__/clouds_test.tsx index d2884a48c1..dde827c525 100644 --- a/frontend/three_d_garden/garden/__tests__/clouds_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/clouds_test.tsx @@ -13,4 +13,11 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("clouds"); }); + + it("doesn't mount zero-opacity clouds", () => { + const p = fakeProps(); + p.config.plants = "Summer"; + const { container } = render(); + expect(container).not.toContainHTML("clouds"); + }); }); diff --git a/frontend/three_d_garden/garden/__tests__/grid_test.tsx b/frontend/three_d_garden/garden/__tests__/grid_test.tsx index 4b6556407f..9064c63628 100644 --- a/frontend/three_d_garden/garden/__tests__/grid_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/grid_test.tsx @@ -1,8 +1,13 @@ import React from "react"; import { render } from "@testing-library/react"; import { Grid, gridLineOffsets, GridProps } from "../grid"; -import { INITIAL } from "../../config"; +import { INITIAL, PRESETS } from "../../config"; import { clone } from "lodash"; +import { + actRenderer, + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; describe("gridLineOffsets()", () => { it("calculates offsets", () => { @@ -25,4 +30,20 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("grid"); }); + + it("refreshes focus material binding when grid dimensions change", () => { + const p = fakeProps(); + p.config.grid = true; + const wrapper = createRenderer(); + const findGridGroup = () => wrapper.root.findAll(node => + node.props.name == "garden-grid")[0]; + const genesisBindingKey = findGridGroup().props.materialBindingKey; + p.config = { ...p.config, ...PRESETS["Genesis XL"] }; + actRenderer(() => { + wrapper.update(); + }); + expect(findGridGroup().props.materialBindingKey) + .not.toEqual(genesisBindingKey); + unmountRenderer(wrapper); + }); }); diff --git a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx index 04324976b8..e0180e5332 100644 --- a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx @@ -39,6 +39,10 @@ import { PLANT_ICON_ATLAS } from "../plant_icon_atlas"; import { Mode } from "../../../farm_designer/map/interfaces"; import * as mapUtil from "../../../farm_designer/map/util"; import * as meshKey from "../instanced_mesh_key"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; describe("", () => { let reactUseRefSpy: jest.SpyInstance; @@ -100,6 +104,24 @@ describe("", () => { expect(meshes.length).toBe(2); }); + it("uses reserved icon capacity while rendering only active plants", () => { + const p = fakeProps(); + p.plants = [p.plants[0]]; + p.iconCapacities = { [p.plants[0].icon]: 10 }; + const { container } = render(); + const mesh = container.querySelector("instancedmesh"); + expect(mesh?.getAttribute("args")).toContain("10"); + expect(mesh?.getAttribute("count")).toEqual("1"); + }); + + it("disables frustum culling for billboarded plant icons", () => { + const wrapper = createRenderer(); + const mesh = wrapper.root.findAll(node => + node.type == "instancedMesh")[0]; + expect(mesh.props.frustumCulled).toEqual(false); + unmountRenderer(wrapper); + }); + it("doesn't build per-plant mesh keys while rendering", () => { const keySpy = jest.spyOn(meshKey, "instancedMeshKey"); render(); diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index b434f87f0e..6c2c5a5bc7 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -191,6 +191,17 @@ describe("", () => { expect(container.querySelectorAll("instancedmesh").length).toBe(1); }); + it("uses reserved spread capacity while rendering active plants", () => { + location.pathname = Path.mock(Path.cropSearch("mint")); + queueMeshRef(); + const p = fakeProps(); + p.instanceCapacity = 10; + const { container } = render(); + const mesh = container.querySelector("instancedmesh"); + expect(mesh?.getAttribute("args")).toContain("10"); + expect(mesh?.getAttribute("count")).toEqual("2"); + }); + it("doesn't build per-plant spread mesh keys while rendering", () => { const keySpy = jest.spyOn(meshKey, "instancedMeshKey"); queueMeshRef(); diff --git a/frontend/three_d_garden/garden/__tests__/solar_test.tsx b/frontend/three_d_garden/garden/__tests__/solar_test.tsx index aec76f6bb8..ddc0171d03 100644 --- a/frontend/three_d_garden/garden/__tests__/solar_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/solar_test.tsx @@ -3,6 +3,13 @@ import { render } from "@testing-library/react"; import { Solar, SolarProps } from "../solar"; import { INITIAL } from "../../config"; import { clone } from "lodash"; +import { FocusTransitionProvider } from "../../focus_transition"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; +import { RenderOrder } from "../../constants"; +import { DoubleSide } from "three"; describe("", () => { const fakeProps = (): SolarProps => ({ @@ -16,4 +23,44 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("solar"); }); + + it("keeps solar mounted during focus transitions", () => { + const { container } = render( + + + , + ); + expect(container).toContainHTML("solar-wiring"); + }); + + it("doesn't cull instanced solar cells", () => { + const p = fakeProps(); + p.config.solar = true; + const wrapper = createRenderer(); + const solarCells = wrapper.root.findAll(node => + node.type == "instancedMesh"); + expect(solarCells[0].props.frustumCulled).toEqual(false); + expect(solarCells[0].props.renderOrder).toEqual(RenderOrder.one + 1); + unmountRenderer(wrapper); + }); + + it("renders solar cells above panels and wiring", () => { + const p = fakeProps(); + p.config.solar = true; + const wrapper = createRenderer(); + const wiring = wrapper.root.findAll(node => + node.props.name == "solar-wiring")[0]; + const panel = wrapper.root.findAll(node => + node.type == "mesh" && node.props.renderOrder == RenderOrder.one)[0]; + const cells = wrapper.root.findAll(node => + node.type == "instancedMesh")[0]; + const cellMaterial = wrapper.root.findAll(node => + node.props.side == DoubleSide)[0]; + expect(wiring.props.renderOrder).toEqual(RenderOrder.default); + expect(panel.props.renderOrder).toEqual(RenderOrder.one); + expect(Number(cells.props.renderOrder)) + .toBeGreaterThan(Number(panel.props.renderOrder)); + expect(cellMaterial.props.side).toEqual(DoubleSide); + unmountRenderer(wrapper); + }); }); diff --git a/frontend/three_d_garden/garden/__tests__/zoom_beacons_test.tsx b/frontend/three_d_garden/garden/__tests__/zoom_beacons_test.tsx index abec987e83..af8dfe290d 100644 --- a/frontend/three_d_garden/garden/__tests__/zoom_beacons_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/zoom_beacons_test.tsx @@ -11,6 +11,8 @@ import { createRenderer, unmountRenderer, } from "../../../__test_support__/test_renderer"; +import { FocusTransitionProvider } from "../../focus_transition"; +import { RenderOrder } from "../../constants"; const originalDocumentQuerySelector = document.querySelector.bind(document); let isDesktopSpy: jest.SpyInstance; @@ -78,7 +80,7 @@ describe("", () => { unmountRenderer(wrapper); }); - it("changes cursor", () => { + it("hides beacon while focused", () => { const element = document.createElement("div"); Object.defineProperty(document, "querySelector", { value: () => element, @@ -89,19 +91,50 @@ describe("", () => { p.config.animate = false; const wrapper = createRenderer(); const sphere = wrapper.root.findAll(node => node.props.name == "beacon-sphere")[0]; - actRenderer(() => { - sphere?.props.onPointerEnter(); - }); - expect(element.style.cursor).toEqual("zoom-out"); - actRenderer(() => { - sphere?.props.onPointerLeave(); - }); - expect(element.style.cursor).toEqual(""); - actRenderer(() => { - sphere?.props.onClick(); - }); + expect(sphere).toBeUndefined(); expect(element.style.cursor).toEqual(""); - expect(p.setActiveFocus).toHaveBeenCalledWith(""); + expect(p.setActiveFocus).not.toHaveBeenCalled(); + unmountRenderer(wrapper); + }); + + it("renders visible beacons without writing depth", () => { + const p = fakeProps(); + p.config.animate = false; + const wrapper = createRenderer(); + const sphere = wrapper.root.findAll(node => + node.props.name == "beacon-sphere")[0]; + const materials = wrapper.root.findAll(node => + node.props.depthWrite === false); + expect(sphere.props.renderOrder).toEqual(RenderOrder.beacons); + expect(materials[0].props.depthTest).toBeUndefined(); + expect(materials[0].props.depthWrite).toEqual(false); + unmountRenderer(wrapper); + }); + + it("applies load-in scale on each anchored beacon visual", () => { + const p = fakeProps(); + p.config.animate = false; + const wrapper = createRenderer(); + const beacon = wrapper.root.findAll(node => + node.props.name == "zoom-beacon")[0]; + const visual = wrapper.root.findAll(node => + node.props.name == "beacon-visual")[0]; + expect(beacon.props.position).toBeTruthy(); + expect(visual.props.scale).toEqual(0.35); + unmountRenderer(wrapper); + }); + + it("doesn't mount stable focused beacon visuals", () => { + const p = fakeProps(); + p.activeFocus = "What you can grow"; + p.config.animate = false; + const wrapper = createRenderer( + + + , + ); + expect(wrapper.root.findAll(node => + node.props.name == "beacon-sphere").length).toEqual(0); unmountRenderer(wrapper); }); @@ -129,7 +162,8 @@ describe("", () => { p.config.animate = false; const wrapper = createRenderer(); const e = { stopPropagation: jest.fn() }; - const info = wrapper.root.findAll(node => node.props.className == "beacon-info")[0]; + const info = wrapper.root.findAll(node => + (node.props.className || "").includes("beacon-info"))[0]; actRenderer(() => { info?.props.onPointerDown(e); info?.props.onPointerMove(e); diff --git a/frontend/three_d_garden/garden/clouds.tsx b/frontend/three_d_garden/garden/clouds.tsx index 0a4fd62d99..3f24373fab 100644 --- a/frontend/three_d_garden/garden/clouds.tsx +++ b/frontend/three_d_garden/garden/clouds.tsx @@ -2,18 +2,29 @@ import React from "react"; import { Config, getSeasonProperties } from "../config"; import { Cloud, Clouds as DreiClouds } from "@react-three/drei"; import { ASSETS, RenderOrder } from "../constants"; +import { animated, useSpring } from "@react-spring/three"; export interface CloudsProps { config: Config; } +const AnimatedCloud = animated(Cloud); + export const Clouds = (props: CloudsProps) => { const { config } = props; const sunParams = getSeasonProperties(config, "Summer"); + const targetOpacity = sunParams.cloudOpacity; + const { opacity } = useSpring({ + from: { opacity: 0 }, + to: { opacity: targetOpacity }, + immediate: !config.animate, + config: { duration: 600 }, + }); + if (!config.clouds || targetOpacity <= 0) { return undefined; } return - { color="#ccc" growth={400} speed={.1} - opacity={sunParams.cloudOpacity} + opacity={opacity} fade={5000} /> ; }; diff --git a/frontend/three_d_garden/garden/grid.tsx b/frontend/three_d_garden/garden/grid.tsx index a853aace2e..06a6c912b8 100644 --- a/frontend/three_d_garden/garden/grid.tsx +++ b/frontend/three_d_garden/garden/grid.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Config } from "../config"; -import { Group, Primitive } from "../components"; +import { Primitive } from "../components"; import { get3DPositionFunc, zero as zeroFunc, } from "../helpers"; @@ -13,6 +13,7 @@ import { LineSegmentsGeometry, } from "three/examples/jsm/lines/LineSegmentsGeometry.js"; import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js"; +import { FocusVisibilityGroup } from "../focus_transition"; export const gridLineOffsets = (botDimension: number): number[] => { const lastRegularOffset = floor(botDimension, -2); @@ -132,8 +133,17 @@ export const Grid = (props: GridProps) => { }); return result; }, [config, props.getZ]); - return { color={"white"} opacity={0.5} linewidth={2} /> - ; + ; }; diff --git a/frontend/three_d_garden/garden/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx index 5b67931d0c..8f14055f1a 100644 --- a/frontend/three_d_garden/garden/plant_instances.tsx +++ b/frontend/three_d_garden/garden/plant_instances.tsx @@ -34,12 +34,14 @@ export interface PlantInstancesProps { visible?: boolean; startTimeRef?: React.RefObject; dispatch?: Function; + iconCapacities?: Record; } interface PlantIconInstancesProps extends PlantInstancesProps { icon: string; plants: ThreeDGardenPlant[]; plantIndexes: number[]; + capacity: number; } interface PlantIconUpdateState { @@ -96,7 +98,7 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { const updateState = getUpdateState(); updateState.needsMatrixUpdate = true; lastBrightness.current = undefined; - }); + }, [config, getZ, plants, startTimeRef]); // eslint-disable-next-line complexity useFrame(state => { @@ -161,7 +163,9 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { return { icon: plant.icon, plants: [plant], plantIndexes: [index], + capacity: 0, startTimeRef: props.startTimeRef, visible: props.visible, }; } }); - return Object.values(iconInstances); + return Object.values(iconInstances).map(instance => ({ + ...instance, + capacity: Math.max( + instance.plants.length, + props.iconCapacities?.[instance.icon] || 0, + ), + })); }, [ props.config, props.dispatch, props.getZ, + props.iconCapacities, props.plants, props.startTimeRef, props.visible, @@ -209,7 +221,7 @@ export const PlantInstances = React.memo((props: PlantInstancesProps) => { return <> {instances.map(instance => )} ; }); diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index bfa68db5e2..da902ec3e1 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -101,6 +101,7 @@ export interface PlantSpreadInstancesProps { dispatch?: Function; activePositionRef: ActivePositionRef; spreadVisible: boolean; + instanceCapacity?: number; } interface PlantSpreadUpdateState { @@ -115,6 +116,7 @@ export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps const { config, plants, getZ, visible, dispatch, activePositionRef, spreadVisible, } = props; + const instanceCapacity = Math.max(props.instanceCapacity || 0, plants.length); const navigate = useNavigate(); // eslint-disable-next-line no-null/no-null const instancedRef = React.useRef(null); @@ -157,9 +159,9 @@ export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps const ensureInstanceColor = React.useCallback((mesh: InstancedMeshType) => { const needsResize = !mesh.instanceColor - || mesh.instanceColor.count != plants.length; + || mesh.instanceColor.count != instanceCapacity; if (needsResize) { - const colors = new Float32Array(plants.length * 3); + const colors = new Float32Array(instanceCapacity * 3); colors.fill(1); mesh.instanceColor = new InstancedBufferAttribute(colors, 3); if (mesh.geometry) { @@ -173,7 +175,7 @@ export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps material.needsUpdate = true; } } - }, [plants.length]); + }, [instanceCapacity]); React.useLayoutEffect(() => { const mesh = instancedRef.current; @@ -184,7 +186,7 @@ export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps React.useEffect(() => { const updateState = getUpdateState(); updateState.needsInstanceUpdate = true; - }); + }, [activeDragSpread, config, getZ, plants]); // eslint-disable-next-line complexity useFrame(state => { @@ -266,9 +268,10 @@ export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps }; return diff --git a/frontend/three_d_garden/garden/solar.tsx b/frontend/three_d_garden/garden/solar.tsx index be72a50ddf..3a10432670 100644 --- a/frontend/three_d_garden/garden/solar.tsx +++ b/frontend/three_d_garden/garden/solar.tsx @@ -1,9 +1,18 @@ import React from "react"; -import { Shape } from "three"; -import { Extrude, Line } from "@react-three/drei"; +import { + DoubleSide, ExtrudeGeometry, InstancedMesh as ThreeInstancedMesh, + Object3D, Shape, +} from "three"; +import { Line } from "@react-three/drei"; +import { animated, useSpring } from "@react-spring/three"; +import { SpringValue } from "@react-spring/core"; import { threeSpace } from "../helpers"; import { Config } from "../config"; -import { Group, Mesh, BoxGeometry, MeshPhongMaterial } from "../components"; +import { + Group, Mesh, BoxGeometry, MeshPhongMaterial, InstancedMesh, +} from "../components"; +import { easeInOutCubic, useFocusTransition } from "../focus_transition"; +import { RenderOrder } from "../constants"; export interface SolarProps { config: Config; @@ -12,6 +21,11 @@ export interface SolarProps { const panelWidth = 540; const panelLength = 1040; +const panelDepth = 30; +const cellDepth = 2; +const cellZ = panelDepth / 2 + cellDepth + 1; +const AnimatedMeshPhongMaterial = animated(MeshPhongMaterial); +const AnimatedLine = animated(Line); const cell2D = () => { const cellSize = 95; @@ -28,8 +42,8 @@ const cell2D = () => { return path; }; -const cellArray = () => { - const cells = []; +const cellPositions = () => { + const positions: [number, number, number][] = []; const cellSize = 100; const cellsWide = Math.floor(panelWidth / cellSize); const cellsLong = Math.floor(panelLength / cellSize); @@ -38,32 +52,85 @@ const cellArray = () => { for (let y = 0; y < cellsLong; y++) { const xPos = x * cellSize - (panelWidth / 2) + 20 + 2.5; const yPos = y * cellSize - (panelLength / 2) + 20 + 2.5; - cells.push( - - - - - ); + positions.push([xPos, yPos, cellZ]); } } - return cells; + return positions; }; -const SolarPanel = () => { +const CELL_POSITIONS = cellPositions(); + +interface SolarMaterialProps { + opacity: number | SpringValue; + color: string; + side?: typeof DoubleSide; +} + +const SolarMaterial = (props: SolarMaterialProps) => + ; + +const SolarCells = (props: { opacity: SolarMaterialProps["opacity"] }) => { + const geometry = React.useMemo( + () => new ExtrudeGeometry(cell2D(), { + steps: 1, + depth: cellDepth, + bevelEnabled: false, + }), + [], + ); + const setRef = React.useCallback((mesh: ThreeInstancedMesh | null) => { + if (!mesh || typeof mesh.setMatrixAt != "function") { return; } + const dummy = new Object3D(); + CELL_POSITIONS.map((position, index) => { + dummy.position.set(...position); + dummy.updateMatrix(); + mesh.setMatrixAt(index, dummy.matrix); + }); + mesh.instanceMatrix.needsUpdate = true; + }, []); + + return + + ; +}; + +const SolarPanel = (props: { opacity: SolarMaterialProps["opacity"] }) => { return - - - + + + - {cellArray()} + ; }; export const Solar = (props: SolarProps) => { const { config } = props; const zGround = -config.bedZOffset - config.bedHeight; - return + const transition = useFocusTransition(); + const visible = config.solar || props.activeFocus == "What you need to provide"; + const { opacity } = useSpring({ + opacity: visible ? 1 : 0, + immediate: !transition.enabled, + config: { + duration: transition.duration, + easing: easeInOutCubic, + }, + }); + const rendered = transition.enabled || visible; + if (!rendered) { return undefined; } + + return { ]} rotation={[0, 0, Math.PI]}> - + - + - { zGround + 20, ]]} color={"yellow"} + transparent={true} + opacity={opacity} lineWidth={5} /> ; }; diff --git a/frontend/three_d_garden/garden/zoom_beacons.tsx b/frontend/three_d_garden/garden/zoom_beacons.tsx index 0556064838..bbde52f670 100644 --- a/frontend/three_d_garden/garden/zoom_beacons.tsx +++ b/frontend/three_d_garden/garden/zoom_beacons.tsx @@ -2,30 +2,39 @@ import { Sphere, Html, Line } from "@react-three/drei"; import React from "react"; import { Config, PositionConfig } from "../config"; import { FOCI, getCameraOffset, setUrlParam } from "../zoom_beacons_constants"; -import { useSpring, animated } from "@react-spring/three"; -import { Group, Mesh, MeshPhongMaterial } from "../components"; +import { animated, useSpring } from "@react-spring/three"; +import { SpringValue, to } from "@react-spring/core"; +import { Group, MeshPhongMaterial, Mesh } from "../components"; import { isDesktop } from "../../screen_size"; import { RenderOrder } from "../constants"; +import { + easeInOutCubic, useFocusTransition, useFocusVisibilityClass, +} from "../focus_transition"; const beaconColor = "#0266b5"; const AnimatedMesh = animated(Mesh); +const AnimatedGroup = animated(Group); const AnimatedMeshPhongMaterial = animated(MeshPhongMaterial); +type Focus = ReturnType[number]; export interface ZoomBeaconsProps { config: Config; configPosition: PositionConfig; activeFocus: string; setActiveFocus(focus: string): void; + loadInOpacity?: SpringValue; + loadInScale?: SpringValue | number; } interface BeaconPulseProps { beaconSize: number; animate: boolean; + parentOpacity: SpringValue; } const BeaconPulse = (props: BeaconPulseProps) => { - const { beaconSize, animate } = props; + const { beaconSize, animate, parentOpacity } = props; const { scale, opacity } = useSpring({ from: { scale: 1, opacity: 0.75 }, to: async (next) => { @@ -43,12 +52,122 @@ const BeaconPulse = (props: BeaconPulseProps) => { renderOrder={RenderOrder.beacons}> + pulse * parent)} + depthWrite={false} transparent={true} /> ; }; +interface BeaconVisualProps { + activeFocus: string; + beaconSize: number; + hovered: boolean; + onClick(): void; + onPointerEnter(): void; + onPointerLeave(): void; + config: Config; + loadInOpacity?: SpringValue; + loadInScale?: SpringValue | number; +} + +const BeaconVisual = (props: BeaconVisualProps) => { + const transition = useFocusTransition(); + const visible = !props.activeFocus; + const [rendered, setRendered] = React.useState(visible); + const { opacity } = useSpring({ + opacity: visible ? 1 : 0, + immediate: !transition.enabled, + config: { + duration: transition.duration, + easing: easeInOutCubic, + }, + onRest: () => { + if (transition.enabled && !visible) { + setRendered(false); + } + }, + }); + + React.useEffect(() => { + if (transition.enabled && visible) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setRendered(true); + } + }, [transition.enabled, visible]); + + if (!rendered && !visible) { return undefined; } + const beaconOpacity = props.loadInOpacity + ? to([opacity, props.loadInOpacity], (focus, load) => focus * load) + : opacity; + + return + + + + } /> + ; +}; + +interface BeaconInfoProps { + focus: Focus; + active: boolean; + onExit(): void; +} + +const BeaconInfo = (props: BeaconInfoProps) => { + const transition = useFocusVisibilityClass(props.active); + if (!transition.mounted) { return undefined; } + const className = [ + "beacon-info", + "focus-transition-opacity", + transition.className, + ].join(" "); + return +
e.stopPropagation()} + onPointerMove={e => e.stopPropagation()}> +
+

{props.focus.label}

+
+ ❌ +
+
+ {props.focus.info.description} +
+ ; +}; + export const ZoomBeacons = (props: ZoomBeaconsProps) => { const [hoveredFocus, setHoveredFocus] = React.useState(""); const { activeFocus, setActiveFocus } = props; @@ -60,6 +179,19 @@ export const ZoomBeacons = (props: ZoomBeaconsProps) => { return {FOCI(props.config, props.configPosition).map(focus => { const camera = getCameraOffset(focus); + const exitFocus = () => { + setActiveFocus(""); + setUrlParam("focus", ""); + }; + const enterFocus = () => { + if (activeFocus) { return; } + setActiveFocus(focus.label); + setUrlParam("focus", focus.label); + setHoveredFocus(""); + if (gardenBedDiv) { + gardenBedDiv.style.cursor = ""; + } + }; return {props.config.zoomBeaconDebug && @@ -71,19 +203,19 @@ export const ZoomBeacons = (props: ZoomBeaconsProps) => { } - { - setActiveFocus(activeFocus ? "" : focus.label); - setUrlParam("focus", focus.label); - setHoveredFocus(""); - if (gardenBedDiv) { - gardenBedDiv.style.cursor = ""; - } - }} + { + if (activeFocus) { return; } setHoveredFocus(focus.label); if (gardenBedDiv) { - gardenBedDiv.style.cursor = activeFocus ? "zoom-out" : "zoom-in"; + gardenBedDiv.style.cursor = "zoom-in"; } }} onPointerLeave={() => { @@ -91,44 +223,11 @@ export const ZoomBeacons = (props: ZoomBeaconsProps) => { if (gardenBedDiv) { gardenBedDiv.style.cursor = ""; } - }} - receiveShadow={false} - castShadow={false} - visible={!activeFocus} - args={[ - beaconSize - * (hoveredFocus == focus.label ? 1.5 : 1) - * ((!activeFocus && props.config.sizePreset == "Genesis XL") ? 1.5 : 1), - 12, - 12, - ]}> - - - {!activeFocus && - } - {activeFocus == focus.label && - -
e.stopPropagation()} - onPointerMove={e => e.stopPropagation()}> -
-

{focus.label}

-
{ - setActiveFocus(""); - setUrlParam("focus", ""); - }}> - ❌ -
-
- {focus.info.description} -
- } + }} /> +
; })}
; diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index eb4f98d2a1..0ae8a234b2 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -23,6 +23,7 @@ import { NorthArrow, skyColor, ThreeDPlantLabel, + ZoomBeaconsProps, } from "./garden"; import { Config, PositionConfig } from "./config"; import { useSpring, animated } from "@react-spring/three"; @@ -53,14 +54,44 @@ import { PerfMark, perfMark, usePerfRenderCount, } from "../performance/perf"; import { - FallInGroup, GridRevealGroup, LoadStepReady, PopInGroup, + botLoadInConfig, FallInGroup, GridRevealGroup, LoadStepReady, PopInGroup, ThreeDLoadProgress, ThreeDLoadProgressOverlay, ThreeDLoadStepId, useThreeDLoadProgress, } from "./progressive_load"; +import { + FocusTransitionProvider, FocusVisibilityGroup, SmoothCameraControls, + useSmoothCamera, +} from "./focus_transition"; const AnimatedGroup = animated(Group); const LazyBot = React.lazy(() => import("./bot").then(module => ({ default: module.Bot }))); +export const SMOOTH_XL_CAMERA_BED_SCALE = 1.9; +export const SMOOTH_XL_CAMERA_HEIGHT_SCALE = 1.45; + +interface ZoomBeaconsLoadInProps extends ZoomBeaconsProps { + onRest?: () => void; +} + +const ZoomBeaconsLoadIn = (props: ZoomBeaconsLoadInProps) => { + const { onRest, ...zoomBeaconProps } = props; + const { scale, opacity } = useSpring({ + from: { scale: 0.35, opacity: 0 }, + to: { scale: 1, opacity: 1 }, + onRest, + config: { + tension: 220, + friction: 26, + }, + }); + + return + + ; +}; interface SceneBoundaryProps { markName?: string; @@ -104,12 +135,16 @@ export interface GardenModelProps { images?: TaggedImage[]; sensorReadings?: TaggedSensorReading[]; sensors?: TaggedSensor[]; + smoothFocusTransitions?: boolean; + plantIconCapacities?: Record; + plantInstanceCapacity?: number; + onLoadComplete?(): void; } // eslint-disable-next-line complexity export const GardenModel = (props: GardenModelProps) => { usePerfRenderCount("GardenModel"); - const { config, addPlantProps, threeDPlants } = props; + const { config, addPlantProps, onLoadComplete, threeDPlants } = props; const dispatch = addPlantProps?.dispatch; const Camera = config.perspective ? PerspectiveCamera : OrthographicCamera; @@ -141,8 +176,13 @@ export const GardenModel = (props: GardenModelProps) => { }; const isXL = config.sizePreset == "Genesis XL"; + let modelScale = 1; + if (!props.smoothFocusTransitions && isXL) { + modelScale = 1.75; + } const { scale } = useSpring({ - scale: isXL ? 1.75 : 1, + scale: modelScale, + immediate: props.smoothFocusTransitions && !config.animate, config: { tension: 300, friction: 40, @@ -154,16 +194,40 @@ export const GardenModel = (props: GardenModelProps) => { const topDownCameraAngle = config.topDown ? baseAngle + heading * Math.PI / 180 : undefined; + const cameraBedScale = props.smoothFocusTransitions && isXL + ? SMOOTH_XL_CAMERA_BED_SCALE + : 1; + const cameraBedSize = React.useMemo(() => ({ + x: config.bedLengthOuter * cameraBedScale, + y: config.bedWidthOuter * cameraBedScale, + }), [ + config.bedLengthOuter, + config.bedWidthOuter, + cameraBedScale, + ]); const defaultCamera = React.useMemo( - () => cameraInit({ - topDown: config.topDown, - viewpointHeading: config.viewpointHeading, - bedSize: { x: config.bedLengthOuter, y: config.bedWidthOuter }, - }), [ - config.bedLengthOuter, - config.bedWidthOuter, + () => { + const nextCamera = cameraInit({ + topDown: config.topDown, + viewpointHeading: config.viewpointHeading, + bedSize: cameraBedSize, + }); + return props.smoothFocusTransitions && isXL + ? { + ...nextCamera, + position: [ + nextCamera.position[0], + nextCamera.position[1], + nextCamera.position[2] * SMOOTH_XL_CAMERA_HEIGHT_SCALE, + ] as typeof nextCamera.position, + } + : nextCamera; + }, [ + cameraBedSize, config.topDown, config.viewpointHeading, + isXL, + props.smoothFocusTransitions, ]); const camera = props.activeFocus ? getCamera(config, props.configPosition, props.activeFocus, defaultCamera) @@ -171,7 +235,11 @@ export const GardenModel = (props: GardenModelProps) => { const [controlsCamera, setControlsCamera] = // eslint-disable-next-line no-null/no-null React.useState(null); + const [controls, setControls] = + // eslint-disable-next-line no-null/no-null + React.useState(null); const loadProgress = useThreeDLoadProgress(); + const loadCompleteNotified = React.useRef(false); const markLoadStep = loadProgress.markStep; const markDetailsLoaded = React.useCallback(() => { markLoadStep("details"); @@ -181,6 +249,12 @@ export const GardenModel = (props: GardenModelProps) => { perfMark("garden_model_mounted"); }, []); + React.useEffect(() => { + if (!loadProgress.complete || loadCompleteNotified.current) { return; } + loadCompleteNotified.current = true; + onLoadComplete?.(); + }, [loadProgress.complete, onLoadComplete]); + const showPlants = !addPlantProps || !!addPlantProps.getConfigValue(BooleanSetting.show_plants); const plantsVisible = props.activeFocus != "Planter bed" && showPlants; @@ -215,6 +289,16 @@ export const GardenModel = (props: GardenModelProps) => { const topDownAtStart = !!props.addPlantProps?.getConfigValue( BooleanSetting.top_down_view); const topDownZoomLevel = 0.25 * 3000 / config.bedLengthOuter; + const targetZoom = config.topDown ? topDownZoomLevel : 1; + const focusTransitionsEnabled = + !!props.smoothFocusTransitions && config.animate; + const renderedCamera = useSmoothCamera({ + camera, + zoom: targetZoom, + enabled: focusTransitionsEnabled, + cameraObject: controlsCamera, + controls, + }); // eslint-disable-next-line no-null/no-null const skyRef = React.useRef(null); @@ -223,6 +307,7 @@ export const GardenModel = (props: GardenModelProps) => { const plantLabelNodes = React.useMemo( () => { + if (!config.labels && !config.labelsOnHover) { return undefined; } if (config.labelsOnHover) { if (hoveredPlant === undefined) { return undefined; } const plant = threeDPlants[hoveredPlant]; @@ -242,27 +327,40 @@ export const GardenModel = (props: GardenModelProps) => { }, [threeDPlants, config, getZ, hoveredPlant]); - // eslint-disable-next-line no-null/no-null - return console.log(e.intersections.map(x => x.object.name)) - : undefined}> - - - - - - {controlsCamera && + const plantInstancesVisible = props.smoothFocusTransitions + ? showPlants + : plantsVisible; + let cameraScale: number | typeof scale = scale; + if (props.smoothFocusTransitions || props.activeFocus) { + cameraScale = 1; + } + const cameraProps = focusTransitionsEnabled + ? {} + : { position: renderedCamera.position, zoom: renderedCamera.zoom }; + const orbitControlProps = focusTransitionsEnabled + ? {} + : { target: renderedCamera.target }; + + return + {/* eslint-disable-next-line no-null/no-null */} + console.log(e.intersections.map(x => x.object.name)) + : undefined}> + + + + + + {controlsCamera && { enableZoom={config.zoom} enablePan={config.pan} dampingFactor={0.2} - target={camera.target} + {...orbitControlProps} minZoom={config.lightsDebug ? 0 : 0.05} maxZoom={10} minDistance={config.lightsDebug ? 50 : 500} maxDistance={config.lightsDebug ? BigDistance.devZoom : BigDistance.zoom} />} - - - - - - - - - - - - - loadProgress.markStep("bed")} - distance={config.bedHeight + config.bedZOffset}> - + + + + + + - - - - loadProgress.markStep("grid")}> - - - - - loadProgress.markStep("plants")} - distance={200}> - - {plantLabelNodes} - - - + + + + + + loadProgress.markStep("bed")} + distance={config.bedHeight + config.bedZOffset}> + - + + + + loadProgress.markStep("grid")}> + - - - - - loadProgress.markStep("weeds")} - distance={200}> - - {(props.weeds?.length || 0) > 0 && + activeFocus={props.activeFocus} /> + + + + loadProgress.markStep("plants")} + distance={200}> + + {plantLabelNodes} + + + + + + + + + loadProgress.markStep("weeds")} + distance={200}> + + {(props.weeds?.length || 0) > 0 && } - - - - - loadProgress.markStep("points")} - distance={config.columnLength + 1000}> - - {(props.mapPoints?.length || 0) > 0 && + + + + + loadProgress.markStep("points")} + distance={config.columnLength + 1000}> + + {(props.mapPoints?.length || 0) > 0 && } - - - - - {showFarmbot && + + + + + {showFarmbot && loadProgress.markStep("farmbot")} - distance={config.columnLength + 1500}> + config={botLoadInConfig} + distance={config.columnLength + 1500} + fadeIn={true} + preserveDepthWrite={true}> { mountedToolName={props.mountedToolName} toolSlots={props.toolSlots} /> } - - - {config.stats && } - {config.stats && } - {config.zoomBeacons && - - - } - - {config.viewCube && } - - {showMoistureMap && props.config.moistureDebug && + + + {config.stats && } + {config.stats && } + {config.zoomBeacons && + } + + {config.viewCube && } + + {showMoistureMap && props.config.moistureDebug && } - - {props.addPlantProps?.designer.visualizedSequence && + + {props.addPlantProps?.designer.visualizedSequence && } - - - - {config.cameraSelectionView && + + + + {config.cameraSelectionView && } - {!animatedDetailsLoadIn && + {!animatedDetailsLoadIn && } - - ; + + + ; }; diff --git a/frontend/three_d_garden/progressive_load.tsx b/frontend/three_d_garden/progressive_load.tsx index af54e84573..dc675a698b 100644 --- a/frontend/three_d_garden/progressive_load.tsx +++ b/frontend/three_d_garden/progressive_load.tsx @@ -2,6 +2,8 @@ import React from "react"; import { animated, useSpring } from "@react-spring/three"; import { Html } from "@react-three/drei"; import { Group } from "./components"; +import { Object3D } from "three"; +import { createFocusMaterialBinding } from "./focus_transition"; const AnimatedGroup = animated(Group); @@ -16,6 +18,8 @@ export const THREE_D_LOAD_STEPS = [ { id: "details", label: "Loading scene details" }, ] as const; +export const THREE_D_LOAD_PROGRESS_FADE_MS = 300; + export type ThreeDLoadStepId = typeof THREE_D_LOAD_STEPS[number]["id"]; type ReadyStepTimes = Partial>; type StepDependencies = Record; @@ -127,15 +131,32 @@ interface ThreeDLoadProgressOverlayProps { export const ThreeDLoadProgressOverlay = (props: ThreeDLoadProgressOverlayProps) => { - if (props.progress.complete) { return undefined; } + const [mounted, setMounted] = React.useState(!props.progress.complete); + + React.useEffect(() => { + if (!props.progress.complete) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setMounted(true); + return; + } + const timeout = window.setTimeout(() => + setMounted(false), THREE_D_LOAD_PROGRESS_FADE_MS); + return () => window.clearTimeout(timeout); + }, [props.progress.complete]); + + if (!mounted) { return undefined; } + const className = [ + "three-d-load-progress", + props.progress.complete ? "three-d-load-progress-complete" : "", + ].join(" "); return -
+
-

{props.progress.currentStep?.label}

+

{props.progress.complete ? "Enjoy!" : props.progress.currentStep?.label}

; }; @@ -145,33 +166,94 @@ const loadInConfig = { friction: 26, }; +export const botLoadInConfig = { + tension: 220, + friction: 30, + clamp: true, +}; + +type LoadInSpringConfig = typeof loadInConfig & { + clamp?: boolean; +}; + +const canTraverse = (value: unknown): value is Object3D => + !!value + && typeof value == "object" + && typeof (value as Object3D).traverse == "function"; + interface LoadInGroupProps { name: string; children: React.ReactNode; onRest?: () => void; + config?: LoadInSpringConfig; fromPosition?: [number, number, number]; toPosition?: [number, number, number]; fromScale?: number | [number, number, number]; toScale?: number | [number, number, number]; + fadeIn?: boolean; + preserveDepthWrite?: boolean; } export const LoadInGroup = (props: LoadInGroupProps) => { + const fromPosition = props.fromPosition || [0, 0, 0]; + const toPosition = props.toPosition || [0, 0, 0]; + const fromScale = props.fromScale || 1; + const toScale = props.toScale || 1; + const groupRef = React.useRef(undefined); + const opacityRef = React.useRef(props.fadeIn ? 0 : 1); + const materialBinding = React.useRef | undefined>(undefined); + const applyOpacity = React.useCallback((opacity: number) => { + opacityRef.current = opacity; + if (!props.fadeIn || !canTraverse(groupRef.current)) { return; } + materialBinding.current ||= createFocusMaterialBinding(groupRef.current, { + preserveDepthWrite: props.preserveDepthWrite, + }); + materialBinding.current.apply(opacity); + }, [props.fadeIn, props.preserveDepthWrite]); + const setRef = React.useCallback((value: Object3D | null) => { + groupRef.current = value || undefined; + }, []); + const restoreMaterialBinding = React.useCallback(() => { + materialBinding.current?.restore(); + materialBinding.current = undefined; + }, []); + const { position, scale } = useSpring({ from: { - position: props.fromPosition || [0, 0, 0], - scale: props.fromScale || 1, + position: fromPosition, + scale: fromScale, + opacity: props.fadeIn ? 0 : 1, }, to: { - position: props.toPosition || [0, 0, 0], - scale: props.toScale || 1, + position: toPosition, + scale: toScale, + opacity: 1, }, - onRest: props.onRest, - config: loadInConfig, + onChange: result => { + const value = result.value as { opacity?: number }; + applyOpacity(value.opacity ?? 1); + }, + onRest: () => { + applyOpacity(1); + restoreMaterialBinding(); + props.onRest?.(); + }, + config: props.config || loadInConfig, }); + React.useLayoutEffect(() => { + if (!props.fadeIn) { return; } + applyOpacity(opacityRef.current); + }, [applyOpacity, props.fadeIn]); + + React.useEffect(() => restoreMaterialBinding, [restoreMaterialBinding]); + return {props.children} ; @@ -198,16 +280,22 @@ interface FallInGroupProps { name: string; children: React.ReactNode; onRest?: () => void; + config?: LoadInSpringConfig; distance?: number; + fadeIn?: boolean; + preserveDepthWrite?: boolean; } export const FallInGroup = (props: FallInGroupProps) => + toScale={1} + fadeIn={props.fadeIn} + preserveDepthWrite={props.preserveDepthWrite}> {props.children} ; diff --git a/frontend/three_d_garden/scenes/greenhouse.tsx b/frontend/three_d_garden/scenes/greenhouse.tsx index 5795056ca9..cede0e4100 100644 --- a/frontend/three_d_garden/scenes/greenhouse.tsx +++ b/frontend/three_d_garden/scenes/greenhouse.tsx @@ -7,6 +7,7 @@ import { Config } from "../config"; import { Group, MeshPhongMaterial } from "../components"; import { StarterTray, PottedPlant, GreenhouseWall, People } from "./props"; import { PopInGroup } from "../progressive_load"; +import { FocusVisibilityGroup } from "../focus_transition"; export interface GreenhouseProps { config: Config; @@ -97,7 +98,7 @@ export const Greenhouse = (props: GreenhouseProps) => { }, ]} /> - { groundZ, ]}> - + ; }; diff --git a/frontend/three_d_garden/scenes/props/desk.tsx b/frontend/three_d_garden/scenes/props/desk.tsx index 9916c95aec..fa263bef5f 100644 --- a/frontend/three_d_garden/scenes/props/desk.tsx +++ b/frontend/three_d_garden/scenes/props/desk.tsx @@ -5,6 +5,7 @@ import { ASSETS } from "../../constants"; import { threeSpace } from "../../helpers"; import { Config } from "../../config"; import { Group, MeshPhongMaterial } from "../../components"; +import { FocusVisibilityGroup } from "../../focus_transition"; export interface DeskProps { config: Config; @@ -36,7 +37,7 @@ export const Desk = (props: DeskProps) => { texture.wrapT = RepeatWrapping; return texture; }, [screenTextureBase]); - return { - ; + ; }; diff --git a/frontend/three_d_garden/scenes/props/people.tsx b/frontend/three_d_garden/scenes/props/people.tsx index eb4fd22cde..329994d237 100644 --- a/frontend/three_d_garden/scenes/props/people.tsx +++ b/frontend/three_d_garden/scenes/props/people.tsx @@ -5,6 +5,7 @@ import { Config } from "../../config"; import { threeSpace } from "../../helpers"; import { Vector3, DoubleSide } from "three"; import { ASSETS, RenderOrder } from "../../constants"; +import { FocusVisibilityGroup } from "../../focus_transition"; export interface PeopleProps { config: Config; @@ -15,7 +16,7 @@ export interface PeopleProps { export const People = (props: PeopleProps) => { const { people, config } = props; const groundZ = -config.bedZOffset - config.bedHeight; - return {people.map((person, i) => { const offset = new Vector3(...person.offset); @@ -28,7 +29,7 @@ export const People = (props: PeopleProps) => { ; })} - ; + ; }; interface DataRecord { diff --git a/frontend/three_d_garden/zoom_beacons_constants.tsx b/frontend/three_d_garden/zoom_beacons_constants.tsx index 7054ba254c..90d5bc942d 100644 --- a/frontend/three_d_garden/zoom_beacons_constants.tsx +++ b/frontend/three_d_garden/zoom_beacons_constants.tsx @@ -12,6 +12,8 @@ export interface Camera { target: VectorXyz; } +const TOP_DOWN_FOCUS_AZIMUTH_OFFSET = 25; + interface Focus { label: string; info: { @@ -70,7 +72,7 @@ export const FOCI = (config: Config, configPosition: PositionConfig): Focus[] => narrow: { position: [ 0, - -1000, + -1000 - TOP_DOWN_FOCUS_AZIMUTH_OFFSET, config.sizePreset == "Genesis XL" ? 16000 : 8000, ], target: [ @@ -82,7 +84,7 @@ export const FOCI = (config: Config, configPosition: PositionConfig): Focus[] => wide: { position: [ 0, - 0, + -TOP_DOWN_FOCUS_AZIMUTH_OFFSET, config.sizePreset == "Genesis XL" ? 10000 : 5000, ], target: [ From 3447556bc14c3727c912c21bbeed0a7606f70263 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 20:54:20 -0700 Subject: [PATCH 24/83] Instance seed trays in greenhouse --- .../scenes/__tests__/greenhouse_test.tsx | 5 +- frontend/three_d_garden/scenes/greenhouse.tsx | 14 +- .../props/__tests__/starter_tray_test.tsx | 65 +++++++-- .../scenes/props/starter_tray.tsx | 126 ++++++++++++++---- 4 files changed, 164 insertions(+), 46 deletions(-) diff --git a/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx b/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx index 634b2d4832..c6fcd81cee 100644 --- a/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx +++ b/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx @@ -18,8 +18,9 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("greenhouse-environment"); expect(container).not.toContainHTML("people"); - expect(container).toContainHTML("starter-tray-1"); - expect(container).toContainHTML("starter-tray-2"); + expect(container).toContainHTML("starter-trays"); + expect(container).toContainHTML("starter-tray-bases"); + expect(container).toContainHTML("starter-tray-seedlings"); expect(container).toContainHTML("left-greenhouse-wall"); expect(container).toContainHTML("right-greenhouse-wall"); expect(container).toContainHTML("potted-plant"); diff --git a/frontend/three_d_garden/scenes/greenhouse.tsx b/frontend/three_d_garden/scenes/greenhouse.tsx index cede0e4100..586dd3f467 100644 --- a/frontend/three_d_garden/scenes/greenhouse.tsx +++ b/frontend/three_d_garden/scenes/greenhouse.tsx @@ -5,7 +5,7 @@ import { ASSETS } from "../constants"; import { threeSpace } from "../helpers"; import { Config } from "../config"; import { Group, MeshPhongMaterial } from "../components"; -import { StarterTray, PottedPlant, GreenhouseWall, People } from "./props"; +import { StarterTrays, PottedPlant, GreenhouseWall, People } from "./props"; import { PopInGroup } from "../progressive_load"; import { FocusVisibilityGroup } from "../focus_transition"; @@ -62,14 +62,10 @@ export const Greenhouse = (props: GreenhouseProps) => { side={DoubleSide} /> - - - - - - + ", () => { - const fakeProps = (): DeskProps => ({ - config: clone(INITIAL), - activeFocus: "", +import { StarterTray, StarterTrays } from "../starter_tray"; +import { InstancedMesh } from "three"; + +const mockMesh = () => ({ + setMatrixAt: jest.fn(), + instanceMatrix: { needsUpdate: false }, +}) as unknown as InstancedMesh; + +describe("", () => { + it("renders a single starter tray", () => { + const { container } = render(); + + expect(container).toContainHTML("starter-tray"); + expect(container).toContainHTML("starter-trays"); + expect(container.querySelectorAll("instancedmesh").length).toEqual(2); }); +}); + +describe("", () => { + it("renders instanced starter trays and seedlings", () => { + const { container } = render(); + + expect(container).toContainHTML("starter-tray-bases"); + expect(container).toContainHTML("starter-tray-seedlings"); + expect(container.querySelectorAll("instancedmesh").length).toEqual(2); + expect(container.querySelectorAll(".billboard").length).toEqual(0); + expect(container.querySelectorAll(".image").length).toEqual(0); + }); + + it("updates tray and seedling instance matrices", () => { + const trayMesh = mockMesh(); + const seedlingMesh = mockMesh(); + const useRef = React.useRef; + const useEffectSpy = jest.spyOn(React, "useEffect") + .mockImplementationOnce(effect => { + effect(); + }); + const useRefSpy = jest.spyOn(React, "useRef") + .mockImplementationOnce(() => ({ current: trayMesh })) + .mockImplementationOnce(() => ({ current: seedlingMesh })) + .mockImplementation(useRef); + + render(); - it("renders", () => { - const { container } = render(); - expect(container).toContainHTML("desk"); + expect(trayMesh.setMatrixAt).toHaveBeenCalledTimes(2); + expect(seedlingMesh.setMatrixAt).toHaveBeenCalledTimes(140); + expect(trayMesh.instanceMatrix.needsUpdate).toBeTruthy(); + expect(seedlingMesh.instanceMatrix.needsUpdate).toBeTruthy(); + useRefSpy.mockRestore(); + useEffectSpy.mockRestore(); }); }); diff --git a/frontend/three_d_garden/scenes/props/starter_tray.tsx b/frontend/three_d_garden/scenes/props/starter_tray.tsx index 34c62fe266..2b88f5f2ce 100644 --- a/frontend/three_d_garden/scenes/props/starter_tray.tsx +++ b/frontend/three_d_garden/scenes/props/starter_tray.tsx @@ -1,8 +1,22 @@ import React from "react"; -import { Box, Billboard, Image } from "@react-three/drei"; -import { DoubleSide } from "three"; +import { useTexture } from "@react-three/drei"; +import { useFrame } from "@react-three/fiber"; +import { + DoubleSide, + InstancedMesh as InstancedMeshType, + Matrix4, + Quaternion, + Vector3, +} from "three"; import { ASSETS, RenderOrder } from "../../constants"; -import { Group, MeshPhongMaterial } from "../../components"; +import { + BoxGeometry, + Group, + InstancedMesh, + MeshBasicMaterial, + MeshPhongMaterial, + PlaneGeometry, +} from "../../components"; import { range } from "lodash"; const length = 250; @@ -10,31 +24,95 @@ const width = 700; const height = 50; const cellSize = 50; const seedlingSize = 40; +const trayCells = range(5).flatMap(row => + range(14).map(col => ({ + x: -width / 2 + cellSize / 2 + col * cellSize, + y: -length / 2 + cellSize / 2 + row * cellSize, + }))); -export const StarterTray = () => { +export interface StarterTraysProps { + positions: [number, number, number][]; +} - return - { + // eslint-disable-next-line no-null/no-null + const trayRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null + const seedlingRef = React.useRef(null); + const plantTexture = useTexture(ASSETS.other.plant); + const matrix = React.useMemo(() => new Matrix4(), []); + const position = React.useMemo(() => new Vector3(), []); + const scale = React.useMemo(() => new Vector3(), []); + const trayQuaternion = React.useMemo(() => new Quaternion(), []); + const seedlingQuaternion = React.useMemo(() => new Quaternion(), []); + + React.useEffect(() => { + const mesh = trayRef.current; + if (!mesh) { return; } + props.positions.forEach((trayPosition, index) => { + position.set( + trayPosition[0], + trayPosition[1], + trayPosition[2] + height / 2, + ); + scale.set(1, 1, 1); + matrix.compose(position, trayQuaternion, scale); + mesh.setMatrixAt(index, matrix); + }); + mesh.instanceMatrix.needsUpdate = true; + }, [matrix, position, props.positions, scale, trayQuaternion]); + + useFrame(state => { + const mesh = seedlingRef.current; + if (!mesh) { return; } + seedlingQuaternion.copy(state.camera.quaternion); + scale.set(seedlingSize, seedlingSize, seedlingSize); + props.positions.forEach((trayPosition, trayIndex) => { + trayCells.forEach((cell, cellIndex) => { + const index = trayIndex * trayCells.length + cellIndex; + position.set( + trayPosition[0] + cell.x, + trayPosition[1] + cell.y, + trayPosition[2] + height + seedlingSize / 2, + ); + matrix.compose(position, seedlingQuaternion, scale); + mesh.setMatrixAt(index, matrix); + }); + }); + mesh.instanceMatrix.needsUpdate = true; + }); + + return + + frustumCulled={false}> + - - {range(5).map(row => - range(14).map(col => { - const x = -width / 2 + cellSize / 2 + col * cellSize; - const y = -length / 2 + cellSize / 2 + row * cellSize; - return - - ; - }), - )} + + + + + + ; +}; + +export const StarterTray = () => { + + return + ; }; From 4dfb377207bd57b89fe1ca0618fd2fed896f4349 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 21:08:00 -0700 Subject: [PATCH 25/83] config search --- .../css/farm_designer/three_d_garden.scss | 35 +- .../__tests__/config_overlays_test.tsx | 27 ++ frontend/three_d_garden/config_overlays.tsx | 326 ++++++++++-------- 3 files changed, 237 insertions(+), 151 deletions(-) diff --git a/frontend/css/farm_designer/three_d_garden.scss b/frontend/css/farm_designer/three_d_garden.scss index bfef5ba222..195c79c1c0 100644 --- a/frontend/css/farm_designer/three_d_garden.scss +++ b/frontend/css/farm_designer/three_d_garden.scss @@ -267,6 +267,7 @@ .all-configs { position: absolute; + z-index: 2; top: 1rem; right: 1rem; width: 22rem; @@ -297,25 +298,29 @@ margin-top: 1rem; } - details { - color: $off_white; + .config-search { + width: 100%; + height: 2rem; + margin-bottom: 0.5rem; + padding: 0.2rem 0.5rem; + font-size: 0.85rem; + box-shadow: none; + } - label { - font-weight: bold; - color: $off_white; - text-transform: none; - font-size: 0.9rem; - } + .config-title { + color: $off_white; + font-weight: bold; } - summary { - margin: -1rem; - cursor: pointer; - padding: 1rem; + label { + font-weight: bold; + color: $off_white; + text-transform: none; + font-size: 0.9rem; + } - &:hover { - background: $dark_gray; - } + .config-section:not(:has(.config-row)) { + display: none; } .config-row { diff --git a/frontend/three_d_garden/__tests__/config_overlays_test.tsx b/frontend/three_d_garden/__tests__/config_overlays_test.tsx index 0f52b7fbd5..be2aae28e0 100644 --- a/frontend/three_d_garden/__tests__/config_overlays_test.tsx +++ b/frontend/three_d_garden/__tests__/config_overlays_test.tsx @@ -105,6 +105,23 @@ describe("", () => { it("renders", () => { const { container } = render(); expect(container.innerHTML).toContain("all-configs"); + expect(container.querySelector("details")).toBeFalsy(); + }); + + it("focuses the config search", () => { + const { getByPlaceholderText } = render(); + expect(document.activeElement).toEqual(getByPlaceholderText("Search configs")); + }); + + it("filters configs", () => { + const { container, getByPlaceholderText } = + render(); + fireEvent.change(getByPlaceholderText("Search configs"), { + target: { value: "promo" }, + }); + expect(container).toContainHTML("promoInfo"); + expect(container).toContainHTML("promoSpread"); + expect(container).not.toContainHTML("settingsBar"); }); it("changes value: number", () => { @@ -175,6 +192,16 @@ describe("", () => { }); }); + it("closes the config menu with Escape", () => { + const p = fakeProps(); + render(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(p.setConfig).toHaveBeenCalledWith({ + ...p.config, + config: false, + }); + }); + it("removes url param", () => { location.search = "?urlParamAutoAdd=true"; const p = fakeProps(); diff --git a/frontend/three_d_garden/config_overlays.tsx b/frontend/three_d_garden/config_overlays.tsx index 6198e1ad3f..046f158c1f 100644 --- a/frontend/three_d_garden/config_overlays.tsx +++ b/frontend/three_d_garden/config_overlays.tsx @@ -190,10 +190,14 @@ interface ConfigRowProps { configKey: keyof ConfigWithPosition; children: React.ReactNode; addLabel?: string; + searchTerms?: string[]; } +const ConfigSearchContext = React.createContext(""); + const ConfigRow = (props: ConfigRowProps) => { const { configKey } = props; + const search = React.useContext(ConfigSearchContext).trim().toLowerCase(); const urlHasParam = (key: keyof ConfigWithPosition) => !!(new URLSearchParams(window.location.search)).get(key); const removeParam = () => { @@ -209,6 +213,10 @@ const ConfigRow = (props: ConfigRowProps) => { if (props.addLabel) { label += ` (${props.addLabel})`; } + const searchText = [label, ...(props.searchTerms || [])] + .join(" ") + .toLowerCase(); + if (search && !searchText.includes(search)) { return ; } return
{hasParam &&

x

} {label} @@ -289,7 +297,10 @@ const Radio = (props: RadioProps) => { setConfig(modifyConfig(config, update)); maybeAddParam(config.urlParamAutoAdd, configKey, "" + newValue); }; - return + return
{options.map(value =>
@@ -311,145 +322,188 @@ export const PrivateOverlay = (props: OverlayProps) => { const bedMin = props.config.bedWallThickness * 2; const { config, setConfig } = props; const common = { ...props }; + const [search, setSearch] = React.useState(""); + // eslint-disable-next-line no-null/no-null + const searchInputRef = React.useRef(null); + React.useEffect(() => { + searchInputRef.current?.focus(); + }, []); + React.useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key != "Escape") { return; } + setConfig(modifyConfig(config, { config: false })); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [config, setConfig]); return
-
- - {"Configs"} -

setConfig(modifyConfig(config, { config: false }))}> - X -

-
-
+
+ {"Configs"} +

setConfig(modifyConfig(config, { config: false }))}> + X +

+
+
+ setSearch(e.target.value)} + /> + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+ + + + + + +
+
+ + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + +
+
+ + + + + + + + + + + + +
+
+ + + + + + + + + + +
+
+ + + + + + + +
+
+ + + + + + + + + +
+
+ + + + + + + + + + + +
+
+ + + + + + + +
+
+ + + + + + + + +
+
; }; From c7391799ebbf12b0e41caf11d9302a040d596958 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 21:14:59 -0700 Subject: [PATCH 26/83] Instant season switching --- frontend/promo/__tests__/promo_test.tsx | 15 ++++++++ frontend/promo/promo.tsx | 34 +++++++++++-------- .../garden/__tests__/plant_instances_test.tsx | 15 ++++++++ .../three_d_garden/garden/plant_instances.tsx | 14 ++++++++ 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/frontend/promo/__tests__/promo_test.tsx b/frontend/promo/__tests__/promo_test.tsx index e97cdee6dd..30e9c01fb7 100644 --- a/frontend/promo/__tests__/promo_test.tsx +++ b/frontend/promo/__tests__/promo_test.tsx @@ -121,4 +121,19 @@ describe("getPromoPlantCapacities()", () => { plants.map(plant => expect(capacities.iconCapacities[plant.icon]).toBeGreaterThan(0)); }); + + it("precalculates capacities for other seasons", () => { + const config = { + ...INITIAL, + ...PRESETS["Genesis"], + plants: "Spring", + }; + const capacities = getPromoPlantCapacities(config); + const summerPlants = calculatePlantPositions({ + ...config, + plants: "Summer", + }); + summerPlants.map(plant => + expect(capacities.iconCapacities[plant.icon]).toBeGreaterThan(0)); + }); }); diff --git a/frontend/promo/promo.tsx b/frontend/promo/promo.tsx index 982351d495..4a3e2510c9 100644 --- a/frontend/promo/promo.tsx +++ b/frontend/promo/promo.tsx @@ -83,19 +83,22 @@ const getCachedPlants = (config: Config) => { export const getPromoPlantCapacities = (config: Config): PromoPlantCapacities => { const iconCapacities: Record = {}; let plantInstanceCapacity = 0; - PROMO_BED_SIZES.map(({ length, width }) => { - const plants = getCachedPlants({ - ...config, - bedLengthOuter: length, - bedWidthOuter: width, - }); - plantInstanceCapacity = Math.max(plantInstanceCapacity, plants.length); - const iconCounts: Record = {}; - plants.map(plant => { - iconCounts[plant.icon] = (iconCounts[plant.icon] || 0) + 1; - }); - Object.entries(iconCounts).map(([icon, count]) => { - iconCapacities[icon] = Math.max(iconCapacities[icon] || 0, count); + SEASONS.map(season => { + PROMO_BED_SIZES.map(({ length, width }) => { + const plants = getCachedPlants({ + ...config, + bedLengthOuter: length, + bedWidthOuter: width, + plants: season, + }); + plantInstanceCapacity = Math.max(plantInstanceCapacity, plants.length); + const iconCounts: Record = {}; + plants.map(plant => { + iconCounts[plant.icon] = (iconCounts[plant.icon] || 0) + 1; + }); + Object.entries(iconCounts).map(([icon, count]) => { + iconCapacities[icon] = Math.max(iconCapacities[icon] || 0, count); + }); }); }); return { iconCapacities, plantInstanceCapacity }; @@ -186,8 +189,9 @@ export const Promo = () => { }, [plants, config.promoSpread]); const plantCapacityConfig = React.useMemo(() => ({ ...INITIAL, - plants: config.plants, - }), [config.plants]); + bedLengthOuter: config.bedLengthOuter, + bedWidthOuter: config.bedWidthOuter, + }), [config.bedLengthOuter, config.bedWidthOuter]); const plantCapacities = React.useMemo(() => getPromoPlantCapacities(plantCapacityConfig), [plantCapacityConfig]); diff --git a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx index e0180e5332..788279b1db 100644 --- a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx @@ -114,6 +114,21 @@ describe("", () => { expect(mesh?.getAttribute("count")).toEqual("1"); }); + it("keeps reserved icon meshes mounted without active plants", () => { + const p = fakeProps(); + p.plants = [p.plants[0]]; + p.iconCapacities = { + [p.plants[0].icon]: 10, + "https://example.com/inactive-icon.avif": 5, + }; + const { container } = render(); + const meshes = container.querySelectorAll("instancedmesh"); + expect(meshes.length).toBe(2); + expect(meshes.item(1).getAttribute("args")).toContain("5"); + expect(meshes.item(1).getAttribute("count")).toEqual("0"); + expect(useTexture).toHaveBeenCalledWith("https://example.com/inactive-icon.avif"); + }); + it("disables frustum culling for billboarded plant icons", () => { const wrapper = createRenderer(); const mesh = wrapper.root.findAll(node => diff --git a/frontend/three_d_garden/garden/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx index 8f14055f1a..927fe1c003 100644 --- a/frontend/three_d_garden/garden/plant_instances.tsx +++ b/frontend/three_d_garden/garden/plant_instances.tsx @@ -104,6 +104,7 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { useFrame(state => { const mesh = instancedRef.current; if (!mesh || visible === false) { return; } + if (plants.length == 0) { return; } const updateState = getUpdateState(); const seasonAnimating = !!(config.animateSeasons && startTimeRef); const cameraChanged = !updateState.hasCameraQuaternion @@ -182,6 +183,19 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { export const PlantInstances = React.memo((props: PlantInstancesProps) => { const instances = React.useMemo(() => { const iconInstances: Record = {}; + Object.entries(props.iconCapacities || {}).map(([icon, capacity]) => { + iconInstances[icon] = { + config: props.config, + dispatch: props.dispatch, + getZ: props.getZ, + icon, + plants: [], + plantIndexes: [], + capacity, + startTimeRef: props.startTimeRef, + visible: props.visible, + }; + }); props.plants.forEach((plant, index) => { const instance = iconInstances[plant.icon]; if (instance) { From f8eb51b81d6b72d48c707066c5c47c00da80f097 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 22:02:46 -0700 Subject: [PATCH 27/83] Speed up night time --- .../three_d_garden/garden/plant_instances.tsx | 9 ++-- frontend/three_d_garden/garden/sun.tsx | 51 +++++++++++++++++-- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/frontend/three_d_garden/garden/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx index 927fe1c003..c6679e12d9 100644 --- a/frontend/three_d_garden/garden/plant_instances.tsx +++ b/frontend/three_d_garden/garden/plant_instances.tsx @@ -24,8 +24,9 @@ import { getPlantIconTextureUrl, } from "./plant_icon_atlas"; import { Mode } from "../../farm_designer/map/interfaces"; -import moment from "moment"; -import { calcSunCoordinate, calcSunI, getCycleLength } from "./sun"; +import { + calcSunCoordinate, calcSunI, getAnimatedSeasonDate, +} from "./sun"; export interface PlantInstancesProps { plants: ThreeDGardenPlant[]; @@ -111,11 +112,9 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { || !updateState.lastCameraQuaternion.equals(state.camera.quaternion); let sunFactor = calcSunI(config.sunInclination); if (seasonAnimating) { - const totalCycle = getCycleLength(config.plants); const currentTime = performance.now() / 1000; const t = currentTime - (startTimeRef.current || 0); - const timeOffset = Math.min(t / totalCycle, 1) * 24 * 60 * 60; - const date = moment().utc().startOf("day").add(timeOffset, "seconds").toDate(); + const date = getAnimatedSeasonDate(config.plants, t); sunFactor = calcSunI(calcSunCoordinate(date, 0, 52, 0).inclination); } const brightness = plantIconBrightness(sunFactor); diff --git a/frontend/three_d_garden/garden/sun.tsx b/frontend/three_d_garden/garden/sun.tsx index dd84efd6fc..9c247ecbe6 100644 --- a/frontend/three_d_garden/garden/sun.tsx +++ b/frontend/three_d_garden/garden/sun.tsx @@ -24,10 +24,20 @@ const shadowRadius = 8; const shadowBlurSamples = 8; const shadowBuffer = 1000; const SUN_COLOR = ["#FFD700", "#FFEA00", "#FFF700", "#FFE066"]; +const DAY_SECONDS = 24 * 60 * 60; +const SUN_TIME_STEP_SECONDS = 60; +const BELOW_HORIZON_SUN_SPEED = 10; +const BELOW_HORIZON_SPEED_INCLINATION = -10; +const sunAnimationCache: Record = {}; export const getCycleLength = (season: string) => SEASON_DURATIONS[season] || 20; +interface SunAnimationSample { + animationSeconds: number; + sunSeconds: number; +} + export interface SunProps { config: Config; startTimeRef?: React.RefObject; @@ -63,6 +73,43 @@ export const calcSunCoordinate = ( }; }; +export const getAnimatedSeasonDate = ( + season: string, + elapsedSeconds: number, + dayStart = moment().utc().startOf("day").toDate(), +) => { + const totalCycle = getCycleLength(season); + const clampedElapsed = Math.min(Math.max(elapsedSeconds, 0), totalCycle); + const samples = getSunAnimationSamples(dayStart); + const totalAnimationSeconds = samples[samples.length - 1].animationSeconds; + const targetAnimationSeconds = + clampedElapsed / totalCycle * totalAnimationSeconds; + const sample = samples.find(({ animationSeconds }) => + animationSeconds >= targetAnimationSeconds) || samples[samples.length - 1]; + const date = new Date(dayStart.getTime() + sample.sunSeconds * 1000); + return date; +}; + +const getSunAnimationSamples = (dayStart: Date): SunAnimationSample[] => { + const cacheKey = dayStart.toISOString().slice(0, 10); + const cachedSamples = sunAnimationCache[cacheKey]; + if (cachedSamples) { return cachedSamples; } + const samples: SunAnimationSample[] = []; + let animationSeconds = 0; + for (let sunSeconds = 0; sunSeconds <= DAY_SECONDS; + sunSeconds += SUN_TIME_STEP_SECONDS) { + samples.push({ animationSeconds, sunSeconds }); + const date = new Date(dayStart.getTime() + sunSeconds * 1000); + const { inclination } = calcSunCoordinate(date, 0, 52, 0); + const speed = inclination < BELOW_HORIZON_SPEED_INCLINATION + ? BELOW_HORIZON_SUN_SPEED + : 1; + animationSeconds += SUN_TIME_STEP_SECONDS / speed; + } + sunAnimationCache[cacheKey] = samples; + return samples; +}; + const toRad = (degrees: number) => degrees * Math.PI / 180; const polarToCartesian = ( radius: number, @@ -192,11 +239,9 @@ export const Sun = (props: SunProps) => { useFrame(() => { if (!config.animateSeasons || !props.startTimeRef) { return; } - const totalCycle = getCycleLength(config.plants); const currentTime = performance.now() / 1000; const t = currentTime - props.startTimeRef.current; - const timeOffset = Math.min(t / totalCycle, 1) * 24 * 60 * 60; - const date = moment().utc().startOf("day").add(timeOffset, "seconds").toDate(); + const date = getAnimatedSeasonDate(config.plants, t); const { azimuth, inclination } = calcSunCoordinate(date, 0, 52, 0); const sunFactor = calcSunI(inclination); const position = (index: number) => { From 175ad1a1fa13200ed62fefa12c98aeb3a0d84634 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 22:11:23 -0700 Subject: [PATCH 28/83] seasonal sun arcs --- .../garden/__tests__/sun_test.tsx | 24 ++++++++++++++++++- frontend/three_d_garden/garden/sun.tsx | 24 +++++++++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/frontend/three_d_garden/garden/__tests__/sun_test.tsx b/frontend/three_d_garden/garden/__tests__/sun_test.tsx index 642f5efbc3..bd935942e5 100644 --- a/frontend/three_d_garden/garden/__tests__/sun_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/sun_test.tsx @@ -30,7 +30,9 @@ const mockMaterialRef: MockMaterialRef = { import React from "react"; import { render } from "@testing-library/react"; -import { calcSunI, getCycleLength, skyColor, Sun, SunProps } from "../sun"; +import { + calcSunI, getAnimatedSeasonDate, getCycleLength, skyColor, Sun, SunProps, +} from "../sun"; import { INITIAL } from "../../config"; import { clone } from "lodash"; import { MeshBasicMaterial } from "three"; @@ -143,6 +145,26 @@ describe("getCycleLength()", () => { }); }); +describe("getAnimatedSeasonDate()", () => { + it("uses representative season dates", () => { + const dayStart = new Date(Date.UTC(2026, 0, 1)); + expect(getAnimatedSeasonDate("Spring", 0, dayStart).toISOString()) + .toContain("2026-03-20"); + expect(getAnimatedSeasonDate("Summer", 0, dayStart).toISOString()) + .toContain("2026-06-21"); + expect(getAnimatedSeasonDate("Fall", 0, dayStart).toISOString()) + .toContain("2026-09-22"); + expect(getAnimatedSeasonDate("Winter", 0, dayStart).toISOString()) + .toContain("2026-12-21"); + }); + + it("keeps unknown seasons on the provided date", () => { + const dayStart = new Date(Date.UTC(2026, 0, 1)); + expect(getAnimatedSeasonDate("Random", 0, dayStart).toISOString()) + .toContain("2026-01-01"); + }); +}); + describe("skyColor(calcSunI())", () => { const DARK_BLUE = [ 0.04373502925049377, diff --git a/frontend/three_d_garden/garden/sun.tsx b/frontend/three_d_garden/garden/sun.tsx index 9c247ecbe6..478bbfa62f 100644 --- a/frontend/three_d_garden/garden/sun.tsx +++ b/frontend/three_d_garden/garden/sun.tsx @@ -15,7 +15,7 @@ import { useFrame } from "@react-three/fiber"; import SunCalc from "suncalc"; import { range } from "lodash"; import moment from "moment"; -import { SEASON_DURATIONS } from "../../promo/constants"; +import { Season, SEASON_DURATIONS } from "../../promo/constants"; import { Line2 } from "three/examples/jsm/lines/Line2.js"; import { ASSETS, BigDistance } from "../constants"; @@ -29,6 +29,12 @@ const SUN_TIME_STEP_SECONDS = 60; const BELOW_HORIZON_SUN_SPEED = 10; const BELOW_HORIZON_SPEED_INCLINATION = -10; const sunAnimationCache: Record = {}; +const SEASON_SUN_DATES: Record = { + [Season.Spring]: [2, 20], + [Season.Summer]: [5, 21], + [Season.Fall]: [8, 22], + [Season.Winter]: [11, 21], +}; export const getCycleLength = (season: string) => SEASON_DURATIONS[season] || 20; @@ -80,16 +86,24 @@ export const getAnimatedSeasonDate = ( ) => { const totalCycle = getCycleLength(season); const clampedElapsed = Math.min(Math.max(elapsedSeconds, 0), totalCycle); - const samples = getSunAnimationSamples(dayStart); + const seasonDayStart = getSeasonDayStart(season, dayStart); + const samples = getSunAnimationSamples(seasonDayStart); const totalAnimationSeconds = samples[samples.length - 1].animationSeconds; const targetAnimationSeconds = clampedElapsed / totalCycle * totalAnimationSeconds; const sample = samples.find(({ animationSeconds }) => animationSeconds >= targetAnimationSeconds) || samples[samples.length - 1]; - const date = new Date(dayStart.getTime() + sample.sunSeconds * 1000); + const date = new Date(seasonDayStart.getTime() + sample.sunSeconds * 1000); return date; }; +const getSeasonDayStart = (season: string, dayStart: Date) => { + const seasonDate = SEASON_SUN_DATES[season]; + if (!seasonDate) { return dayStart; } + const [month, day] = seasonDate; + return new Date(Date.UTC(2016, month, day)); +}; + const getSunAnimationSamples = (dayStart: Date): SunAnimationSample[] => { const cacheKey = dayStart.toISOString().slice(0, 10); const cachedSamples = sunAnimationCache[cacheKey]; @@ -100,7 +114,7 @@ const getSunAnimationSamples = (dayStart: Date): SunAnimationSample[] => { sunSeconds += SUN_TIME_STEP_SECONDS) { samples.push({ animationSeconds, sunSeconds }); const date = new Date(dayStart.getTime() + sunSeconds * 1000); - const { inclination } = calcSunCoordinate(date, 0, 52, 0); + const { inclination } = calcSunCoordinate(date, 0, 35, 0); const speed = inclination < BELOW_HORIZON_SPEED_INCLINATION ? BELOW_HORIZON_SUN_SPEED : 1; @@ -242,7 +256,7 @@ export const Sun = (props: SunProps) => { const currentTime = performance.now() / 1000; const t = currentTime - props.startTimeRef.current; const date = getAnimatedSeasonDate(config.plants, t); - const { azimuth, inclination } = calcSunCoordinate(date, 0, 52, 0); + const { azimuth, inclination } = calcSunCoordinate(date, 0, 35, 0); const sunFactor = calcSunI(inclination); const position = (index: number) => { const sunPos = sunPosition(inclination, azimuth, BigDistance.sunActual); From 3e8a0f6fbfe10a697047a4bafabd0a0b22770a0b Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 22:23:03 -0700 Subject: [PATCH 29/83] Clean up 4 sun code --- frontend/three_d_garden/config.ts | 8 +- .../garden/__tests__/sun_test.tsx | 50 +------ frontend/three_d_garden/garden/sun.tsx | 127 +++++++----------- 3 files changed, 54 insertions(+), 131 deletions(-) diff --git a/frontend/three_d_garden/config.ts b/frontend/three_d_garden/config.ts index f7066d3a0b..e403c67819 100644 --- a/frontend/three_d_garden/config.ts +++ b/frontend/three_d_garden/config.ts @@ -578,10 +578,10 @@ type SeasonProperties = { cloudOpacity: number; }; const SEASON_PROPERTIES: Record = { - Winter: { sunIntensity: 4 / 4, sunColor: "#A0C4FF", cloudOpacity: 0.85 }, - Spring: { sunIntensity: 7 / 4, sunColor: "#BDE0FE", cloudOpacity: 0.2 }, - Summer: { sunIntensity: 9 / 4, sunColor: "#FFFFFF", cloudOpacity: 0 }, - Fall: { sunIntensity: 5.5 / 4, sunColor: "#FFD6BC", cloudOpacity: 0.3 }, + Winter: { sunIntensity: 4, sunColor: "#A0C4FF", cloudOpacity: 0.75 }, + Spring: { sunIntensity: 7, sunColor: "#BDE0FE", cloudOpacity: 0.2 }, + Summer: { sunIntensity: 9, sunColor: "#FFFFFF", cloudOpacity: 0 }, + Fall: { sunIntensity: 5.5, sunColor: "#FFD6BC", cloudOpacity: 0.3 }, }; export const getSeasonProperties = ( config: Config, diff --git a/frontend/three_d_garden/garden/__tests__/sun_test.tsx b/frontend/three_d_garden/garden/__tests__/sun_test.tsx index bd935942e5..5d687e4528 100644 --- a/frontend/three_d_garden/garden/__tests__/sun_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/sun_test.tsx @@ -1,26 +1,9 @@ -interface Mock0Ref { - current: number; -} -const mock0Ref: Mock0Ref = { - current: 0, -}; interface Mock1Ref { current: { position: { set: Function; }; } | undefined; } const mock1Ref: Mock1Ref = { current: { position: { set: jest.fn() } } }; -interface Mock4Ref { - current: { position: { set: Function; }; }[] | undefined; -} -const mock4Ref: Mock4Ref = { - current: [ - { position: { set: jest.fn() } }, - { position: { set: jest.fn() } }, - { position: { set: jest.fn() } }, - { position: { set: jest.fn() } }, - ] -}; interface MockMaterialRef { current: { opacity: number; } | undefined; } @@ -30,12 +13,10 @@ const mockMaterialRef: MockMaterialRef = { import React from "react"; import { render } from "@testing-library/react"; -import { - calcSunI, getAnimatedSeasonDate, getCycleLength, skyColor, Sun, SunProps, -} from "../sun"; +import { calcSunI, getCycleLength, skyColor, Sun, SunProps } from "../sun"; import { INITIAL } from "../../config"; import { clone } from "lodash"; -import { MeshBasicMaterial } from "three"; +import { MeshBasicMaterial, Vector3 } from "three"; beforeEach(() => { jest.clearAllMocks(); @@ -120,14 +101,13 @@ describe("", () => { it("renders animated", () => { jest.spyOn(React, "useRef") - .mockImplementationOnce(() => mock4Ref) - .mockImplementationOnce(() => mock4Ref) .mockImplementationOnce(() => mock1Ref) .mockImplementationOnce(() => mock1Ref) .mockImplementationOnce(() => mock1Ref) - .mockImplementationOnce(() => mock0Ref) + .mockImplementationOnce(() => mock1Ref) + .mockImplementationOnce(() => mock1Ref) .mockImplementationOnce(() => mockMaterialRef); - jest.spyOn(React, "useState").mockReturnValue([[], jest.fn()]); + jest.spyOn(React, "useState").mockReturnValue([new Vector3(), jest.fn()]); const p = fakeProps(); p.config.animateSeasons = true; p.startTimeRef = { current: 0 }; @@ -145,26 +125,6 @@ describe("getCycleLength()", () => { }); }); -describe("getAnimatedSeasonDate()", () => { - it("uses representative season dates", () => { - const dayStart = new Date(Date.UTC(2026, 0, 1)); - expect(getAnimatedSeasonDate("Spring", 0, dayStart).toISOString()) - .toContain("2026-03-20"); - expect(getAnimatedSeasonDate("Summer", 0, dayStart).toISOString()) - .toContain("2026-06-21"); - expect(getAnimatedSeasonDate("Fall", 0, dayStart).toISOString()) - .toContain("2026-09-22"); - expect(getAnimatedSeasonDate("Winter", 0, dayStart).toISOString()) - .toContain("2026-12-21"); - }); - - it("keeps unknown seasons on the provided date", () => { - const dayStart = new Date(Date.UTC(2026, 0, 1)); - expect(getAnimatedSeasonDate("Random", 0, dayStart).toISOString()) - .toContain("2026-01-01"); - }); -}); - describe("skyColor(calcSunI())", () => { const DARK_BLUE = [ 0.04373502925049377, diff --git a/frontend/three_d_garden/garden/sun.tsx b/frontend/three_d_garden/garden/sun.tsx index 478bbfa62f..a27634294a 100644 --- a/frontend/three_d_garden/garden/sun.tsx +++ b/frontend/three_d_garden/garden/sun.tsx @@ -23,7 +23,7 @@ const shadowBias = -0.0005; const shadowRadius = 8; const shadowBlurSamples = 8; const shadowBuffer = 1000; -const SUN_COLOR = ["#FFD700", "#FFEA00", "#FFF700", "#FFE066"]; +const SUN_COLOR = "#FFD700"; const DAY_SECONDS = 24 * 60 * 60; const SUN_TIME_STEP_SECONDS = 60; const BELOW_HORIZON_SUN_SPEED = 10; @@ -50,21 +50,6 @@ export interface SunProps { skyRef: React.RefObject; } -const SUN_COUNT = 1; -const offset = 50; -const SUN_OFFSETS: [number, number][] = [ - [0, 0], - [0, offset], - [offset, offset], - [offset, 0], -]; - -const offsetSunPos = - (sunPos: Vector3, index: number): [number, number, number] => { - const offset = SUN_OFFSETS[index]; - return [sunPos.x + offset[0], sunPos.y + offset[1], sunPos.z]; - }; - export const calcSunCoordinate = ( date: Date, heading: number, @@ -201,17 +186,17 @@ export const Sun = (props: SunProps) => { config.sunAzimuth, BigDistance.sunActual); - const lightRefs = React.useRef<(ThreeDirectionalLight | null)[]>([]); - const sphereRefs = React.useRef<(Mesh | null)[]>([]); + // eslint-disable-next-line no-null/no-null + const lightRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null + const debugSunRef = React.useRef(null); // eslint-disable-next-line no-null/no-null const sunRef = React.useRef(null); // eslint-disable-next-line no-null/no-null const sunFlatRef = React.useRef(null); // eslint-disable-next-line no-null/no-null const lineRef = React.useRef(null); - const [points, setPoints] = React.useState( - range(SUN_COUNT).map(index => new Vector3(...offsetSunPos(sunPos, index))), - ); + const [point, setPoint] = React.useState(sunPos); // eslint-disable-next-line no-null/no-null const starsRef = React.useRef(null); const origin = new Vector3(0, 0, 0); @@ -258,26 +243,17 @@ export const Sun = (props: SunProps) => { const date = getAnimatedSeasonDate(config.plants, t); const { azimuth, inclination } = calcSunCoordinate(date, 0, 35, 0); const sunFactor = calcSunI(inclination); - const position = (index: number) => { - const sunPos = sunPosition(inclination, azimuth, BigDistance.sunActual); - return offsetSunPos(sunPos, index); - }; + const position = sunPosition(inclination, azimuth, BigDistance.sunActual); setSunSky(sunFactor, config.sun); - lightRefs.current.forEach((light, index) => { - if (light) { - light.position?.set(...position(index)); - light.intensity = - sunIntensity * config.sun / 100 * sunFactor; - } - }); + if (lightRef.current) { + lightRef.current.position?.set(position.x, position.y, position.z); + lightRef.current.intensity = + sunIntensity * config.sun / 100 * sunFactor; + } - sphereRefs.current.forEach((sphere, index) => { - if (sphere) { - sphere.position.set(...position(index)); - } - }); + debugSunRef.current?.position.set(position.x, position.y, position.z); const visualPos = sunPosition(inclination, azimuth, BigDistance.sunVisual); sunRef.current?.position?.set(visualPos.x, visualPos.y, visualPos.z); @@ -285,53 +261,40 @@ export const Sun = (props: SunProps) => { sunFlatRef.current?.position?.set(flatPos.x, flatPos.y, flatPos.z); if (lineRef.current) { - const newPoints = range(SUN_COUNT) - // eslint-disable-next-line @react-three/no-new-in-loop - .map(index => new Vector3(...position(index))); - setPoints(newPoints); + setPoint(position); } }); return - {range(SUN_COUNT).map(index => { - const position = offsetSunPos(sunPos, index); - const color = SUN_COLOR[index]; - const intensity = sunIntensity * config.sun / 100 * renderedSunFactor; - const castShadow = !config.lowDetail; - return - { - if (el) { lightRefs.current[index] = el; } - }} - intensity={intensity * 4 / SUN_COUNT} - color={sunColor} - castShadow={castShadow} - shadow-bias={shadowBias} - shadow-radius={shadowRadius} - shadow-blurSamples={shadowBlurSamples} - shadow-mapSize-width={1024} - shadow-mapSize-height={1024} - shadow-camera-near={1} - shadow-camera-far={BigDistance.sunAffect} - shadow-camera-left={-shadowBounds} - shadow-camera-right={shadowBounds} - shadow-camera-top={shadowBounds} - shadow-camera-bottom={-shadowBounds} - position={position} - /> - {config.lightsDebug && - } - {config.lightsDebug && - t}> - { if (el) { sphereRefs.current[index] = el; } }} - args={[500, 16, 16]} - position={position}> - - - } - ; - })} + + {config.lightsDebug && + } + {config.lightsDebug && + t}> + + + + } { config.sunInclination, config.sunAzimuth, BigDistance.sunVisual)}> - + {config.lightsDebug && } @@ -347,7 +310,7 @@ export const Sun = (props: SunProps) => { ref={sunFlatRef} args={[500, 8, 8]} position={sunPosition(0, config.sunAzimuth, BigDistance.ground)}> - + } ; }; From 6ea2ebbb931ac4c5be470704ff752a3b2f60f767 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 23:33:13 -0700 Subject: [PATCH 30/83] Optimize 3D soil height lookup --- frontend/performance/__tests__/perf_test.tsx | 6 +- frontend/performance/perf.ts | 10 ++ .../__tests__/triangle_functions_test.ts | 59 +++++++++ frontend/three_d_garden/triangle_functions.ts | 117 +++++++++++++++++- scripts/perf/stress_1000_3d.js | 54 ++++++++ 5 files changed, 242 insertions(+), 4 deletions(-) diff --git a/frontend/performance/__tests__/perf_test.tsx b/frontend/performance/__tests__/perf_test.tsx index 447ec63921..535b5ee6f1 100644 --- a/frontend/performance/__tests__/perf_test.tsx +++ b/frontend/performance/__tests__/perf_test.tsx @@ -1,6 +1,6 @@ import { - PerfMark, perfCount, perfEnabled, perfMark, perfSample, perfStore, - usePerfRenderCount, + PerfMark, perfCount, perfEnabled, perfMark, perfMeasure, perfSample, + perfStore, usePerfRenderCount, } from "../perf"; import React from "react"; import { render } from "@testing-library/react"; @@ -29,9 +29,11 @@ describe("perf helpers", () => { perfMark("ready"); perfCount("render.Test"); perfSample("fps", 60); + expect(perfMeasure("workMs", () => "done")).toEqual("done"); expect(perfStore()?.marks.ready.length).toEqual(1); expect(perfStore()?.counts["render.Test"]).toEqual(1); expect(perfStore()?.samples.fps).toEqual([60]); + expect(perfStore()?.samples.workMs.length).toEqual(1); }); it("counts renders", () => { diff --git a/frontend/performance/perf.ts b/frontend/performance/perf.ts index 381c5a9dba..1bab951bdd 100644 --- a/frontend/performance/perf.ts +++ b/frontend/performance/perf.ts @@ -51,6 +51,16 @@ export const perfSample = (name: string, value: number) => { store.samples[name].push(value); }; +export const perfMeasure = (name: string, fn: () => T): T => { + if (!perfEnabled()) { return fn(); } + const startedAt = performance.now(); + try { + return fn(); + } finally { + perfSample(name, performance.now() - startedAt); + } +}; + export const usePerfRenderCount = (name: string) => { perfCount(`render.${name}`); }; diff --git a/frontend/three_d_garden/__tests__/triangle_functions_test.ts b/frontend/three_d_garden/__tests__/triangle_functions_test.ts index 471e513db2..1ae28c0894 100644 --- a/frontend/three_d_garden/__tests__/triangle_functions_test.ts +++ b/frontend/three_d_garden/__tests__/triangle_functions_test.ts @@ -19,6 +19,10 @@ describe("precomputeTriangles()", () => { b: [4, 1, 0], c: [2, 3, 0], det: 6, + maxX: 4, + maxY: 3, + minX: 1, + minY: 1, x1: 1, x2: 4, x3: 2, @@ -40,6 +44,10 @@ describe("getZFunc()", () => { b: [2, 0, 20], c: [0, 2, 30], det: 4, + maxX: 2, + maxY: 2, + minX: 0, + minY: 0, x1: 0, x2: 2, x3: 0, @@ -55,6 +63,10 @@ describe("getZFunc()", () => { b: [2, 0, 20] as [number, number, number], c: [0, 2, 30] as [number, number, number], det: 4, + maxX: 2, + maxY: 2, + minX: 0, + minY: 0, x1: 0, x2: 2, x3: 0, @@ -68,4 +80,51 @@ describe("getZFunc()", () => { expect(getZ(1, 1)).toEqual(25); expect(getZFunc([triangle], -100)(1, 1)).toEqual(160); }); + + it("gets Z through the spatial index", () => { + const triangles = precomputeTriangles([ + [0, 0, 10], + [10, 0, 20], + [0, 10, 30], + [20, 0, 40], + [30, 0, 50], + [20, 10, 60], + [0, 20, 70], + [10, 20, 80], + [0, 30, 90], + [20, 20, 100], + [30, 20, 110], + [20, 30, 120], + ], [ + 0, 1, 2, + 3, 4, 5, + 6, 7, 8, + 9, 10, 11, + ]); + expect(getZFunc(triangles, -100)(25, 25)).toEqual(115); + expect(getZFunc(triangles, -100)(15, 15)).toEqual(-100); + }); + + it("keeps original triangle priority in indexed buckets", () => { + const triangles = precomputeTriangles([ + [0, 0, 10], + [10, 0, 10], + [0, 10, 10], + [0, 0, 20], + [10, 0, 20], + [0, 10, 20], + [20, 0, 30], + [30, 0, 30], + [20, 10, 30], + [0, 20, 40], + [10, 20, 40], + [0, 30, 40], + ], [ + 0, 1, 2, + 3, 4, 5, + 6, 7, 8, + 9, 10, 11, + ]); + expect(getZFunc(triangles, -100)(1, 1)).toEqual(10); + }); }); diff --git a/frontend/three_d_garden/triangle_functions.ts b/frontend/three_d_garden/triangle_functions.ts index 8edc1d2956..c3e41d0bc2 100644 --- a/frontend/three_d_garden/triangle_functions.ts +++ b/frontend/three_d_garden/triangle_functions.ts @@ -1,7 +1,13 @@ +import { perfEnabled, perfSample } from "../performance/perf"; + export interface TriangleData { a: [number, number, number]; b: [number, number, number]; c: [number, number, number]; + minX: number; + maxX: number; + minY: number; + maxY: number; x1: number; y1: number; x2: number; @@ -11,6 +17,21 @@ export interface TriangleData { det: number; } +interface TriangleIndex { + minX: number; + maxX: number; + minY: number; + maxY: number; + columns: number; + rows: number; + cellWidth: number; + cellHeight: number; + buckets: TriangleData[][]; +} + +const MAX_BUCKETS_PER_AXIS = 64; +const MIN_INDEXED_TRIANGLES = 4; + export const precomputeTriangles = ( vertices: [number, number, number][], faces: number[], @@ -28,22 +49,112 @@ export const precomputeTriangles = ( const det = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3); if (Math.abs(det) < 1e-10) { continue; } - triangles.push({ a, b, c, x1, y1, x2, y2, x3, y3, det }); + triangles.push({ + a, + b, + c, + minX: Math.min(x1, x2, x3), + maxX: Math.max(x1, x2, x3), + minY: Math.min(y1, y2, y3), + maxY: Math.max(y1, y2, y3), + x1, + y1, + x2, + y2, + x3, + y3, + det, + }); } return triangles; }; +const clampCell = (value: number, min: number, size: number, count: number) => + Math.min(count - 1, Math.max(0, Math.floor((value - min) / size))); + +const bucketIndex = (index: TriangleIndex, column: number, row: number) => + row * index.columns + column; + +const buildTriangleIndex = (triangles: TriangleData[]) => { + if (triangles.length < MIN_INDEXED_TRIANGLES) { return undefined; } + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const triangle of triangles) { + minX = Math.min(minX, triangle.minX); + maxX = Math.max(maxX, triangle.maxX); + minY = Math.min(minY, triangle.minY); + maxY = Math.max(maxY, triangle.maxY); + } + if (minX == maxX || minY == maxY) { return undefined; } + const bucketCount = Math.min( + MAX_BUCKETS_PER_AXIS, + Math.max(1, Math.ceil(Math.sqrt(triangles.length))), + ); + const index: TriangleIndex = { + minX, + maxX, + minY, + maxY, + columns: bucketCount, + rows: bucketCount, + cellWidth: (maxX - minX) / bucketCount, + cellHeight: (maxY - minY) / bucketCount, + buckets: Array.from({ length: bucketCount * bucketCount }, () => []), + }; + for (const triangle of triangles) { + const minColumn = clampCell(triangle.minX, minX, index.cellWidth, + index.columns); + const maxColumn = clampCell(triangle.maxX, minX, index.cellWidth, + index.columns); + const minRow = clampCell(triangle.minY, minY, index.cellHeight, + index.rows); + const maxRow = clampCell(triangle.maxY, minY, index.cellHeight, + index.rows); + for (let row = minRow; row <= maxRow; row++) { + for (let column = minColumn; column <= maxColumn; column++) { + index.buckets[bucketIndex(index, column, row)].push(triangle); + } + } + } + return index; +}; + +const indexedTrianglesForPoint = ( + index: TriangleIndex | undefined, + triangles: TriangleData[], + x: number, + y: number, +) => { + if (!index) { return triangles; } + if ( + x < index.minX || x > index.maxX || + y < index.minY || y > index.maxY + ) { + return []; + } + const column = clampCell(x, index.minX, index.cellWidth, index.columns); + const row = clampCell(y, index.minY, index.cellHeight, index.rows); + return index.buckets[bucketIndex(index, column, row)]; +}; + export const getZFunc = ( triangles: TriangleData[], fallback: number, ) => { const cache: Record = {}; + const measure = perfEnabled(); + const indexStartedAt = measure ? performance.now() : 0; + const index = buildTriangleIndex(triangles); + measure && perfSample("getZIndexMs", performance.now() - indexStartedAt); return (x: number, y: number) => { const key = `${x},${y}`; const cached = cache[key]; if (cached !== undefined) { return cached; } - for (const t of triangles) { + const startedAt = measure ? performance.now() : 0; + for (const t of indexedTrianglesForPoint(index, triangles, x, y)) { const { a, b, c, x1, y1, x2, y2, x3, y3, det } = t; const l1 = ((y2 - y3) * (x - x3) + (x3 - x2) * (y - y3)) / det; const l2 = ((y3 - y1) * (x - x3) + (x1 - x3) * (y - y3)) / det; @@ -51,10 +162,12 @@ export const getZFunc = ( if (l1 >= 0 && l2 >= 0 && l3 >= 0) { cache[key] = l1 * a[2] + l2 * b[2] + l3 * c[2]; + measure && perfSample("getZMs", performance.now() - startedAt); return cache[key]; } } cache[key] = fallback; + measure && perfSample("getZMs", performance.now() - startedAt); return cache[key]; }; }; diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js index 4e668c1f63..a628022576 100644 --- a/scripts/perf/stress_1000_3d.js +++ b/scripts/perf/stress_1000_3d.js @@ -54,6 +54,18 @@ const summary = runs => { gardenModelRenders: metric("gardenModelRenders"), threeDGardenRenders: metric("threeDGardenRenders"), plantInventoryItemRenders: metric("plantInventoryItemRenders"), + drawCalls: metric("drawCalls"), + triangles: metric("triangles"), + webglGeometries: metric("webglGeometries"), + webglTextures: metric("webglTextures"), + sceneObjects: metric("sceneObjects"), + sceneMeshes: metric("sceneMeshes"), + sceneInstancedMeshes: metric("sceneInstancedMeshes"), + usedJSHeapSize: metric("usedJSHeapSize"), + getZBatchMs: metric("getZBatchMs"), + getZCalls: metric("getZCalls"), + getZIndexMs: metric("getZIndexMs"), + getZP95Ms: metric("getZP95Ms"), }; }; @@ -99,6 +111,39 @@ const resourceSummary = async page => page.evaluate(() => { }; }); +const runtimeSummary = async page => page.evaluate(() => { + const parseLegacySceneMetrics = () => { + const values = (window.__scene_metrics || "") + .split(",") + .map(value => Number(value.trim())); + return { + calls: values[2], + triangles: values[3], + geometries: values[6], + textures: values[7], + total: values[8], + meshes: values[9], + instancedMeshes: values[10], + }; + }; + const legacy = parseLegacySceneMetrics(); + const render = window.__threeDRenderMetrics || {}; + const scene = window.__collectThreeDSceneMetrics?.() || {}; + const memory = performance.memory || {}; + return { + drawCalls: render.calls ?? legacy.calls, + triangles: render.triangles ?? legacy.triangles, + webglGeometries: render.geometries ?? legacy.geometries, + webglTextures: render.textures ?? legacy.textures, + sceneObjects: scene.total ?? legacy.total, + sceneMeshes: scene.meshes ?? legacy.meshes, + sceneInstancedMeshes: scene.instancedMeshes ?? legacy.instancedMeshes, + usedJSHeapSize: memory.usedJSHeapSize, + totalJSHeapSize: memory.totalJSHeapSize, + jsHeapSizeLimit: memory.jsHeapSizeLimit, + }; +}); + const createDemoSession = async (browser, baseUrl) => { const secret = crypto.randomUUID().replaceAll("-", ""); const page = await browser.newPage(); @@ -266,12 +311,15 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { await nextPaint(page); const resources = await resourceSummary(page); await page.waitForTimeout(options.sampleMs); + const runtime = await runtimeSummary(page); const perf = await page.evaluate(() => window.__fbPerf); const marks = perf?.marks || {}; const samples = perf?.samples || {}; const counts = perf?.counts || {}; const fpsSamples = samples.fps || []; const frameSamples = samples.frame_ms || []; + const getZSamples = samples.getZMs || []; + const getZIndexSamples = samples.getZIndexMs || []; const togglePlantsMs = await measureLayerToggle(page, "Plants"); const togglePointsMs = await measureLayerToggle(page, "Points"); const toggleWeedsMs = await measureLayerToggle(page, "Weeds"); @@ -326,6 +374,11 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { ]) || marks.garden_model_mounted?.[0], fpsMedian: median(fpsSamples), frameP95Ms: percentile(frameSamples, 95), + getZBatchMs: getZSamples.reduce((total, value) => total + value, 0), + getZCalls: getZSamples.length, + getZIndexMs: getZIndexSamples + .reduce((total, value) => total + value, 0), + getZP95Ms: percentile(getZSamples, 95), navPlantMs, navPointMs, navWeedMs, @@ -335,6 +388,7 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { toggleSpreadMs, toggleFarmbotMs, ...resources, + ...runtime, threeDGardenMapRenders: counts["render.ThreeDGardenMap"], gardenModelRenders: counts["render.GardenModel"], threeDGardenRenders: counts["render.ThreeDGarden"], From cafb9c3220346407f7dc0a7e59a5d345c466eb2b Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 23:43:19 -0700 Subject: [PATCH 31/83] Compact 3D soil triangle storage --- frontend/demo/lua_runner/stubs.ts | 8 +- .../__tests__/triangle_functions_test.ts | 31 +++++- frontend/three_d_garden/garden_model.tsx | 10 +- frontend/three_d_garden/triangle_functions.ts | 97 ++++++++++++++----- scripts/perf/stress_1000_3d.js | 6 ++ 5 files changed, 121 insertions(+), 31 deletions(-) diff --git a/frontend/demo/lua_runner/stubs.ts b/frontend/demo/lua_runner/stubs.ts index b99d1e314e..d13aa3fdbf 100644 --- a/frontend/demo/lua_runner/stubs.ts +++ b/frontend/demo/lua_runner/stubs.ts @@ -19,7 +19,9 @@ import { import { pointsSelectedByGroup } from "../../point_groups/criteria/apply"; import { sortGroupBy } from "../../point_groups/point_group_sort"; import { ResourceIndex } from "../../resources/interfaces"; -import { getZFunc, TriangleData } from "../../three_d_garden/triangle_functions"; +import { + getZFunc, parseStoredTriangles, +} from "../../three_d_garden/triangle_functions"; export const getFirmwareSettings = (): FirmwareConfig => { const fwConfig = getters.getFirmwareConfig(store.getState().resources.index); @@ -56,8 +58,8 @@ export const getSafeZ = (): number => { }; export const getSoilHeight = (x: number, y: number): number => { - const triangles = JSON.parse( - sessionStorage.getItem("soilSurfaceTriangles") || "[]") as TriangleData[]; + const triangles = parseStoredTriangles( + sessionStorage.getItem("soilSurfaceTriangles")); const getZ = getZFunc(triangles, -500); return getZ(x, y); }; diff --git a/frontend/three_d_garden/__tests__/triangle_functions_test.ts b/frontend/three_d_garden/__tests__/triangle_functions_test.ts index 1ae28c0894..54899e17d4 100644 --- a/frontend/three_d_garden/__tests__/triangle_functions_test.ts +++ b/frontend/three_d_garden/__tests__/triangle_functions_test.ts @@ -1,4 +1,6 @@ -import { getZFunc, precomputeTriangles } from "../triangle_functions"; +import { + getZFunc, parseStoredTriangles, precomputeTriangles, serializeTriangles, +} from "../triangle_functions"; describe("precomputeTriangles()", () => { it("computes triangles: zero", () => { @@ -128,3 +130,30 @@ describe("getZFunc()", () => { expect(getZFunc(triangles, -100)(1, 1)).toEqual(10); }); }); + +describe("stored triangles", () => { + it("serializes compact triangles", () => { + const triangles = precomputeTriangles([ + [0, 0, 10], + [2, 0, 20], + [0, 2, 30], + ], [0, 1, 2]); + expect(parseStoredTriangles(serializeTriangles(triangles))) + .toEqual(triangles); + }); + + it("parses legacy triangle objects", () => { + const triangles = precomputeTriangles([ + [0, 0, 10], + [2, 0, 20], + [0, 2, 30], + ], [0, 1, 2]); + expect(parseStoredTriangles(JSON.stringify(triangles))) + .toEqual(triangles); + }); + + it("ignores invalid stored triangles", () => { + expect(parseStoredTriangles("[\"foo\"]")).toEqual([]); + expect(parseStoredTriangles("not json")).toEqual([]); + }); +}); diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 0ae8a234b2..3c058672a7 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -44,14 +44,14 @@ import { SlotWithTool } from "../resources/interfaces"; import { cameraInit } from "./camera"; import { filterSoilPoints, getSurface } from "./triangles"; import { BigDistance } from "./constants"; -import { getZFunc } from "./triangle_functions"; +import { getZFunc, serializeTriangles } from "./triangle_functions"; import { Visualization } from "./visualization"; import { GroupOrderVisual } from "./group_order_visual"; import { MoistureReadings } from "./garden/moisture_texture"; import { FPSProbe } from "./fps_probe"; import { CameraSelectionUI } from "./camera_selection_ui"; import { - PerfMark, perfMark, usePerfRenderCount, + PerfMark, perfMark, perfMeasure, usePerfRenderCount, } from "../performance/perf"; import { botLoadInConfig, FallInGroup, GridRevealGroup, LoadStepReady, PopInGroup, @@ -271,8 +271,10 @@ export const GardenModel = (props: GardenModelProps) => { const soilSurface = React.useMemo(() => getSurface(soilPoints), [soilPoints]); React.useEffect(() => { - sessionStorage.setItem("soilSurfaceTriangles", - JSON.stringify(soilSurface.triangles)); + perfMeasure("soilStorageMs", () => { + sessionStorage.setItem("soilSurfaceTriangles", + serializeTriangles(soilSurface.triangles)); + }); }, [soilSurface.triangles]); const getZ = React.useMemo( () => getZFunc(soilSurface.triangles, -config.soilHeight), diff --git a/frontend/three_d_garden/triangle_functions.ts b/frontend/three_d_garden/triangle_functions.ts index c3e41d0bc2..6bef57faea 100644 --- a/frontend/three_d_garden/triangle_functions.ts +++ b/frontend/three_d_garden/triangle_functions.ts @@ -32,6 +32,34 @@ interface TriangleIndex { const MAX_BUCKETS_PER_AXIS = 64; const MIN_INDEXED_TRIANGLES = 4; +const triangleData = ( + a: [number, number, number], + b: [number, number, number], + c: [number, number, number], +) => { + const [x1, y1] = [a[0], a[1]]; + const [x2, y2] = [b[0], b[1]]; + const [x3, y3] = [c[0], c[1]]; + const det = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3); + if (Math.abs(det) < 1e-10) { return undefined; } + return { + a, + b, + c, + minX: Math.min(x1, x2, x3), + maxX: Math.max(x1, x2, x3), + minY: Math.min(y1, y2, y3), + maxY: Math.max(y1, y2, y3), + x1, + y1, + x2, + y2, + x3, + y3, + det, + }; +}; + export const precomputeTriangles = ( vertices: [number, number, number][], faces: number[], @@ -42,34 +70,57 @@ export const precomputeTriangles = ( const a = vertices[faces[i]]; const b = vertices[faces[i + 1]]; const c = vertices[faces[i + 2]]; - - const [x1, y1] = [a[0], a[1]]; - const [x2, y2] = [b[0], b[1]]; - const [x3, y3] = [c[0], c[1]]; - - const det = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3); - if (Math.abs(det) < 1e-10) { continue; } - triangles.push({ - a, - b, - c, - minX: Math.min(x1, x2, x3), - maxX: Math.max(x1, x2, x3), - minY: Math.min(y1, y2, y3), - maxY: Math.max(y1, y2, y3), - x1, - y1, - x2, - y2, - x3, - y3, - det, - }); + const triangle = triangleData(a, b, c); + triangle && triangles.push(triangle); } return triangles; }; +export const serializeTriangles = (triangles: TriangleData[]) => + JSON.stringify(triangles.map(({ a, b, c }) => [ + a[0], a[1], a[2], + b[0], b[1], b[2], + c[0], c[1], c[2], + ])); + +const pointFromArray = ( + values: unknown[], + offset: number, +): [number, number, number] | undefined => { + const point = values.slice(offset, offset + 3); + if (point.length != 3 || !point.every(Number.isFinite)) { + return undefined; + } + return point as [number, number, number]; +}; + +const parseStoredTriangle = (value: unknown): TriangleData | undefined => { + if (Array.isArray(value)) { + const a = pointFromArray(value, 0); + const b = pointFromArray(value, 3); + const c = pointFromArray(value, 6); + return a && b && c ? triangleData(a, b, c) : undefined; + } + if (!value || typeof value != "object") { return undefined; } + const { a, b, c } = value as Partial; + return Array.isArray(a) && Array.isArray(b) && Array.isArray(c) + ? triangleData(a, b, c) + : undefined; +}; + +export const parseStoredTriangles = (storedTriangles: string | null) => { + try { + const parsed = JSON.parse(storedTriangles || "[]") as unknown; + if (!Array.isArray(parsed)) { return []; } + return parsed + .map(parseStoredTriangle) + .filter((triangle): triangle is TriangleData => !!triangle); + } catch { + return []; + } +}; + const clampCell = (value: number, min: number, size: number, count: number) => Math.min(count - 1, Math.max(0, Math.floor((value - min) / size))); diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js index a628022576..5d891ff098 100644 --- a/scripts/perf/stress_1000_3d.js +++ b/scripts/perf/stress_1000_3d.js @@ -66,6 +66,8 @@ const summary = runs => { getZCalls: metric("getZCalls"), getZIndexMs: metric("getZIndexMs"), getZP95Ms: metric("getZP95Ms"), + soilStorageMs: metric("soilStorageMs"), + soilStorageCalls: metric("soilStorageCalls"), }; }; @@ -320,6 +322,7 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { const frameSamples = samples.frame_ms || []; const getZSamples = samples.getZMs || []; const getZIndexSamples = samples.getZIndexMs || []; + const soilStorageSamples = samples.soilStorageMs || []; const togglePlantsMs = await measureLayerToggle(page, "Plants"); const togglePointsMs = await measureLayerToggle(page, "Points"); const toggleWeedsMs = await measureLayerToggle(page, "Weeds"); @@ -379,6 +382,9 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { getZIndexMs: getZIndexSamples .reduce((total, value) => total + value, 0), getZP95Ms: percentile(getZSamples, 95), + soilStorageMs: soilStorageSamples + .reduce((total, value) => total + value, 0), + soilStorageCalls: soilStorageSamples.length, navPlantMs, navPointMs, navWeedMs, From 7a2c73ac70a5026de683111ed7d4e10e73bfced9 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 24 Apr 2026 23:51:53 -0700 Subject: [PATCH 32/83] Skip hidden 3D moisture interpolation --- .../__tests__/moisture_texture_test.tsx | 10 +++++++ .../garden/moisture_texture.tsx | 29 ++++++++++++------- scripts/perf/stress_1000_3d.js | 8 +++++ 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx index 2910dd0211..b4f2cb8cfa 100644 --- a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx @@ -6,6 +6,8 @@ import { INITIAL } from "../../config"; import { fakeSensor, fakeSensorReading, } from "../../../__test_support__/fake_state/resources"; +import * as interpolationMap from + "../../../farm_designer/map/layers/points/interpolation_map"; describe("", () => { const fakeProps = (): MoistureSurfaceProps => ({ @@ -40,4 +42,12 @@ describe("", () => { const { container } = render(); expect(container).toContainHTML("moisture-layer"); }); + + it("skips interpolation when the moisture map is hidden", () => { + const generateData = jest.spyOn(interpolationMap, "generateData"); + const p = fakeProps(); + p.showMoistureMap = false; + render(); + expect(generateData).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/three_d_garden/garden/moisture_texture.tsx b/frontend/three_d_garden/garden/moisture_texture.tsx index e75e333aac..b3ebddc702 100644 --- a/frontend/three_d_garden/garden/moisture_texture.tsx +++ b/frontend/three_d_garden/garden/moisture_texture.tsx @@ -11,6 +11,7 @@ import { filterMoistureReadings, getMoistureColor, } from "../../farm_designer/map/layers/sensor_readings/sensor_readings_layer"; import { InstancedBufferAttribute, InstancedMesh } from "three"; +import { perfMeasure } from "../../performance/perf"; export interface MoistureSurfaceProps { position: [number, number, number]; @@ -25,21 +26,27 @@ export interface MoistureSurfaceProps { } export const MoistureSurface = (props: MoistureSurfaceProps) => { - const { readings: moistureReadings } = - filterMoistureReadings(props.sensorReadings, props.sensors); const options = { stepSize: props.config.interpolationStepSize, useNearest: props.config.interpolationUseNearest, power: props.config.interpolationPower, }; - generateData({ - kind: "SensorReading", - points: moistureReadings, - gridSize: { x: props.config.bedLengthOuter, y: props.config.bedWidthOuter }, - getColor: getMoistureColor, - options, + const data = perfMeasure("moistureSurfaceMs", () => { + if (!props.showMoistureMap) { return []; } + const { readings: moistureReadings } = + filterMoistureReadings(props.sensorReadings, props.sensors); + generateData({ + kind: "SensorReading", + points: moistureReadings, + gridSize: { + x: props.config.bedLengthOuter, + y: props.config.bedWidthOuter, + }, + getColor: getMoistureColor, + options, + }); + return getInterpolationData("SensorReading"); }); - const data = getInterpolationData("SensorReading"); // eslint-disable-next-line no-null/no-null const ref = React.useRef(null); React.useEffect(() => { @@ -81,13 +88,13 @@ export const MoistureSurface = (props: MoistureSurfaceProps) => { "vec4 diffuseColor = vec4( diffuse, opacity );", "vec4 diffuseColor = vec4( diffuse, opacity * vInstanceOpacity );"); }} /> - {data.map(p => { + {perfMeasure("moistureInstanceNodesMs", () => data.map(p => { const { x, y, z } = p; return ; - })} + }))} } ; }; diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js index 5d891ff098..a97d318b11 100644 --- a/scripts/perf/stress_1000_3d.js +++ b/scripts/perf/stress_1000_3d.js @@ -68,6 +68,8 @@ const summary = runs => { getZP95Ms: metric("getZP95Ms"), soilStorageMs: metric("soilStorageMs"), soilStorageCalls: metric("soilStorageCalls"), + moistureSurfaceMs: metric("moistureSurfaceMs"), + moistureInstanceNodesMs: metric("moistureInstanceNodesMs"), }; }; @@ -323,6 +325,8 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { const getZSamples = samples.getZMs || []; const getZIndexSamples = samples.getZIndexMs || []; const soilStorageSamples = samples.soilStorageMs || []; + const moistureSurfaceSamples = samples.moistureSurfaceMs || []; + const moistureInstanceNodeSamples = samples.moistureInstanceNodesMs || []; const togglePlantsMs = await measureLayerToggle(page, "Plants"); const togglePointsMs = await measureLayerToggle(page, "Points"); const toggleWeedsMs = await measureLayerToggle(page, "Weeds"); @@ -385,6 +389,10 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { soilStorageMs: soilStorageSamples .reduce((total, value) => total + value, 0), soilStorageCalls: soilStorageSamples.length, + moistureSurfaceMs: moistureSurfaceSamples + .reduce((total, value) => total + value, 0), + moistureInstanceNodesMs: moistureInstanceNodeSamples + .reduce((total, value) => total + value, 0), navPlantMs, navPointMs, navWeedMs, From 402a494ba52534d1f86d1c04c62e179e0e0e446c Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Tue, 28 Apr 2026 13:11:09 -0700 Subject: [PATCH 33/83] Use native mesh for 3D moisture map --- .../__tests__/moisture_texture_test.tsx | 7 ++ .../garden/moisture_texture.tsx | 89 +++++++++++++------ frontend/three_d_garden/garden_model.tsx | 5 +- scripts/perf/stress_1000_3d.js | 42 +++++++++ 4 files changed, 113 insertions(+), 30 deletions(-) diff --git a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx index b4f2cb8cfa..67051f0506 100644 --- a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx @@ -43,6 +43,13 @@ describe("", () => { expect(container).toContainHTML("moisture-layer"); }); + it("renders the moisture map with a native instanced mesh", () => { + const { container } = render(); + expect(container.querySelector("instancedmesh")).toBeTruthy(); + expect(container.querySelector(".instances")).toBeFalsy(); + expect(container.querySelector(".instance")).toBeFalsy(); + }); + it("skips interpolation when the moisture map is hidden", () => { const generateData = jest.spyOn(interpolationMap, "generateData"); const p = fakeProps(); diff --git a/frontend/three_d_garden/garden/moisture_texture.tsx b/frontend/three_d_garden/garden/moisture_texture.tsx index b3ebddc702..960e002b40 100644 --- a/frontend/three_d_garden/garden/moisture_texture.tsx +++ b/frontend/three_d_garden/garden/moisture_texture.tsx @@ -1,7 +1,10 @@ import React from "react"; import { Config } from "../config"; -import { Instance, Instances, Sphere } from "@react-three/drei"; -import { BoxGeometry, Group, MeshBasicMaterial } from "../components"; +import { Sphere } from "@react-three/drei"; +import { + BoxGeometry, Group, InstancedMesh as InstancedMeshComponent, + MeshBasicMaterial, +} from "../components"; import { TaggedSensor, TaggedSensorReading } from "farmbot"; import { threeSpace, zZero } from "../helpers"; import { @@ -10,7 +13,9 @@ import { import { filterMoistureReadings, getMoistureColor, } from "../../farm_designer/map/layers/sensor_readings/sensor_readings_layer"; -import { InstancedBufferAttribute, InstancedMesh } from "three"; +import { + Color, InstancedBufferAttribute, InstancedMesh, Matrix4, +} from "three"; import { perfMeasure } from "../../performance/perf"; export interface MoistureSurfaceProps { @@ -26,12 +31,23 @@ export interface MoistureSurfaceProps { } export const MoistureSurface = (props: MoistureSurfaceProps) => { - const options = { - stepSize: props.config.interpolationStepSize, - useNearest: props.config.interpolationUseNearest, - power: props.config.interpolationPower, - }; - const data = perfMeasure("moistureSurfaceMs", () => { + const { + interpolationStepSize, + interpolationUseNearest, + interpolationPower, + bedLengthOuter, + bedWidthOuter, + } = props.config; + const options = React.useMemo(() => ({ + stepSize: interpolationStepSize, + useNearest: interpolationUseNearest, + power: interpolationPower, + }), [ + interpolationPower, + interpolationStepSize, + interpolationUseNearest, + ]); + const data = React.useMemo(() => perfMeasure("moistureSurfaceMs", () => { if (!props.showMoistureMap) { return []; } const { readings: moistureReadings } = filterMoistureReadings(props.sensorReadings, props.sensors); @@ -39,24 +55,45 @@ export const MoistureSurface = (props: MoistureSurfaceProps) => { kind: "SensorReading", points: moistureReadings, gridSize: { - x: props.config.bedLengthOuter, - y: props.config.bedWidthOuter, + x: bedLengthOuter, + y: bedWidthOuter, }, getColor: getMoistureColor, options, }); return getInterpolationData("SensorReading"); - }); + }), [ + bedLengthOuter, + bedWidthOuter, + options, + props.sensorReadings, + props.sensors, + props.showMoistureMap, + ]); // eslint-disable-next-line no-null/no-null const ref = React.useRef(null); - React.useEffect(() => { - const opacities = new Float32Array(data.length); - data.map((d, i) => { - opacities[i] = getMoistureColor(d.z).a; + const tempMatrix = React.useMemo(() => new Matrix4(), []); + const tempColor = React.useMemo(() => new Color(), []); + React.useLayoutEffect(() => { + perfMeasure("moistureInstanceNodesMs", () => { + const mesh = ref.current; + if (!mesh) { return; } + const opacities = new Float32Array(data.length); + data.map((d, i) => { + const color = getMoistureColor(d.z); + tempMatrix.identity().setPosition(d.x, d.y, d.z / 2); + mesh.setMatrixAt(i, tempMatrix); + mesh.setColorAt(i, tempColor.set(color.rgb)); + opacities[i] = color.a; + }); + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) { + mesh.instanceColor.needsUpdate = true; + } + mesh.geometry?.setAttribute("instanceOpacity", + new InstancedBufferAttribute(opacities, 1)); }); - ref.current?.geometry?.setAttribute("instanceOpacity", - new InstancedBufferAttribute(opacities, 1)); - }, [data]); + }, [data, tempColor, tempMatrix]); return {props.showMoistureReadings && { readingZOverride={props.readingZOverride} readings={props.sensorReadings} />} {props.showMoistureMap && - + { "vec4 diffuseColor = vec4( diffuse, opacity );", "vec4 diffuseColor = vec4( diffuse, opacity * vInstanceOpacity );"); }} /> - {perfMeasure("moistureInstanceNodesMs", () => data.map(p => { - const { x, y, z } = p; - return ; - }))} - } + } ; }; diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 3c058672a7..6063f33085 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -266,10 +266,11 @@ export const GardenModel = (props: GardenModelProps) => { const showSpread = !!addPlantProps?.getConfigValue(BooleanSetting.show_spread); const soilPoints = React.useMemo( - () => filterSoilPoints({ points: props.mapPoints, config }), + () => perfMeasure("soilPointFilterMs", () => + filterSoilPoints({ points: props.mapPoints, config })), [props.mapPoints, config]); const soilSurface = React.useMemo(() => - getSurface(soilPoints), [soilPoints]); + perfMeasure("soilSurfaceMs", () => getSurface(soilPoints)), [soilPoints]); React.useEffect(() => { perfMeasure("soilStorageMs", () => { sessionStorage.setItem("soilSurfaceTriangles", diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js index a97d318b11..e97bbb869b 100644 --- a/scripts/perf/stress_1000_3d.js +++ b/scripts/perf/stress_1000_3d.js @@ -66,6 +66,8 @@ const summary = runs => { getZCalls: metric("getZCalls"), getZIndexMs: metric("getZIndexMs"), getZP95Ms: metric("getZP95Ms"), + soilPointFilterMs: metric("soilPointFilterMs"), + soilSurfaceMs: metric("soilSurfaceMs"), soilStorageMs: metric("soilStorageMs"), soilStorageCalls: metric("soilStorageCalls"), moistureSurfaceMs: metric("moistureSurfaceMs"), @@ -298,6 +300,21 @@ const measureLayerToggle = async (page, labelText) => { return elapsed; }; +const ensureLayerVisible = async (page, labelText) => { + const toggle = page.locator("fieldset") + .filter({ hasText: labelText }) + .locator(".fb-layer-toggle") + .first(); + const count = await toggle.count(); + if (count == 0) { return; } + const className = await toggle.getAttribute("class"); + if (className?.includes("green")) { return; } + const beforeRenderCount = await page.evaluate(() => + window.__fbPerf?.counts?.["render.GardenModel"] || 0); + await toggle.click(); + await waitForGardenRender(page, beforeRenderCount); +}; + const collectRun = async (browser, baseUrl, session, runIndex, options) => { const context = await browser.newContext({ viewport: options.viewport, @@ -308,10 +325,19 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { window.localStorage.setItem("FPS_LOGS", "false"); }, { session }); const page = await context.newPage(); + page.on("pageerror", error => console.error("pageerror", error)); + page.on("console", message => { + if (message.type() == "error") { + console.error("console", message.text()); + } + }); page.setDefaultTimeout(TIMEOUT); const appUrl = `${baseUrl}/app/designer/plants?fb_perf=1`; await page.goto(appUrl, { waitUntil: "domcontentloaded" }); await waitFor3D(page); + if (options.moistureMap) { + await ensureLayerVisible(page, "Moisture"); + } await nextPaint(page); const resources = await resourceSummary(page); await page.waitForTimeout(options.sampleMs); @@ -324,6 +350,8 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { const frameSamples = samples.frame_ms || []; const getZSamples = samples.getZMs || []; const getZIndexSamples = samples.getZIndexMs || []; + const soilPointFilterSamples = samples.soilPointFilterMs || []; + const soilSurfaceSamples = samples.soilSurfaceMs || []; const soilStorageSamples = samples.soilStorageMs || []; const moistureSurfaceSamples = samples.moistureSurfaceMs || []; const moistureInstanceNodeSamples = samples.moistureInstanceNodesMs || []; @@ -386,6 +414,10 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { getZIndexMs: getZIndexSamples .reduce((total, value) => total + value, 0), getZP95Ms: percentile(getZSamples, 95), + soilPointFilterMs: soilPointFilterSamples + .reduce((total, value) => total + value, 0), + soilSurfaceMs: soilSurfaceSamples + .reduce((total, value) => total + value, 0), soilStorageMs: soilStorageSamples .reduce((total, value) => total + value, 0), soilStorageCalls: soilStorageSamples.length, @@ -437,11 +469,21 @@ const runBenchmark = async args => { if (lowDetail) { await setFarmwareEnv(baseUrl, session, "3D_lowDetail", "1"); } + if (["1", "true"].includes(args["moisture-map"])) { + await apiJson(baseUrl, session, "/api/web_app_config/", { + method: "PUT", + body: JSON.stringify({ + show_sensor_readings: true, + show_moisture_interpolation_map: true, + }), + }); + } const measuredRuns = []; for (let i = 0; i < warmups + runs; i++) { const run = await collectRun(browser, baseUrl, session, i, { viewport, sampleMs, + moistureMap: ["1", "true"].includes(args["moisture-map"]), }); console.log(`${i < warmups ? "warmup" : "run"} ${i + 1}`, run); if (i >= warmups) { measuredRuns.push(run); } From 50f37da30234e239b741cc4eef13661c8e0d41d8 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Tue, 28 Apr 2026 13:27:36 -0700 Subject: [PATCH 34/83] Optimize 3D image texture keys --- .../garden/__tests__/images_test.tsx | 11 +- frontend/three_d_garden/garden/images.tsx | 104 ++++++++++-------- .../garden/moisture_texture.tsx | 51 +++++---- scripts/perf/stress_1000_3d.js | 8 ++ 4 files changed, 105 insertions(+), 69 deletions(-) diff --git a/frontend/three_d_garden/garden/__tests__/images_test.tsx b/frontend/three_d_garden/garden/__tests__/images_test.tsx index 77683f4321..83c226e8c4 100644 --- a/frontend/three_d_garden/garden/__tests__/images_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/images_test.tsx @@ -8,7 +8,7 @@ import { import { INITIAL } from "../../config"; import { clone } from "lodash"; import { - fakeImage, fakeSensorReading, fakeWebAppConfig, + fakeImage, fakeSensor, fakeSensorReading, fakeWebAppConfig, } from "../../../__test_support__/fake_state/resources"; import { fakeAddPlantProps } from "../../../__test_support__/fake_props"; import * as mustBeOnline from "../../../devices/must_be_online"; @@ -173,6 +173,15 @@ describe("", () => { reading.body.value = 800; expect(getImageTextureKey(p)).not.toEqual(key); }); + + it("changes texture key when moisture sensor metadata changes", () => { + const p = fakeProps(); + const sensor = fakeSensor(); + p.sensors = [sensor]; + const key = getImageTextureKey(p); + sensor.body.pin = sensor.body.pin + 1; + expect(getImageTextureKey(p)).not.toEqual(key); + }); }); describe("extraRotation()", () => { diff --git a/frontend/three_d_garden/garden/images.tsx b/frontend/three_d_garden/garden/images.tsx index 057be598a0..d685589b38 100644 --- a/frontend/three_d_garden/garden/images.tsx +++ b/frontend/three_d_garden/garden/images.tsx @@ -20,6 +20,7 @@ import { } from "../../farm_designer/map/layers/images/map_image"; import { forceOnline } from "../../devices/must_be_online"; import { MoistureSurface } from "./moisture_texture"; +import { perfMeasure } from "../../performance/perf"; interface BaseProps { config: Config; @@ -87,6 +88,25 @@ export interface ImageTextureProps extends BaseProps { showMoistureMap: boolean; } +const getSensorKey = (sensors: TaggedSensor[]) => { + let key = ""; + sensors.map(sensor => { + key += `${sensor.uuid},${sensor.body.label},`; + key += `${sensor.body.mode},${sensor.body.pin}|`; + }); + return key; +}; + +const getSensorReadingKey = (readings: TaggedSensorReading[]) => { + let key = ""; + readings.map(reading => { + key += `${reading.uuid},${reading.body.x},${reading.body.y},`; + key += `${reading.body.z},${reading.body.value},`; + key += `${reading.body.mode},${reading.body.pin},${reading.body.read_at}|`; + }); + return key; +}; + export const getImageTextureKey = (props: ImageTextureProps) => { const extents = soilSurfaceExtents(props.config); const moistureVisible = props.showMoistureMap || props.showMoistureReadings; @@ -95,22 +115,8 @@ export const getImageTextureKey = (props: ImageTextureProps) => { extents.y.min, extents.y.max, props.showMoistureMap, props.showMoistureReadings, - moistureVisible && props.sensors.map(sensor => [ - sensor.uuid, - sensor.body.label, - sensor.body.mode, - sensor.body.pin, - ].join(",")).join("|"), - moistureVisible && props.sensorReadings.map(reading => [ - reading.uuid, - reading.body.x, - reading.body.y, - reading.body.z, - reading.body.value, - reading.body.mode, - reading.body.pin, - reading.body.read_at, - ].join(",")).join("|"), + moistureVisible && getSensorKey(props.sensors), + moistureVisible && getSensorReadingKey(props.sensorReadings), ].join(":"); }; @@ -125,7 +131,8 @@ export const ImageTexture = (props: ImageTextureProps) => { const textureHeight = height >= width ? textureSize : Math.max(1, Math.round(textureSize * height / width)); - const textureKey = getImageTextureKey(props); + const textureKey = perfMeasure("imageTextureSetupMs", () => + getImageTextureKey(props)); const { bedXOffset, bedYOffset, bedWallThickness } = props.config; const soilTexture = useTexture(ASSETS.textures.soil + "?=soilT"); const color = getColorFromBrightness(props.config.soilBrightness); @@ -133,15 +140,20 @@ export const ImageTexture = (props: ImageTextureProps) => { const designer = addPlantProps?.designer; const getConfigValue = addPlantProps?.getConfigValue; const visible = !!addPlantProps?.getConfigValue(BooleanSetting.show_images); - const filteredImages = filterImages({ - visible, - designer, - images, - getConfigValue, - calibrationZ: "" + props.config.imgCalZ, - }); - const imageArray = filteredImages.filter(img => !img.highlighted); - const lastImageArray = filteredImages.filter(img => img.highlighted); + const { imageArray, lastImageArray } = + perfMeasure("imageTextureSetupMs", () => { + const filteredImages = filterImages({ + visible, + designer, + images, + getConfigValue, + calibrationZ: "" + props.config.imgCalZ, + }); + return { + imageArray: filteredImages.filter(img => !img.highlighted), + lastImageArray: filteredImages.filter(img => img.highlighted), + }; + }); const highlightActive = lastImageArray[0]?.highlighted; const commonProps = { width, height, bedWallThickness }; const mirrorTextureProps = getMirrorTextureProps(props.config); @@ -237,27 +249,29 @@ const ImageWrapper = (props: ImageWrapperProps) => { const texture = useTexture(url); const i = (texture.source?.data ?? texture.image) as HTMLImageElement | undefined; if (!i) { return; } - const aspect = i.width / i.height; - const height = i.height * config.imgScale; - const width = height * aspect; - if (!props.image.highlighted && - !imageSizeCheck({ width: i.width, height: i.height }, - { x: "" + config.imgCenterX, y: "" + config.imgCenterY })) { return; } + return perfMeasure("imageWrapperSetupMs", () => { + const aspect = i.width / i.height; + const height = i.height * config.imgScale; + const width = height * aspect; + if (!props.image.highlighted && + !imageSizeCheck({ width: i.width, height: i.height }, + { x: "" + config.imgCenterX, y: "" + config.imgCenterY })) { return; } - const alreadyRotated = isRotated(props.image.body.meta.name); - const initialRotation = alreadyRotated ? 0 : config.imgRotation; - const rotation = (initialRotation + extraRotation(config)) * Math.PI / 180; + const alreadyRotated = isRotated(props.image.body.meta.name); + const initialRotation = alreadyRotated ? 0 : config.imgRotation; + const rotation = (initialRotation + extraRotation(config)) * Math.PI / 180; - return ; + return ; + }); }; export const extraRotation = (config: Config) => { diff --git a/frontend/three_d_garden/garden/moisture_texture.tsx b/frontend/three_d_garden/garden/moisture_texture.tsx index 960e002b40..56a5ed277b 100644 --- a/frontend/three_d_garden/garden/moisture_texture.tsx +++ b/frontend/three_d_garden/garden/moisture_texture.tsx @@ -13,9 +13,7 @@ import { import { filterMoistureReadings, getMoistureColor, } from "../../farm_designer/map/layers/sensor_readings/sensor_readings_layer"; -import { - Color, InstancedBufferAttribute, InstancedMesh, Matrix4, -} from "three"; +import { Color, Matrix4 } from "three"; import { perfMeasure } from "../../performance/perf"; export interface MoistureSurfaceProps { @@ -30,6 +28,12 @@ export interface MoistureSurfaceProps { showMoistureMap: boolean; } +interface MoistureInstanceBuffers { + matrices: Float32Array; + colors: Float32Array; + opacities: Float32Array; +} + export const MoistureSurface = (props: MoistureSurfaceProps) => { const { interpolationStepSize, @@ -70,30 +74,22 @@ export const MoistureSurface = (props: MoistureSurfaceProps) => { props.sensors, props.showMoistureMap, ]); - // eslint-disable-next-line no-null/no-null - const ref = React.useRef(null); - const tempMatrix = React.useMemo(() => new Matrix4(), []); - const tempColor = React.useMemo(() => new Color(), []); - React.useLayoutEffect(() => { + const buffers = React.useMemo(() => perfMeasure("moistureInstanceNodesMs", () => { - const mesh = ref.current; - if (!mesh) { return; } + const matrices = new Float32Array(data.length * 16); + const colors = new Float32Array(data.length * 3); const opacities = new Float32Array(data.length); + const matrix = new Matrix4(); + const instanceColor = new Color(); data.map((d, i) => { const color = getMoistureColor(d.z); - tempMatrix.identity().setPosition(d.x, d.y, d.z / 2); - mesh.setMatrixAt(i, tempMatrix); - mesh.setColorAt(i, tempColor.set(color.rgb)); + matrix.identity().setPosition(d.x, d.y, d.z / 2); + matrix.toArray(matrices, i * 16); + instanceColor.set(color.rgb).toArray(colors, i * 3); opacities[i] = color.a; }); - mesh.instanceMatrix.needsUpdate = true; - if (mesh.instanceColor) { - mesh.instanceColor.needsUpdate = true; - } - mesh.geometry?.setAttribute("instanceOpacity", - new InstancedBufferAttribute(opacities, 1)); - }); - }, [data, tempColor, tempMatrix]); + return { matrices, colors, opacities }; + }), [data]); return {props.showMoistureReadings && { readings={props.sensorReadings} />} {props.showMoistureMap && + + + args={[options.stepSize, options.stepSize, options.stepSize]}> + + { shader.vertexShader = ` diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js index e97bbb869b..81ef3a6154 100644 --- a/scripts/perf/stress_1000_3d.js +++ b/scripts/perf/stress_1000_3d.js @@ -70,6 +70,8 @@ const summary = runs => { soilSurfaceMs: metric("soilSurfaceMs"), soilStorageMs: metric("soilStorageMs"), soilStorageCalls: metric("soilStorageCalls"), + imageTextureSetupMs: metric("imageTextureSetupMs"), + imageWrapperSetupMs: metric("imageWrapperSetupMs"), moistureSurfaceMs: metric("moistureSurfaceMs"), moistureInstanceNodesMs: metric("moistureInstanceNodesMs"), }; @@ -353,6 +355,8 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { const soilPointFilterSamples = samples.soilPointFilterMs || []; const soilSurfaceSamples = samples.soilSurfaceMs || []; const soilStorageSamples = samples.soilStorageMs || []; + const imageTextureSetupSamples = samples.imageTextureSetupMs || []; + const imageWrapperSetupSamples = samples.imageWrapperSetupMs || []; const moistureSurfaceSamples = samples.moistureSurfaceMs || []; const moistureInstanceNodeSamples = samples.moistureInstanceNodesMs || []; const togglePlantsMs = await measureLayerToggle(page, "Plants"); @@ -421,6 +425,10 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { soilStorageMs: soilStorageSamples .reduce((total, value) => total + value, 0), soilStorageCalls: soilStorageSamples.length, + imageTextureSetupMs: imageTextureSetupSamples + .reduce((total, value) => total + value, 0), + imageWrapperSetupMs: imageWrapperSetupSamples + .reduce((total, value) => total + value, 0), moistureSurfaceMs: moistureSurfaceSamples .reduce((total, value) => total + value, 0), moistureInstanceNodesMs: moistureInstanceNodeSamples From f51bbfe96131a7cb007c95ddeff48684d1989e67 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Tue, 28 Apr 2026 13:42:53 -0700 Subject: [PATCH 35/83] Limit 3D soil texture rerenders --- frontend/__test_support__/three_d_mocks.tsx | 8 +- .../garden/__tests__/images_test.tsx | 4 +- frontend/three_d_garden/garden/images.tsx | 4 +- scripts/perf/stress_1000_3d.js | 124 ++++++++++++++++++ 4 files changed, 136 insertions(+), 4 deletions(-) diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index 2cfc183de1..60609f69b5 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -809,8 +809,12 @@ jest.mock("@react-three/drei", () => { }; return makeTexture(); }), - RenderTexture: ({ children }: { children: ReactNode }) => -
{children}
, + RenderTexture: ( + props: { children: ReactNode, frames?: number }, + ) => +
+ {props.children} +
, GizmoHelper: ({ name }: { name: string }) =>
{name}
, GizmoViewcube: ({ name }: { name: string }) => diff --git a/frontend/three_d_garden/garden/__tests__/images_test.tsx b/frontend/three_d_garden/garden/__tests__/images_test.tsx index 83c226e8c4..ad697a6c4c 100644 --- a/frontend/three_d_garden/garden/__tests__/images_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/images_test.tsx @@ -61,6 +61,8 @@ describe("", () => { p.addPlantProps = apProps; render(); expect(screen.getAllByText("image").length).toEqual(3); + expect(document.querySelector(".render-texture")) + .toHaveAttribute("data-frames", "1"); }); it("renders when images missing", () => { @@ -179,7 +181,7 @@ describe("", () => { const sensor = fakeSensor(); p.sensors = [sensor]; const key = getImageTextureKey(p); - sensor.body.pin = sensor.body.pin + 1; + sensor.body.pin = (sensor.body.pin || 0) + 1; expect(getImageTextureKey(p)).not.toEqual(key); }); }); diff --git a/frontend/three_d_garden/garden/images.tsx b/frontend/three_d_garden/garden/images.tsx index d685589b38..0e2f37f427 100644 --- a/frontend/three_d_garden/garden/images.tsx +++ b/frontend/three_d_garden/garden/images.tsx @@ -20,7 +20,7 @@ import { } from "../../farm_designer/map/layers/images/map_image"; import { forceOnline } from "../../devices/must_be_online"; import { MoistureSurface } from "./moisture_texture"; -import { perfMeasure } from "../../performance/perf"; +import { perfCount, perfMeasure } from "../../performance/perf"; interface BaseProps { config: Config; @@ -40,6 +40,7 @@ interface PlaneWrapperProps { const PlaneWrapper = (props: PlaneWrapperProps) => perfCount("soilTextureRenders")} position={[ props.bedWallThickness + props.width / 2, props.bedWallThickness + props.height / 2, @@ -160,6 +161,7 @@ export const ImageTexture = (props: ImageTextureProps) => { return { soilStorageCalls: metric("soilStorageCalls"), imageTextureSetupMs: metric("imageTextureSetupMs"), imageWrapperSetupMs: metric("imageWrapperSetupMs"), + soilTextureRenders: metric("soilTextureRenders"), moistureSurfaceMs: metric("moistureSurfaceMs"), moistureInstanceNodesMs: metric("moistureInstanceNodesMs"), }; @@ -429,6 +430,7 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { .reduce((total, value) => total + value, 0), imageWrapperSetupMs: imageWrapperSetupSamples .reduce((total, value) => total + value, 0), + soilTextureRenders: counts.soilTextureRenders || 0, moistureSurfaceMs: moistureSurfaceSamples .reduce((total, value) => total + value, 0), moistureInstanceNodesMs: moistureInstanceNodeSamples @@ -513,6 +515,124 @@ const runBenchmark = async args => { } }; +const getSession = async (browser, baseUrl, sessionFile) => { + if (sessionFile && fs.existsSync(sessionFile)) { + return fs.readFileSync(sessionFile, "utf8"); + } + const session = await createDemoSession(browser, baseUrl); + if (sessionFile) { + fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); + fs.writeFileSync(sessionFile, session); + } + return session; +}; + +const screenshot = async args => { + const baseUrl = args["base-url"] || DEFAULT_URL; + const out = args.out || "tmp/perf/three_d_garden.png"; + const viewport = { + width: Number(args.width || DEFAULT_VIEWPORT.width), + height: Number(args.height || DEFAULT_VIEWPORT.height), + }; + const browser = await chromium.launch({ + headless: true, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--enable-gpu", + ], + }); + try { + const session = await getSession(browser, baseUrl, args["session-file"]); + const context = await browser.newContext({ viewport }); + await context.addInitScript(value => { + window.localStorage.setItem("session", value.session); + window.localStorage.setItem("FB_PERF_BENCHMARK", "true"); + window.localStorage.setItem("FPS_LOGS", "false"); + }, { session }); + const page = await context.newPage(); + page.setDefaultTimeout(TIMEOUT); + await page.goto(`${baseUrl}/app/designer/plants?fb_perf=1`, { + waitUntil: "domcontentloaded", + }); + await waitFor3D(page); + await page.waitForTimeout(Number(args["settle-ms"] || 3_000)); + await nextPaint(page); + const canvas = page.locator(".garden-bed-3d-model canvas").first(); + fs.mkdirSync(path.dirname(out), { recursive: true }); + await canvas.screenshot({ path: out }); + await context.close(); + console.log(`Wrote ${out}`); + } finally { + await browser.close(); + } +}; + +const imageDiff = async args => { + const before = fs.readFileSync(args.before, "base64"); + const after = fs.readFileSync(args.after, "base64"); + const threshold = Number(args.threshold || 3); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + const result = await page.evaluate(async ({ before, after, threshold }) => { + const load = data => new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = reject; + image.src = `data:image/png;base64,${data}`; + }); + const [beforeImage, afterImage] = + await Promise.all([load(before), load(after)]); + const width = beforeImage.width; + const height = beforeImage.height; + if (width != afterImage.width || height != afterImage.height) { + throw new Error("Image dimensions differ."); + } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + context.drawImage(beforeImage, 0, 0); + const beforePixels = + context.getImageData(0, 0, width, height).data; + context.clearRect(0, 0, width, height); + context.drawImage(afterImage, 0, 0); + const afterPixels = + context.getImageData(0, 0, width, height).data; + let diffPixels = 0; + let maxDelta = 0; + let totalDelta = 0; + for (let i = 0; i < beforePixels.length; i += 4) { + const delta = Math.max( + Math.abs(beforePixels[i] - afterPixels[i]), + Math.abs(beforePixels[i + 1] - afterPixels[i + 1]), + Math.abs(beforePixels[i + 2] - afterPixels[i + 2]), + Math.abs(beforePixels[i + 3] - afterPixels[i + 3]), + ); + maxDelta = Math.max(maxDelta, delta); + totalDelta += delta; + if (delta > threshold) { diffPixels++; } + } + const pixels = width * height; + return { + width, + height, + pixels, + diffPixels, + diffRatio: diffPixels / pixels, + maxDelta, + avgDelta: totalDelta / pixels, + }; + }, { before, after, threshold }); + console.log(result); + if (result.diffPixels > 0) { process.exitCode = 1; } + } finally { + await browser.close(); + } +}; + const readJson = file => JSON.parse(fs.readFileSync(file, "utf8")); const compare = args => { @@ -547,6 +667,10 @@ const main = async () => { const args = parseArgs(); if (args.command == "compare") { compare(args); + } else if (args.command == "screenshot") { + await screenshot(args); + } else if (args.command == "image-diff") { + await imageDiff(args); } else { await runBenchmark(args); } From 125284115dce255b3fcc32c587fd132a4e7c6e35 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Tue, 28 Apr 2026 13:54:10 -0700 Subject: [PATCH 36/83] Gate static 3D spread updates --- .../garden/__tests__/plants_test.tsx | 36 ++++++ frontend/three_d_garden/garden/plants.tsx | 114 ++++++++++-------- scripts/perf/stress_1000_3d.js | 4 + 3 files changed, 106 insertions(+), 48 deletions(-) diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 6c2c5a5bc7..70f96727b5 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -307,6 +307,42 @@ describe("", () => { expect(setMatrixAt).not.toHaveBeenCalled(); }); + it("skips repeated visible spread frame updates", () => { + queueMeshRef(); + const p = fakeProps(); + p.spreadVisible = true; + render(); + const meshRef = allRefs[0]; + meshRef.current = buildMeshRef(); + const mesh = meshRef.current; + const setMatrixAt = mesh?.setMatrixAt as jest.Mock; + const frameFn = (useFrame as jest.Mock).mock.calls[0][0]; + const state = { camera: { quaternion: new Quaternion() } }; + frameFn(state); + setMatrixAt.mockClear(); + frameFn(state); + expect(setMatrixAt).not.toHaveBeenCalled(); + }); + + it("updates click-to-add spread when active position changes", () => { + queueMeshRef(); + const p = fakeProps(); + p.spreadVisible = true; + getModeSpy.mockReturnValue(Mode.clickToAdd); + render(); + const meshRef = allRefs[0]; + meshRef.current = buildMeshRef(); + const mesh = meshRef.current; + const setMatrixAt = mesh?.setMatrixAt as jest.Mock; + const frameFn = (useFrame as jest.Mock).mock.calls[0][0]; + const state = { camera: { quaternion: new Quaternion() } }; + frameFn(state); + setMatrixAt.mockClear(); + p.activePositionRef.current = { x: 100, y: 100 }; + frameFn(state); + expect(setMatrixAt).toHaveBeenCalled(); + }); + it("handles missing mesh in layout effect", () => { reactUseImperativeHandleSpy.mockImplementation(() => undefined); reactUseRefSpy.mockImplementation(() => ({ current: undefined }) as never); diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index da902ec3e1..5d48f0d8e6 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -32,6 +32,7 @@ import { import { ActivePositionRef } from "../bed/objects/pointer_objects"; import { Mode } from "../../farm_designer/map/interfaces"; import { findCrop } from "../../crops/find"; +import { perfMeasure } from "../../performance/perf"; export interface ThreeDGardenPlant { id?: number | undefined; @@ -106,10 +107,12 @@ export interface PlantSpreadInstancesProps { interface PlantSpreadUpdateState { needsInstanceUpdate: boolean; + lastUpdateKey: string; } const newPlantSpreadUpdateState = (): PlantSpreadUpdateState => ({ needsInstanceUpdate: true, + lastUpdateKey: "", }); export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps) => { @@ -130,7 +133,8 @@ export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps const getUpdateState = () => { const current = updateStateRef.current as Partial | undefined; - if (typeof current?.needsInstanceUpdate != "boolean") { + if (typeof current?.needsInstanceUpdate != "boolean" || + typeof current?.lastUpdateKey != "string") { updateStateRef.current = newPlantSpreadUpdateState(); } return updateStateRef.current; @@ -199,61 +203,75 @@ export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps if (!spreadActive && !updateState.needsInstanceUpdate) { return; } ensureInstanceColor(mesh); tempQuaternion.copy(state.camera.quaternion); - const worldPos = activePositionRef.current || { x: -10000, y: -10000 }; - const activePointer = getGardenPositionFunc(config)(worldPos); const active = editPlantMode ? { x: currentPlant?.x || -10000, y: currentPlant?.y || -10000, } - : { - x: activePointer.x, - y: activePointer.y, - }; - plants.forEach((plant, index) => { - const spreadRadii = getSpreadRadii({ - activeDragSpread, - inactiveSpread: plant.spread, - radius: plant.size / 2, - }); - const scale = (spreadVisible || !plant.id || editPlantMode) - ? spreadRadii.inactive - : 0; - const position = get3DPosition({ x: plant.x, y: plant.y }); - tempPosition.set( - position.x, - position.y, - getPlantZ(plant.size, plant), - ); - tempScale.set(scale, scale, scale); - tempMatrix.compose(tempPosition, tempQuaternion, tempScale); - mesh.setMatrixAt(index, tempMatrix); - if (mesh.setColorAt) { - const overlap = getSpreadOverlap({ - spreadRadii, - activeDragXY: { - x: round(active.x), - y: round(active.y), - z: 0, - }, - plantXY: { - x: round(plant.x), - y: round(plant.y), - z: 0, - }, + : getGardenPositionFunc(config)( + activePositionRef.current || { x: -10000, y: -10000 }); + const activeKey = (clickToAddMode || editPlantMode) + ? `${round(active.x)}:${round(active.y)}` + : ""; + const updateKey = [ + spreadVisible, + editPlantMode, + clickToAddMode, + hasTransientPlant, + plantId, + activeDragSpread || "", + activeKey, + ].join(":"); + if (!updateState.needsInstanceUpdate && + updateState.lastUpdateKey == updateKey) { return; } + perfMeasure("spreadFrameUpdateMs", () => { + plants.forEach((plant, index) => { + const spreadRadii = getSpreadRadii({ + activeDragSpread, + inactiveSpread: plant.spread, + radius: plant.size / 2, }); - const color = (plant.id && (plantId != plant.id)) - ? overlap.color.rgb - : [1, 1, 1]; - const insideColor = - (clickToAddMode || editPlantMode) ? color : [0, 1, 0]; - tempColor.setRGB(insideColor[0], insideColor[1], insideColor[2]); - mesh.setColorAt(index, tempColor); - } + const scale = (spreadVisible || !plant.id || editPlantMode) + ? spreadRadii.inactive + : 0; + const position = get3DPosition({ x: plant.x, y: plant.y }); + tempPosition.set( + position.x, + position.y, + getPlantZ(plant.size, plant), + ); + tempScale.set(scale, scale, scale); + tempMatrix.compose(tempPosition, tempQuaternion, tempScale); + mesh.setMatrixAt(index, tempMatrix); + if (mesh.setColorAt) { + let insideColor = [0, 1, 0]; + if (clickToAddMode || editPlantMode) { + const overlap = getSpreadOverlap({ + spreadRadii, + activeDragXY: { + x: round(active.x), + y: round(active.y), + z: 0, + }, + plantXY: { + x: round(plant.x), + y: round(plant.y), + z: 0, + }, + }); + insideColor = (plant.id && (plantId != plant.id)) + ? overlap.color.rgb + : [1, 1, 1]; + } + tempColor.setRGB(insideColor[0], insideColor[1], insideColor[2]); + mesh.setColorAt(index, tempColor); + } + }); + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; } }); - mesh.instanceMatrix.needsUpdate = true; - if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; } updateState.needsInstanceUpdate = false; + updateState.lastUpdateKey = updateKey; }); const onClick = (event: ThreeEvent) => { diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js index fd9a7fa066..bb112fb498 100644 --- a/scripts/perf/stress_1000_3d.js +++ b/scripts/perf/stress_1000_3d.js @@ -73,6 +73,7 @@ const summary = runs => { imageTextureSetupMs: metric("imageTextureSetupMs"), imageWrapperSetupMs: metric("imageWrapperSetupMs"), soilTextureRenders: metric("soilTextureRenders"), + spreadFrameUpdateMs: metric("spreadFrameUpdateMs"), moistureSurfaceMs: metric("moistureSurfaceMs"), moistureInstanceNodesMs: metric("moistureInstanceNodesMs"), }; @@ -358,6 +359,7 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { const soilStorageSamples = samples.soilStorageMs || []; const imageTextureSetupSamples = samples.imageTextureSetupMs || []; const imageWrapperSetupSamples = samples.imageWrapperSetupMs || []; + const spreadFrameUpdateSamples = samples.spreadFrameUpdateMs || []; const moistureSurfaceSamples = samples.moistureSurfaceMs || []; const moistureInstanceNodeSamples = samples.moistureInstanceNodesMs || []; const togglePlantsMs = await measureLayerToggle(page, "Plants"); @@ -431,6 +433,8 @@ const collectRun = async (browser, baseUrl, session, runIndex, options) => { imageWrapperSetupMs: imageWrapperSetupSamples .reduce((total, value) => total + value, 0), soilTextureRenders: counts.soilTextureRenders || 0, + spreadFrameUpdateMs: spreadFrameUpdateSamples + .reduce((total, value) => total + value, 0), moistureSurfaceMs: moistureSurfaceSamples .reduce((total, value) => total + value, 0), moistureInstanceNodesMs: moistureInstanceNodeSamples From 06dcf78ecbc1fffcbaab07e39f65645d7eecf746 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Tue, 28 Apr 2026 14:09:50 -0700 Subject: [PATCH 37/83] Handle hidden controls in 3D perf benchmark --- scripts/perf/stress_1000_3d.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js index bb112fb498..6e44de53bd 100644 --- a/scripts/perf/stress_1000_3d.js +++ b/scripts/perf/stress_1000_3d.js @@ -277,6 +277,7 @@ const clickAndMeasure = async ( count = await item.count(); } if (count == 0) { return undefined; } + if (!await item.isVisible()) { return undefined; } const startedAt = await page.evaluate(() => performance.now()); await item.click(); await page.waitForSelector(panelSelector, { timeout: TIMEOUT }); @@ -290,6 +291,7 @@ const measureLayerToggle = async (page, labelText) => { .first(); const count = await toggle.count(); if (count == 0) { return undefined; } + if (!await toggle.isVisible()) { return undefined; } const beforeRenderCount = await page.evaluate(() => window.__fbPerf?.counts?.["render.GardenModel"] || 0); const startedAt = await page.evaluate(() => performance.now()); @@ -311,6 +313,7 @@ const ensureLayerVisible = async (page, labelText) => { .first(); const count = await toggle.count(); if (count == 0) { return; } + if (!await toggle.isVisible()) { return; } const className = await toggle.getAttribute("class"); if (className?.includes("green")) { return; } const beforeRenderCount = await page.evaluate(() => From 47ceb3dfe20c2cf5b65ff78ebcaea7e5388a3f18 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Tue, 28 Apr 2026 14:23:02 -0700 Subject: [PATCH 38/83] Bake static 3D bot instance geometry --- .../bot/parts/__tests__/cross_slide_test.tsx | 2 +- .../__tests__/gantry_wheel_plate_test.tsx | 2 +- .../merged_instanced_geometry_test.ts | 45 +++++++++++++++++++ .../three_d_garden/bot/parts/cross_slide.tsx | 14 ++++++ .../bot/parts/gantry_wheel_plate.tsx | 14 ++++++ .../bot/parts/merged_instanced_geometry.ts | 40 +++++++++++++++++ 6 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 frontend/three_d_garden/bot/parts/__tests__/merged_instanced_geometry_test.ts create mode 100644 frontend/three_d_garden/bot/parts/merged_instanced_geometry.ts diff --git a/frontend/three_d_garden/bot/parts/__tests__/cross_slide_test.tsx b/frontend/three_d_garden/bot/parts/__tests__/cross_slide_test.tsx index ee0ee4118a..13abe438e0 100644 --- a/frontend/three_d_garden/bot/parts/__tests__/cross_slide_test.tsx +++ b/frontend/three_d_garden/bot/parts/__tests__/cross_slide_test.tsx @@ -10,6 +10,6 @@ describe("", () => { const Component = CrossSlide(model); const { container } = render(); expect(container.innerHTML).toContain("name"); - expect(container.innerHTML).toContain("instancedmesh"); + expect(container.querySelector("mesh, instancedmesh")).toBeTruthy(); }); }); diff --git a/frontend/three_d_garden/bot/parts/__tests__/gantry_wheel_plate_test.tsx b/frontend/three_d_garden/bot/parts/__tests__/gantry_wheel_plate_test.tsx index 1459198c1b..34cd164b0c 100644 --- a/frontend/three_d_garden/bot/parts/__tests__/gantry_wheel_plate_test.tsx +++ b/frontend/three_d_garden/bot/parts/__tests__/gantry_wheel_plate_test.tsx @@ -10,6 +10,6 @@ describe("", () => { const Component = GantryWheelPlate(model); const { container } = render(); expect(container.innerHTML).toContain("name"); - expect(container.innerHTML).toContain("instancedmesh"); + expect(container.querySelector("mesh, instancedmesh")).toBeTruthy(); }); }); diff --git a/frontend/three_d_garden/bot/parts/__tests__/merged_instanced_geometry_test.ts b/frontend/three_d_garden/bot/parts/__tests__/merged_instanced_geometry_test.ts new file mode 100644 index 0000000000..586c69b48c --- /dev/null +++ b/frontend/three_d_garden/bot/parts/__tests__/merged_instanced_geometry_test.ts @@ -0,0 +1,45 @@ +import * as THREE from "three"; +import { mergedInstancedGeometry } from "../merged_instanced_geometry"; + +describe("mergedInstancedGeometry", () => { + it("bakes instanced matrices into one geometry", () => { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute( + new Float32Array([ + 0, 0, 0, + 1, 0, 0, + 0, 1, 0, + ]), + 3, + )); + const matrices = new Float32Array(32); + new THREE.Matrix4().identity().toArray(matrices, 0); + new THREE.Matrix4().makeTranslation(10, 0, 0).toArray(matrices, 16); + const model = { + nodes: { + mesh0_mesh: { + geometry, + instanceMatrix: new THREE.InstancedBufferAttribute(matrices, 16), + } as THREE.Mesh & { instanceMatrix: THREE.InstancedBufferAttribute }, + ignored: { geometry } as THREE.Mesh, + }, + }; + + const merged = mergedInstancedGeometry(model, /^mesh/); + + expect(merged?.getAttribute("position").count).toEqual(6); + const positions = merged?.getAttribute("position").array; + expect(Array.from(positions || []).slice(9, 12)).toEqual([10, 0, 0]); + expect(mergedInstancedGeometry(model, /^mesh/)).toBe(merged); + }); + + it("returns undefined without matching instanced nodes", () => { + const model = { + nodes: { + other: { geometry: new THREE.BufferGeometry() } as THREE.Mesh, + }, + }; + + expect(mergedInstancedGeometry(model, /^mesh/)).toBeUndefined(); + }); +}); diff --git a/frontend/three_d_garden/bot/parts/cross_slide.tsx b/frontend/three_d_garden/bot/parts/cross_slide.tsx index c648a3a657..fea0f75970 100644 --- a/frontend/three_d_garden/bot/parts/cross_slide.tsx +++ b/frontend/three_d_garden/bot/parts/cross_slide.tsx @@ -5,6 +5,7 @@ import { InstancedBufferAttribute } from "three"; import type { GLTF } from "three-stdlib"; import { Group, Mesh as MeshComponent, InstancedMesh } from "../../components"; import { ThreeElements } from "@react-three/fiber"; +import { mergedInstancedGeometry } from "./merged_instanced_geometry"; type Mesh = THREE.Mesh & { instanceMatrix: InstancedBufferAttribute | undefined }; @@ -153,6 +154,19 @@ interface CrossSlideProps extends Omit { export const CrossSlideModel = (props: CrossSlideProps) => { const { model, ...groupProps } = props; const { nodes, materials } = model; + const mergedGeometry = mergedInstancedGeometry(model, /^mesh/); + if (mergedGeometry) { + return + + + ; + } return (props: Omit) => { const { nodes, materials } = model; + const mergedGeometry = mergedInstancedGeometry(model, /^mesh/); + if (mergedGeometry) { + return + + + ; + } return ; +}; + +const geometryCache = new WeakMap(); + +export const mergedInstancedGeometry = ( + model: InstancedModel, + keyPattern: RegExp, +) => { + const cached = geometryCache.get(model); + if (cached) { return cached; } + const matrix = new THREE.Matrix4(); + const geometries: THREE.BufferGeometry[] = []; + Object.entries(model.nodes) + .filter(([key, node]) => keyPattern.test(key) && node.instanceMatrix) + .forEach(([, node]) => { + const instanceMatrix = node.instanceMatrix; + if (!instanceMatrix) { return; } + for (let i = 0; i < instanceMatrix.count; i++) { + matrix.fromArray(instanceMatrix.array, i * instanceMatrix.itemSize); + const geometry = node.geometry.clone(); + geometry.applyMatrix4(matrix); + geometries.push(geometry); + } + }); + if (geometries.length == 0) { return undefined; } + const merged = mergeGeometries(geometries, false); + geometries.forEach(geometry => geometry.dispose()); + if (!merged) { return undefined; } + geometryCache.set(model, merged); + return merged; +}; From 93f6202219f7e93f6e974256b64e5814bba482a2 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Tue, 28 Apr 2026 14:27:58 -0700 Subject: [PATCH 39/83] Track model resources in 3D perf benchmark --- scripts/perf/stress_1000_3d.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js index 6e44de53bd..f422d26cd9 100644 --- a/scripts/perf/stress_1000_3d.js +++ b/scripts/perf/stress_1000_3d.js @@ -50,6 +50,9 @@ const summary = runs => { jsEncodedBytes: metric("jsEncodedBytes"), jsTransferBytes: metric("jsTransferBytes"), jsResourceCount: metric("jsResourceCount"), + modelEncodedBytes: metric("modelEncodedBytes"), + modelTransferBytes: metric("modelTransferBytes"), + modelResourceCount: metric("modelResourceCount"), threeDGardenMapRenders: metric("threeDGardenMapRenders"), gardenModelRenders: metric("gardenModelRenders"), threeDGardenRenders: metric("threeDGardenRenders"), @@ -100,8 +103,22 @@ const nextPaint = page => const resourceSummary = async page => page.evaluate(() => { const jsResources = performance.getEntriesByType("resource") .filter(entry => entry.name.match(/\.js(\?|$)/)); + const modelResources = performance.getEntriesByType("resource") + .filter(entry => entry.name.match(/\.(glb|gltf)(\?|$)/)); const sum = key => jsResources .reduce((total, entry) => total + (entry[key] || 0), 0); + const modelSum = key => modelResources + .reduce((total, entry) => total + (entry[key] || 0), 0); + const largestModels = modelResources + .map(entry => ({ + name: entry.name.split("/").pop(), + transferSize: entry.transferSize || 0, + encodedBodySize: entry.encodedBodySize || 0, + decodedBodySize: entry.decodedBodySize || 0, + duration: entry.duration || 0, + })) + .sort((a, b) => b.encodedBodySize - a.encodedBodySize) + .slice(0, 10); const largestJs = jsResources .map(entry => ({ name: entry.name.split("/").pop(), @@ -118,6 +135,11 @@ const resourceSummary = async page => page.evaluate(() => { jsEncodedBytes: sum("encodedBodySize"), jsDecodedBytes: sum("decodedBodySize"), largestJs, + modelResourceCount: modelResources.length, + modelTransferBytes: modelSum("transferSize"), + modelEncodedBytes: modelSum("encodedBodySize"), + modelDecodedBytes: modelSum("decodedBodySize"), + largestModels, }; }); From c512cf2ed7663bae976d237b8fdb9a3946b19ea7 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Tue, 28 Apr 2026 14:39:05 -0700 Subject: [PATCH 40/83] Memoize generated 3D bot geometry --- frontend/three_d_garden/bot/bot.tsx | 17 +-- .../bot/components/cable_carriers.tsx | 112 +++++++++--------- 2 files changed, 66 insertions(+), 63 deletions(-) diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index 59cb391e07..f2a9204ed8 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -4,7 +4,9 @@ import * as THREE from "three"; import { Cylinder, Extrude, Trail, Tube, useGLTF, useTexture, } from "@react-three/drei"; -import { DoubleSide, Shape, RepeatWrapping } from "three"; +import { + DoubleSide, ExtrudeGeometryOptions, Shape, RepeatWrapping, +} from "three"; import { easyCubicBezierCurve3, get3DPositionNoMirrorFunc, zDir as zDirFunc, @@ -190,7 +192,7 @@ export const Bot = (props: FarmbotModelProps) => { return texture; }, [aluminumTextureBase]); - const yBeltPath = () => { + const yBeltPath = React.useCallback(() => { const radius = 12; const path = new Shape(); path.moveTo(0, 0); @@ -210,7 +212,11 @@ export const Bot = (props: FarmbotModelProps) => { path.arc(-2, -radius, radius, Math.PI / 2, Math.PI); path.lineTo(-2, 0); return path; - }; + }, [botSizeY, y]); + const yBeltArgs = React.useMemo(() => [ + yBeltPath(), + { steps: 1, depth: 6, bevelEnabled: false }, + ] as [Shape, ExtrudeGeometryOptions], [yBeltPath]); const distanceToSoil = -props.getZ(x, y) - zDir * z; const defaultTrailWidth = config.perspective ? 500 : 0.1; @@ -618,10 +624,7 @@ export const Bot = (props: FarmbotModelProps) => {
{ x: botSizeX / 2, y: (tracks ? 0 : extrusionWidth) - 15 - bedYOffset, }); + const args = React.useMemo(() => [ + ccPath( + botSizeX / 2, botSizeX / 2 - x + 20, + bedCCSupportHeight - 40, + true), + { steps: 1, depth: 22, bevelEnabled: false }, + ] as [Shape, THREE.ExtrudeGeometryOptions], [bedCCSupportHeight, botSizeX, x]); return { const position = get3DPosition({ x: x - 28, y: 20 }); return [position.x, position.y, columnLength + 150]; }; + const args = React.useMemo(() => [ + ccPath(botSizeY, y + 40, 70), + { steps: 1, depth: ccDepth(kitVersion), bevelEnabled: false }, + ] as [Shape, THREE.ExtrudeGeometryOptions], [botSizeY, kitVersion, y]); return @@ -140,12 +142,13 @@ export const CableCarrierZ = (props: CableCarrierZProps) => { const zDir = zDirFunc(props.config); const get3DPosition = get3DPositionNoMirrorFunc(props.config); const position = get3DPosition({ x: x - 41, y: y - 25 }); + const args = React.useMemo(() => [ + ccPath(botSizeZ + zGantryOffset - 100, zDir * z + zGantryOffset - 15, 87), + { steps: 1, depth: 60, bevelEnabled: false }, + ] as [Shape, THREE.ExtrudeGeometryOptions], [botSizeZ, z, zDir, zGantryOffset]); return range((zAxisLength - 350) / 200), [zAxisLength]); const verticalRef = React.useRef(undefined); + const verticalGeometry = React.useMemo(() => { + const shape = new THREE.Shape(); + shape.moveTo(0, 0); + shape.lineTo(0, 20); + shape.lineTo(15, 20); + shape.lineTo(20, 1.5); + shape.lineTo(28.5, 1.5); + shape.lineTo(28.5, -61); + shape.lineTo(24, -63); + shape.lineTo(24, -61.5); + shape.lineTo(27, -60); + shape.lineTo(27, 0); + shape.lineTo(0, 0); + return new THREE.ExtrudeGeometry(shape, { + depth: zAxisLength - 350, + bevelEnabled: false, + }); + }, [zAxisLength]); + React.useEffect(() => () => verticalGeometry.dispose(), [verticalGeometry]); React.useEffect(() => { if (!verticalRef.current || verticalInstances.length === 0) { return; } const temp = new THREE.Object3D(); @@ -222,27 +244,7 @@ export const CableCarrierSupportVertical = { - const shape = new THREE.Shape(); - shape.moveTo(0, 0); - shape.lineTo(0, 20); - shape.lineTo(15, 20); - shape.lineTo(20, 1.5); - shape.lineTo(28.5, 1.5); - shape.lineTo(28.5, -61); - shape.lineTo(24, -63); - shape.lineTo(24, -61.5); - shape.lineTo(27, -60); - shape.lineTo(27, 0); - shape.lineTo(0, 0); - return shape; - })(), - { - depth: zAxisLength - 350, - bevelEnabled: false, - }, - )}> + geometry={verticalGeometry}> @@ -268,6 +270,23 @@ export const CableCarrierSupportHorizontal = const horizontalInstances = React.useMemo(() => range((botSizeY - 10) / 300), [botSizeY]); const horizontalRef = React.useRef(undefined); + const horizontalGeometry = React.useMemo(() => { + const shape = new THREE.Shape(); + shape.moveTo(0, 0); + shape.lineTo(0, 20); + shape.lineTo(-40, 20); + shape.lineTo(-41, 22.5); + shape.lineTo(-42.5, 22.5); + shape.lineTo(-41.5, 18.5); + shape.lineTo(-30, 18.5); + shape.lineTo(-25, 0); + shape.lineTo(0, 0); + return new THREE.ExtrudeGeometry(shape, { + depth: botSizeY - 30, + bevelEnabled: false, + }); + }, [botSizeY]); + React.useEffect(() => () => horizontalGeometry.dispose(), [horizontalGeometry]); React.useEffect(() => { if (!horizontalRef.current || horizontalInstances.length === 0) { return; } const temp = new THREE.Object3D(); @@ -313,26 +332,7 @@ export const CableCarrierSupportHorizontal = columnLength + 60, ]} rotation={[Math.PI / 2, 0, 0]} - geometry={new THREE.ExtrudeGeometry( - (() => { - const shape = new THREE.Shape(); - - shape.moveTo(0, 0); - shape.lineTo(0, 20); - shape.lineTo(-40, 20); - shape.lineTo(-41, 22.5); - shape.lineTo(-42.5, 22.5); - shape.lineTo(-41.5, 18.5); - shape.lineTo(-30, 18.5); - shape.lineTo(-25, 0); - shape.lineTo(0, 0); - return shape; - })(), - { - depth: botSizeY - 30, - bevelEnabled: false, - }, - )}> + geometry={horizontalGeometry}> Date: Tue, 28 Apr 2026 14:58:09 -0700 Subject: [PATCH 41/83] Cache shared 3D texture variants --- .../__tests__/texture_variants_test.ts | 69 +++++++++++++++++++ frontend/three_d_garden/bed/bed.tsx | 29 ++++---- .../bed/objects/utilities_post.tsx | 16 ++--- frontend/three_d_garden/bot/bot.tsx | 16 ++--- frontend/three_d_garden/bot/power_supply.tsx | 16 ++--- frontend/three_d_garden/garden/ground.tsx | 42 +++++------ frontend/three_d_garden/scenes/greenhouse.tsx | 16 ++--- frontend/three_d_garden/scenes/lab.tsx | 16 ++--- frontend/three_d_garden/scenes/props/desk.tsx | 27 +++----- frontend/three_d_garden/texture_variants.ts | 51 ++++++++++++++ 10 files changed, 195 insertions(+), 103 deletions(-) create mode 100644 frontend/three_d_garden/__tests__/texture_variants_test.ts create mode 100644 frontend/three_d_garden/texture_variants.ts diff --git a/frontend/three_d_garden/__tests__/texture_variants_test.ts b/frontend/three_d_garden/__tests__/texture_variants_test.ts new file mode 100644 index 0000000000..56813922d5 --- /dev/null +++ b/frontend/three_d_garden/__tests__/texture_variants_test.ts @@ -0,0 +1,69 @@ +import { RepeatWrapping, Texture } from "three"; +import { renderHook } from "@testing-library/react"; +import { useTexture } from "@react-three/drei"; +import { + getTextureVariant, textureVariantKey, useTextureVariant, +} from "../texture_variants"; + +describe("texture variants", () => { + it("builds stable keys", () => { + expect(textureVariantKey({ + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.02, 0.05], + offset: [0.25, 0.5], + rotation: Math.PI / 2, + })).toEqual("1000|1000|0.02,0.05|0.25,0.5|1.5707963267948966"); + }); + + it("reuses identical variants for the same base texture", () => { + const baseTexture = new Texture(); + const options = { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.02, 0.05] as [number, number], + }; + + const first = getTextureVariant(baseTexture, options); + const second = getTextureVariant(baseTexture, options); + + expect(first).toBe(second); + expect(first).not.toBe(baseTexture); + expect(first.wrapS).toEqual(RepeatWrapping); + expect(first.wrapT).toEqual(RepeatWrapping); + expect(first.repeat.x).toEqual(0.02); + expect(first.repeat.y).toEqual(0.05); + }); + + it("keeps different variant options isolated", () => { + const baseTexture = new Texture(); + + const first = getTextureVariant(baseTexture, { + wrapS: RepeatWrapping, + repeat: [0.02, 0.05], + }); + const second = getTextureVariant(baseTexture, { + wrapS: RepeatWrapping, + repeat: [0.3, 0.3], + }); + + expect(first).not.toBe(second); + expect(first.repeat.x).toEqual(0.02); + expect(second.repeat.x).toEqual(0.3); + }); + + it("loads a base texture and returns a cached variant", () => { + (useTexture as unknown as jest.Mock).mockClear(); + + const { result } = renderHook(() => + useTextureVariant("/3D/textures/wood.avif", { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.02, 0.05], + })); + + expect(useTexture).toHaveBeenCalledWith("/3D/textures/wood.avif"); + expect(result.current.wrapS).toEqual(RepeatWrapping); + expect(result.current.wrapT).toEqual(RepeatWrapping); + }); +}); diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index e30086a8bf..4f1eda4664 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -1,6 +1,6 @@ import React from "react"; import { - Box, Detailed, Extrude, Plane, useHelper, useTexture, + Box, Detailed, Extrude, Plane, useHelper, } from "@react-three/drei"; import { DoubleSide, @@ -47,6 +47,7 @@ import { MoistureSurface } from "../garden/moisture_texture"; import { HeightMaterial } from "../garden/height_material"; import { soilSurfaceExtents } from "../triangles"; import { FocusVisibilityGroup } from "../focus_transition"; +import { useTextureVariant } from "../texture_variants"; const soil = ( Type: typeof LinePath | typeof Shape, @@ -206,22 +207,16 @@ export const Bed = (props: BedProps) => { ]; const casterHeight = legSize * 1.375; - const bedWoodTextureBase = useTexture(ASSETS.textures.wood + "?=bedWood"); - const bedWoodTexture = React.useMemo(() => { - const texture = bedWoodTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.0003, 0.003); - return texture; - }, [bedWoodTextureBase]); - const legWoodTextureBase = useTexture(ASSETS.textures.wood + "?=legWood"); - const legWoodTexture = React.useMemo(() => { - const texture = legWoodTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.02, 0.05); - return texture; - }, [legWoodTextureBase]); + const bedWoodTexture = useTextureVariant(ASSETS.textures.wood, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.0003, 0.003], + }); + const legWoodTexture = useTextureVariant(ASSETS.textures.wood, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.02, 0.05], + }); // eslint-disable-next-line no-null/no-null const pointerPlantRef: PointerPlantRef = React.useRef(null); diff --git a/frontend/three_d_garden/bed/objects/utilities_post.tsx b/frontend/three_d_garden/bed/objects/utilities_post.tsx index 328717e51b..f4da9248b3 100644 --- a/frontend/three_d_garden/bed/objects/utilities_post.tsx +++ b/frontend/three_d_garden/bed/objects/utilities_post.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, Cylinder, RoundedBox, Tube, useTexture } from "@react-three/drei"; +import { Box, Cylinder, RoundedBox, Tube } from "@react-three/drei"; import { RepeatWrapping } from "three"; import { ASSETS } from "../../constants"; import { Config } from "../../config"; @@ -10,6 +10,7 @@ import { outletDepth } from "../../bot"; import * as THREE from "three"; import { Group, MeshPhongMaterial } from "../../components"; import { FocusVisibilityGroup } from "../../focus_transition"; +import { useTextureVariant } from "../../texture_variants"; export interface UtilitiesPostProps { config: Config; @@ -42,14 +43,11 @@ export const UtilitiesPost = (props: UtilitiesPostProps) => { new THREE.Vector3(barbX, barbY, barbZ), ); - const postWoodTextureBase = useTexture(ASSETS.textures.wood + "?=post"); - const postWoodTexture = React.useMemo(() => { - const texture = postWoodTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.02, 0.05); - return texture; - }, [postWoodTextureBase]); + const postWoodTexture = useTextureVariant(ASSETS.textures.wood, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.02, 0.05], + }); return { }); } }); - const aluminumTextureBase = useTexture(ASSETS.textures.aluminum + "?=bot"); - const aluminumTexture = React.useMemo(() => { - const texture = aluminumTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.01, 0.0003); - return texture; - }, [aluminumTextureBase]); + const aluminumTexture = useTextureVariant(ASSETS.textures.aluminum, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.01, 0.0003], + }); const yBeltPath = React.useCallback(() => { const radius = 12; diff --git a/frontend/three_d_garden/bot/power_supply.tsx b/frontend/three_d_garden/bot/power_supply.tsx index 9813f4809c..855af14bec 100644 --- a/frontend/three_d_garden/bot/power_supply.tsx +++ b/frontend/three_d_garden/bot/power_supply.tsx @@ -2,11 +2,12 @@ import React from "react"; import { RepeatWrapping } from "three"; import * as THREE from "three"; -import { Box, Tube, useTexture } from "@react-three/drei"; +import { Box, Tube } from "@react-three/drei"; import { ASSETS } from "../constants"; import { threeSpace, easyCubicBezierCurve3 } from "../helpers"; import { Config } from "../config"; import { Group, MeshPhongMaterial } from "../components"; +import { useTextureVariant } from "../texture_variants"; export interface PowerSupplyProps { config: Config; @@ -30,14 +31,11 @@ export const PowerSupply = (props: PowerSupplyProps) => { } = props.config; const zGround = -bedHeight - bedZOffset; - const powerSupplyTextureBase = useTexture(ASSETS.textures.aluminum + "?=powerSupply"); - const powerSupplyTexture = React.useMemo(() => { - const texture = powerSupplyTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.01, 0.003); - return texture; - }, [powerSupplyTextureBase]); + const powerSupplyTexture = useTextureVariant(ASSETS.textures.aluminum, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.01, 0.003], + }); const combinedCablePath = new THREE.CurvePath(); diff --git a/frontend/three_d_garden/garden/ground.tsx b/frontend/three_d_garden/garden/ground.tsx index 4050b93408..8dceff03d8 100644 --- a/frontend/three_d_garden/garden/ground.tsx +++ b/frontend/three_d_garden/garden/ground.tsx @@ -1,9 +1,10 @@ import React from "react"; import { Config, detailLevels } from "../config"; -import { Detailed, useTexture } from "@react-three/drei"; +import { Detailed } from "@react-three/drei"; import { Mesh, MeshPhongMaterial } from "../components"; import { ASSETS, BigDistance } from "../constants"; import { CircleGeometry, Float32BufferAttribute, RepeatWrapping } from "three"; +import { useTextureVariant } from "../texture_variants"; export interface GroundProps { config: Config; @@ -44,30 +45,21 @@ export const Ground = (props: GroundProps) => { const { config } = props; const groundZ = config.bedZOffset + config.bedHeight; - const grassTextureBase = useTexture(ASSETS.textures.grass + "?=grass"); - const grassTexture = React.useMemo(() => { - const texture = grassTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(24, 24); - return texture; - }, [grassTextureBase]); - const labFloorTextureBase = useTexture(ASSETS.textures.concrete + "?=labFloor"); - const labFloorTexture = React.useMemo(() => { - const texture = labFloorTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(16, 24); - return texture; - }, [labFloorTextureBase]); - const brickTextureBase = useTexture(ASSETS.textures.bricks + "?=bricks"); - const brickTexture = React.useMemo(() => { - const texture = brickTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(30, 30); - return texture; - }, [brickTextureBase]); + const grassTexture = useTextureVariant(ASSETS.textures.grass, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [24, 24], + }); + const labFloorTexture = useTextureVariant(ASSETS.textures.concrete, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [16, 24], + }); + const brickTexture = useTextureVariant(ASSETS.textures.bricks, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [30, 30], + }); const getGroundProperties = (sceneName: string) => { switch (sceneName) { diff --git a/frontend/three_d_garden/scenes/greenhouse.tsx b/frontend/three_d_garden/scenes/greenhouse.tsx index 586dd3f467..32e342eca5 100644 --- a/frontend/three_d_garden/scenes/greenhouse.tsx +++ b/frontend/three_d_garden/scenes/greenhouse.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, useTexture } from "@react-three/drei"; +import { Box } from "@react-three/drei"; import { DoubleSide, RepeatWrapping } from "three"; import { ASSETS } from "../constants"; import { threeSpace } from "../helpers"; @@ -8,6 +8,7 @@ import { Group, MeshPhongMaterial } from "../components"; import { StarterTrays, PottedPlant, GreenhouseWall, People } from "./props"; import { PopInGroup } from "../progressive_load"; import { FocusVisibilityGroup } from "../focus_transition"; +import { useTextureVariant } from "../texture_variants"; export interface GreenhouseProps { config: Config; @@ -25,14 +26,11 @@ export const Greenhouse = (props: GreenhouseProps) => { const { config } = props; const groundZ = -config.bedZOffset - config.bedHeight; - const shelfWoodTextureBase = useTexture(ASSETS.textures.wood + "?=shelf"); - const shelfWoodTexture = React.useMemo(() => { - const texture = shelfWoodTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.3, 0.3); - return texture; - }, [shelfWoodTextureBase]); + const shelfWoodTexture = useTextureVariant(ASSETS.textures.wood, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.3, 0.3], + }); return { const { config } = props; const groundZ = -config.bedZOffset - config.bedHeight; - const shelfWoodTextureBase = useTexture(ASSETS.textures.wood + "?=shelf"); - const shelfWoodTexture = React.useMemo(() => { - const texture = shelfWoodTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.3, 0.3); - return texture; - }, [shelfWoodTextureBase]); + const shelfWoodTexture = useTextureVariant(ASSETS.textures.wood, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.3, 0.3], + }); return { const { config } = props; const zGround = -config.bedZOffset - config.bedHeight; - const deskWoodTextureBase = useTexture(ASSETS.textures.wood + "?=desk"); - const deskWoodTexture = React.useMemo(() => { - const texture = deskWoodTextureBase.clone(); - texture.wrapS = RepeatWrapping; - texture.wrapT = RepeatWrapping; - texture.repeat.set(0.3, 0.3); - return texture; - }, [deskWoodTextureBase]); - const screenTextureBase = useTexture(ASSETS.textures.screen + "?=screen"); - const screenTexture = React.useMemo(() => { - const texture = screenTextureBase.clone(); - texture.rotation = Math.PI / 2; - texture.wrapT = RepeatWrapping; - return texture; - }, [screenTextureBase]); + const deskWoodTexture = useTextureVariant(ASSETS.textures.wood, { + wrapS: RepeatWrapping, + wrapT: RepeatWrapping, + repeat: [0.3, 0.3], + }); + const screenTexture = useTextureVariant(ASSETS.textures.screen, { + wrapT: RepeatWrapping, + rotation: Math.PI / 2, + }); return >(); + +export const textureVariantKey = (options: TextureVariantOptions): string => + [ + options.wrapS ?? "", + options.wrapT ?? "", + options.repeat?.join(",") ?? "", + options.offset?.join(",") ?? "", + options.rotation ?? "", + ].join("|"); + +export const getTextureVariant = ( + baseTexture: Texture, + options: TextureVariantOptions, +): Texture => { + const key = textureVariantKey(options); + const variants = textureVariantCache.get(baseTexture) || new Map(); + const existing = variants.get(key); + if (existing) { return existing; } + + const texture = baseTexture.clone(); + if (options.wrapS != undefined) { texture.wrapS = options.wrapS; } + if (options.wrapT != undefined) { texture.wrapT = options.wrapT; } + if (options.repeat) { texture.repeat.set(...options.repeat); } + if (options.offset) { texture.offset.set(...options.offset); } + if (options.rotation != undefined) { texture.rotation = options.rotation; } + texture.needsUpdate = true; + + variants.set(key, texture); + textureVariantCache.set(baseTexture, variants); + return texture; +}; + +export const useTextureVariant = ( + url: string, + options: TextureVariantOptions, +): Texture => { + const baseTexture = useTexture(url); + return getTextureVariant(baseTexture, options); +}; From 9144f9bc6e90967fd5b27bbd3dc45f8c28fdd8d8 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 29 Apr 2026 12:52:19 -0700 Subject: [PATCH 42/83] fix test output noise --- frontend/__tests__/hotkeys_test.tsx | 7 +++++-- frontend/promo/promo.tsx | 6 +++--- frontend/three_d_garden/__tests__/garden_model_test.tsx | 1 + frontend/three_d_garden/__tests__/index_test.tsx | 1 + frontend/three_d_garden/__tests__/visualization_test.tsx | 1 + 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/__tests__/hotkeys_test.tsx b/frontend/__tests__/hotkeys_test.tsx index 449c8fdd9c..8f82ae435f 100644 --- a/frontend/__tests__/hotkeys_test.tsx +++ b/frontend/__tests__/hotkeys_test.tsx @@ -3,6 +3,7 @@ const mockState = fakeState(); import React from "react"; import { render } from "@testing-library/react"; +import { HotkeysProvider } from "@blueprintjs/core"; import { HotKey, HotKeys, HotKeysProps, hotkeysWithActions, HotkeysWithActionsProps, toggleHotkeyHelpOverlay, @@ -128,16 +129,18 @@ describe("", () => { dispatch: jest.fn(), designer: fakeDesignerState(), }); + const renderHotKeys = (props: HotKeysProps) => + render(); it("renders", () => { location.pathname = Path.mock(Path.designer("nope")); - const { container } = render(); + const { container } = renderHotKeys(fakeProps()); expect(container.querySelectorAll("div").length).toEqual(1); }); it("renders default", () => { location.pathname = Path.mock(Path.designer()); - const { container } = render(); + const { container } = renderHotKeys(fakeProps()); expect(container.querySelectorAll("div").length).toEqual(1); }); }); diff --git a/frontend/promo/promo.tsx b/frontend/promo/promo.tsx index 4a3e2510c9..e8fae71a0d 100644 --- a/frontend/promo/promo.tsx +++ b/frontend/promo/promo.tsx @@ -219,10 +219,10 @@ export const Promo = () => { loadComplete={threeDLoaded} startTimeRef={startTimeRef} /> {!config.config && - setConfig({ ...config, config: true })} />} + setConfig({ ...config, config: true })} />} {config.config && - } + } diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 71722b77de..0142abd6fa 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -35,6 +35,7 @@ const mountedWrappers: ReturnType[] = []; describe("", () => { beforeEach(() => { + console.log = jest.fn(); mockIsDesktop = false; mockIsMobile = false; useStateSpy = jest.spyOn(React, "useState") diff --git a/frontend/three_d_garden/__tests__/index_test.tsx b/frontend/three_d_garden/__tests__/index_test.tsx index bc4472cca9..66da3f09c1 100644 --- a/frontend/three_d_garden/__tests__/index_test.tsx +++ b/frontend/three_d_garden/__tests__/index_test.tsx @@ -15,6 +15,7 @@ import { BooleanSetting } from "../../session_keys"; import { fakeDevice } from "../../__test_support__/resource_index_builder"; beforeEach(() => { + console.log = jest.fn(); window.localStorage.clear(); delete window.__fbPerf; jest.spyOn(configStorageActions, "getWebAppConfigValue") diff --git a/frontend/three_d_garden/__tests__/visualization_test.tsx b/frontend/three_d_garden/__tests__/visualization_test.tsx index 2b880c4645..9c6f04f523 100644 --- a/frontend/three_d_garden/__tests__/visualization_test.tsx +++ b/frontend/three_d_garden/__tests__/visualization_test.tsx @@ -19,6 +19,7 @@ let originalGetState: typeof store.getState; describe("", () => { beforeEach(() => { + console.log = jest.fn(); originalGetState = store.getState; (store as unknown as { getState: () => { resources: typeof mockResources } }) .getState = () => ({ resources: mockResources }); From c8fe91700f039665fe73f24ee8626009f06833cd Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 29 Apr 2026 12:53:35 -0700 Subject: [PATCH 43/83] fix blinky tests --- frontend/promo/__tests__/promo_test.tsx | 38 +++++++++++++++---- frontend/resources/__tests__/reducer_test.ts | 13 ++++++- frontend/resources/reducer.ts | 14 +++---- .../__tests__/config_overlays_test.tsx | 28 ++++++++++---- 4 files changed, 70 insertions(+), 23 deletions(-) diff --git a/frontend/promo/__tests__/promo_test.tsx b/frontend/promo/__tests__/promo_test.tsx index 30e9c01fb7..c97eb34c1a 100644 --- a/frontend/promo/__tests__/promo_test.tsx +++ b/frontend/promo/__tests__/promo_test.tsx @@ -1,11 +1,24 @@ +jest.mock("../../three_d_garden/zoom_beacons_constants", () => ({ + ...jest.requireActual("../../three_d_garden/zoom_beacons_constants"), + getFocusFromUrlParams: jest.fn(), +})); + import React from "react"; -import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { + act, render, screen, fireEvent, waitFor, +} from "@testing-library/react"; import { getPromoPlantCapacities, getSeasonTimings, Promo } from "../promo"; import * as reactThreeFiber from "@react-three/fiber"; import * as gardenModelModule from "../../three_d_garden/garden_model"; +import { + getFocusFromUrlParams, +} from "../../three_d_garden/zoom_beacons_constants"; import { INITIAL, PRESETS } from "../../three_d_garden/config"; import { calculatePlantPositions } from "../plants"; +const getFocusFromUrlParamsMock = + getFocusFromUrlParams as jest.Mock; + describe("", () => { const originalSearch = window.location.search; const originalConsoleError = console.error; @@ -22,6 +35,7 @@ describe("", () => {
{config.promoSpread ? "spread" : "garden-model"}
); pushStateSpy = jest.spyOn(history, "pushState") .mockImplementation(jest.fn()); + getFocusFromUrlParamsMock.mockReturnValue(""); }); afterEach(() => { @@ -31,6 +45,7 @@ describe("", () => { canvasSpy.mockRestore(); gardenModelSpy.mockRestore(); pushStateSpy.mockRestore(); + getFocusFromUrlParamsMock.mockReset(); }); it("renders", () => { @@ -89,13 +104,22 @@ describe("", () => { }); it("clears active focus on Escape", async () => { - window.location.search = "?focus=What%20you%20can%20grow"; + getFocusFromUrlParamsMock.mockReturnValue("What you can grow"); + const addEventListenerSpy = jest.spyOn(window, "addEventListener"); const { unmount } = render(); - fireEvent.keyDown(window, { key: "Escape" }); - await waitFor(() => expect(pushStateSpy).toHaveBeenCalled()); - const nextUrl = pushStateSpy.mock.calls[0][2] as string; - expect(nextUrl).not.toContain("focus="); - unmount(); + try { + await waitFor(() => expect(addEventListenerSpy) + .toHaveBeenCalledWith("keydown", expect.any(Function))); + const onKeyDown = addEventListenerSpy.mock.calls + .find(([eventName]) => eventName == "keydown")?.[1] as EventListener; + act(() => onKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))); + expect(pushStateSpy).toHaveBeenCalled(); + const nextUrl = pushStateSpy.mock.calls[0][2] as string; + expect(nextUrl).not.toContain("focus="); + } finally { + unmount(); + addEventListenerSpy.mockRestore(); + } }); }); diff --git a/frontend/resources/__tests__/reducer_test.ts b/frontend/resources/__tests__/reducer_test.ts index bb02d457e0..fe9ff64c85 100644 --- a/frontend/resources/__tests__/reducer_test.ts +++ b/frontend/resources/__tests__/reducer_test.ts @@ -12,7 +12,7 @@ import { buildResourceIndex, fakeDevice } from "../../__test_support__/resource_ import { GeneralizedError } from "../actions"; import { Actions } from "../../constants"; import { fakeResource } from "../../__test_support__/fake_resource"; -import { resourceReducer } from "../reducer"; +import { emptyState, resourceReducer } from "../reducer"; import { findByUuid } from "../reducer_support"; import { EditResourceParams } from "../../api/interfaces"; import { @@ -37,6 +37,17 @@ describe("resource reducer", () => { expect(result).toBe(state); }); + it("returns fresh consumer state", () => { + const firstState = emptyState(); + const secondState = emptyState(); + + firstState.consumers.sequences.stepIndex = 1; + + expect(secondState.consumers.sequences.stepIndex).toBeUndefined(); + expect(secondState.consumers.sequences).not.toBe( + firstState.consumers.sequences); + }); + it("updates parent state only when child reducers change", () => { const state = fakeState().resources; const result = resourceReducer(state, { diff --git a/frontend/resources/reducer.ts b/frontend/resources/reducer.ts index 414064f692..f92a941f27 100644 --- a/frontend/resources/reducer.ts +++ b/frontend/resources/reducer.ts @@ -36,13 +36,13 @@ import { FolderUnion } from "../folders/interfaces"; export const emptyState = (): RestResources => { return { consumers: { - sequences: sequenceState, - regimens: regimenState, - farm_designer: designerState, - photos: photosState, - farmware: farmwareState, - help: helpState, - alerts: alertState + sequences: defensiveClone(sequenceState), + regimens: defensiveClone(regimenState), + farm_designer: defensiveClone(designerState), + photos: defensiveClone(photosState), + farmware: defensiveClone(farmwareState), + help: defensiveClone(helpState), + alerts: defensiveClone(alertState) }, loaded: [], index: { diff --git a/frontend/three_d_garden/__tests__/config_overlays_test.tsx b/frontend/three_d_garden/__tests__/config_overlays_test.tsx index be2aae28e0..f64d1d92bb 100644 --- a/frontend/three_d_garden/__tests__/config_overlays_test.tsx +++ b/frontend/three_d_garden/__tests__/config_overlays_test.tsx @@ -1,5 +1,7 @@ import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { + act, render, screen, fireEvent, waitFor, +} from "@testing-library/react"; import { PublicOverlay, OverlayProps, PrivateOverlay, maybeAddParam, } from "../config_overlays"; @@ -192,14 +194,24 @@ describe("", () => { }); }); - it("closes the config menu with Escape", () => { + it("closes the config menu with Escape", async () => { const p = fakeProps(); - render(); - fireEvent.keyDown(window, { key: "Escape" }); - expect(p.setConfig).toHaveBeenCalledWith({ - ...p.config, - config: false, - }); + const addEventListenerSpy = jest.spyOn(window, "addEventListener"); + const { unmount } = render(); + try { + await waitFor(() => expect(addEventListenerSpy) + .toHaveBeenCalledWith("keydown", expect.any(Function))); + const onKeyDown = addEventListenerSpy.mock.calls + .find(([eventName]) => eventName == "keydown")?.[1] as EventListener; + act(() => onKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))); + expect(p.setConfig).toHaveBeenCalledWith({ + ...p.config, + config: false, + }); + } finally { + unmount(); + addEventListenerSpy.mockRestore(); + } }); it("removes url param", () => { From 517a423de560de797ed15576f6ea55896212920a Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 29 Apr 2026 12:56:06 -0700 Subject: [PATCH 44/83] fix add step button pointer issue --- frontend/css/panels/sequences.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/css/panels/sequences.scss b/frontend/css/panels/sequences.scss index 12b7be3c08..ef40e27a9d 100644 --- a/frontend/css/panels/sequences.scss +++ b/frontend/css/panels/sequences.scss @@ -1094,6 +1094,7 @@ } .add-command-button-container { + position: relative; .bp6-collapse { transition-duration: 0ms !important; } From a8c7cc88bd8d87b6a48771d0ba53cd06f80849d2 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 29 Apr 2026 12:57:31 -0700 Subject: [PATCH 45/83] handle bad demo lua msg type input --- frontend/demo/lua_runner/__tests__/actions_test.ts | 14 +++++++++++++- frontend/demo/lua_runner/actions.ts | 11 ++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/frontend/demo/lua_runner/__tests__/actions_test.ts b/frontend/demo/lua_runner/__tests__/actions_test.ts index f1626b66c9..95f20a7be8 100644 --- a/frontend/demo/lua_runner/__tests__/actions_test.ts +++ b/frontend/demo/lua_runner/__tests__/actions_test.ts @@ -10,7 +10,7 @@ let mockResources = buildResourceIndex([]); let mockLocked = false; import { TOAST_OPTIONS } from "../../../toast/constants"; -import { info } from "../../../toast/toast"; +import { error, info } from "../../../toast/toast"; import { store } from "../../../redux/store"; import { eStop, expandActions, runActions, setCurrent } from "../actions"; import * as lodash from "lodash"; @@ -55,6 +55,18 @@ describe("runActions()", () => { expect(info).toHaveBeenCalledWith("Hello, world!", TOAST_OPTIONS().info); }); + it("shows an error for invalid message types", () => { + jest.useFakeTimers(); + runActions( + [ + { type: "send_message", args: ["nope", "Hello, world!", "toast", "{}"] }, + ], + ); + jest.runAllTimers(); + expect(error).toHaveBeenCalledWith("Invalid message type: nope"); + expect(info).not.toHaveBeenCalled(); + }); + it("runs actions: missing", () => { jest.useFakeTimers(); runActions( diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index 684b80fcd0..f9c4305276 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -1,11 +1,10 @@ import { ALLOWED_CHANNEL_NAMES, - ALLOWED_MESSAGE_TYPES, MoveBodyItem, ParameterApplication, PercentageProgress, } from "farmbot"; -import { info } from "../../toast/toast"; +import { error, info } from "../../toast/toast"; import { store } from "../../redux/store"; import { Actions } from "../../constants"; import { TOAST_OPTIONS } from "../../toast/constants"; @@ -20,6 +19,7 @@ import { Point } from "farmbot/dist/resources/api_resources"; import { calculateMove } from "./calculate_move"; import { t } from "../../i18next_wrapper"; import { API } from "../../api"; +import { isMessageType } from "../../sequences/interfaces"; const almostEqual = (a: XyzNumber, b: XyzNumber) => { const epsilon = 0.01; @@ -306,6 +306,11 @@ export const runActions = ( return undefined; case "send_message": const type = "" + action.args[0]; + if (!isMessageType(type)) { + return () => { + error(`Invalid message type: ${type}`); + }; + } const msg = "" + action.args[1]; const channelsStr = "" + action.args[2]; const channels = channelsStr.split(",") as ALLOWED_CHANNEL_NAMES[]; @@ -317,7 +322,7 @@ export const runActions = ( } const initAction = crud.init("Log", { message: msg, - type: type as ALLOWED_MESSAGE_TYPES, + type: type, ...logPosition, channels, verbosity, From cb731b20f8da0dd884e12f66983d272b44488438 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 29 Apr 2026 12:58:10 -0700 Subject: [PATCH 46/83] add context to lua code generation --- app/controllers/api/ais_controller.rb | 40 ++++++++++++------- spec/controllers/api/ai/ai_controller_spec.rb | 8 ++++ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/app/controllers/api/ais_controller.rb b/app/controllers/api/ais_controller.rb index d44288a0aa..6f6188d1b7 100644 --- a/app/controllers/api/ais_controller.rb +++ b/app/controllers/api/ais_controller.rb @@ -240,18 +240,18 @@ def named_resources "nothing for now", ] + DEV_DOCS_API_REF_URL = "https://raw.githubusercontent.com/FarmBot-Docs/farmbot-developers/refs/heads/main/v15/docs/web-app/api-docs.md" + SRC_CODE_BOT_STATE_URL = "https://raw.githubusercontent.com/FarmBot/farmbot-js/refs/heads/main/src/interfaces.ts" + def page_url(page_name) "https://raw.githubusercontent.com/FarmBot-Docs/farmbot-developers/main/" \ "v15/lua/functions/#{page_name}.md" end - def get_page_data(page_name) - url = page_url(page_name) - begin - URI.parse(url).open.read - rescue SocketError => exception - puts "AI Lua docs fetch error: #{exception.message}" unless Rails.env.test? - end + def get_page_data(url) + URI.parse(url).open.read + rescue SocketError => exception + puts "AI Lua docs fetch error: #{exception.message}" unless Rails.env.test? end def shorten_docs(docs) @@ -275,18 +275,30 @@ def shorten_docs(docs) keep.join("\n") end + def process_dev_docs_page_data(url) + page_data = get_page_data(url) + if page_data.nil? + return "" + end + + content = page_data.split("---").slice(2, page_data.length) + if content.nil? + return "" + end + + content.join("---") + end + def get_docs function_docs = "" PAGE_NAMES.each do |page_name| - page_data = get_page_data(page_name) - if page_data.nil? - return "" - end - - page_content = page_data.split("---").slice(2, page_data.length).join("---") - function_docs += page_content + url = page_url(page_name) + function_docs += process_dev_docs_page_data(url) end short_docs = shorten_docs(function_docs) + short_docs += get_page_data(SRC_CODE_BOT_STATE_URL) || "" + short_docs += process_dev_docs_page_data(DEV_DOCS_API_REF_URL) + puts "AI Lua docs fetched: #{short_docs.length} characters" unless Rails.env.test? short_docs end diff --git a/spec/controllers/api/ai/ai_controller_spec.rb b/spec/controllers/api/ai/ai_controller_spec.rb index 3eaebaec00..8b0e81be3d 100644 --- a/spec/controllers/api/ai/ai_controller_spec.rb +++ b/spec/controllers/api/ai/ai_controller_spec.rb @@ -121,6 +121,14 @@ def chunk(content, done=nil) expect(response.body).to eq("red") end + it "handles docs without front matter" do + allow(controller).to receive(:get_page_data).and_return("content") + + result = controller.send(:process_dev_docs_page_data, "url") + + expect(result).to eq("") + end + it "throttles requests" do sign_in user payload = { From 87caaa0eb9dd76fb7ea2c7f0fe1f2193e6f10cef Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 29 Apr 2026 12:59:50 -0700 Subject: [PATCH 47/83] add read_status to lua runner --- frontend/constants.ts | 1 + .../demo/lua_runner/__tests__/index_test.ts | 16 ++++++++++++++- .../demo/lua_runner/__tests__/stubs_test.ts | 19 ++++++++++++++++++ frontend/demo/lua_runner/run.ts | 8 ++++++++ frontend/demo/lua_runner/stubs.ts | 5 +++++ frontend/devices/__tests__/reducer_test.ts | 20 +++++++++++++++++++ frontend/devices/reducer.ts | 15 ++++++++++++++ frontend/nav/__tests__/index_test.tsx | 20 +++++++++++++++++++ frontend/nav/index.tsx | 10 ++++++++++ 9 files changed, 113 insertions(+), 1 deletion(-) diff --git a/frontend/constants.ts b/frontend/constants.ts index c4b9f8c3e8..9b32a1cbcb 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -2511,6 +2511,7 @@ export enum Actions { DEMO_WRITE_PIN = "DEMO_WRITE_PIN", DEMO_SET_POSITION = "DEMO_SET_POSITION", DEMO_SET_JOB_PROGRESS = "DEMO_SET_JOB_PROGRESS", + DEMO_SET_STATE = "DEMO_SET_STATE", DEMO_SET_ESTOP = "DEMO_SET_ESTOP", DEMO_SET_MOUNTED_TOOL_ID = "DEMO_SET_MOUNTED_TOOL_ID", DEMO_SET_QUEUE_LENGTH = "DEMO_SET_QUEUE_LENGTH", diff --git a/frontend/demo/lua_runner/__tests__/index_test.ts b/frontend/demo/lua_runner/__tests__/index_test.ts index 9e4d8b6b0e..962cff7bda 100644 --- a/frontend/demo/lua_runner/__tests__/index_test.ts +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -588,6 +588,7 @@ describe("collectDemoSequenceActions()", () => { beforeEach(() => { localStorage.setItem("myBotIs", "online"); setCurrent({ x: 0, y: 0, z: 0 }); + console.log = jest.fn(); runLuaSpy = jest.spyOn(runModule, "runLua") .mockImplementation((_depth, lua) => { if (lua.includes("\"x\"") || lua.includes("'x'")) { @@ -1254,6 +1255,19 @@ describe("runDemoLuaCode()", () => { expect(console.log).toHaveBeenCalledWith("undefined"); }); + it("runs read_status", () => { + mockLocked = false; + runDemoLuaCode(` + local status = read_status() + print(status.informational_settings.locked) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("false"); + }); + it("runs find_home: all", () => { setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("find_home(\"all\")"); @@ -1844,7 +1858,7 @@ describe("csToLua()", () => { * [ ] new_sensor_reading * [ y ] photo_grid * [ y ] read_pin - * [ ] read_status + * [ y ] read_status * [ y ] rpc * [ y ] sequence * [ y ] send_message diff --git a/frontend/demo/lua_runner/__tests__/stubs_test.ts b/frontend/demo/lua_runner/__tests__/stubs_test.ts index ec1044558f..f52c887389 100644 --- a/frontend/demo/lua_runner/__tests__/stubs_test.ts +++ b/frontend/demo/lua_runner/__tests__/stubs_test.ts @@ -11,9 +11,11 @@ let mockFbosConfig: TaggedFbosConfig | undefined = fakeFbosConfig(); import { getDefaultAxisOrder, + getDeviceStatus, getGardenSize, getSafeZ, } from "../stubs"; +import { store } from "../../../redux/store"; import * as getters from "../../../resources/getters"; let getFirmwareConfigSpy: jest.SpyInstance; @@ -93,3 +95,20 @@ describe("getDefaultAxisOrder()", () => { ]); }); }); + +describe("getDeviceStatus()", () => { + it("gets device status", () => { + const originalGetState = store.getState; + const hardware = { + informational_settings: { locked: true }, + jobs: { "Test job": { percent: 50, status: "Working" } }, + }; + (store as unknown as { getState: Function }).getState = () => ({ + bot: { hardware }, + }); + + expect(getDeviceStatus()).toEqual(hardware); + + (store as unknown as { getState: Function }).getState = originalGetState; + }); +}); diff --git a/frontend/demo/lua_runner/run.ts b/frontend/demo/lua_runner/run.ts index d2d43eba1e..8d6e7d39fb 100644 --- a/frontend/demo/lua_runner/run.ts +++ b/frontend/demo/lua_runner/run.ts @@ -23,6 +23,7 @@ import { import { getFirmwareSettings, getGardenSize, getSafeZ, getSoilHeight, getGroupPoints, getJob, + getDeviceStatus, } from "./stubs"; import { error } from "../../toast/toast"; import { collectDemoSequenceActions } from "./index"; @@ -566,6 +567,13 @@ export const runLua = }); lua.lua_setfield(L, envIndex, to_luastring("garden_size")); + lua.lua_pushjsfunction(L, () => { + const status = getDeviceStatus(); + jsToLua(L, status); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("read_status")); + lauxlib.luaL_loadstring(L, to_luastring(LUA_HELPERS)); lua.lua_pushvalue(L, -2); lua.lua_setupvalue(L, -2, 1); diff --git a/frontend/demo/lua_runner/stubs.ts b/frontend/demo/lua_runner/stubs.ts index b99d1e314e..346d40c9f7 100644 --- a/frontend/demo/lua_runner/stubs.ts +++ b/frontend/demo/lua_runner/stubs.ts @@ -93,3 +93,8 @@ export const getJob = (jobName: string): JobProgress | undefined => { ? value : undefined)[0]; }; + +export const getDeviceStatus = () => { + const status = store.getState().bot.hardware; + return status; +}; diff --git a/frontend/devices/__tests__/reducer_test.ts b/frontend/devices/__tests__/reducer_test.ts index dd90e4c391..b2b31bd0ed 100644 --- a/frontend/devices/__tests__/reducer_test.ts +++ b/frontend/devices/__tests__/reducer_test.ts @@ -229,6 +229,26 @@ describe("botReducer", () => { expect(r.hardware.location_data.position).toEqual({ x: 1, y: 2, z: 3 }); }); + it("sets demo state", () => { + const state = initialState(); + const action = { type: Actions.DEMO_SET_STATE, payload: undefined }; + const r = botReducer(state, action); + expect(r.hardware.informational_settings).toEqual(expect.objectContaining({ + uptime: 12345, + sync_status: "synced", + wifi_level_percent: 98, + wifi_level: -48, + video_devices: "1,0", + throttled: "0x0", + soc_temp: 50, + scheduler_usage: 1, + memory_usage: 50, + disk_usage: 20, + cpu_usage: 1, + firmware_version: "6.6.26.K", + })); + }); + it("set job progress", () => { const state = initialState(); const action = { diff --git a/frontend/devices/reducer.ts b/frontend/devices/reducer.ts index 5e44cd4c56..794230c402 100644 --- a/frontend/devices/reducer.ts +++ b/frontend/devices/reducer.ts @@ -155,6 +155,21 @@ export const botReducer = generateReducer(initialState()) }); return s; }) + .add(Actions.DEMO_SET_STATE, (s) => { + s.hardware.informational_settings.uptime = 12345; + s.hardware.informational_settings.sync_status = "synced"; + s.hardware.informational_settings.wifi_level_percent = 98; + s.hardware.informational_settings.wifi_level = -48; + s.hardware.informational_settings.video_devices = "1,0"; + s.hardware.informational_settings.throttled = "0x0"; + s.hardware.informational_settings.soc_temp = 50; + s.hardware.informational_settings.scheduler_usage = 1; + s.hardware.informational_settings.memory_usage = 50; + s.hardware.informational_settings.disk_usage = 20; + s.hardware.informational_settings.cpu_usage = 1; + s.hardware.informational_settings.firmware_version = "6.6.26.K"; + return s; + }) .add<[string, PercentageProgress]>(Actions.DEMO_SET_JOB_PROGRESS, (s, { payload }) => { s.hardware.jobs[payload[0]] = payload[1]; diff --git a/frontend/nav/__tests__/index_test.tsx b/frontend/nav/__tests__/index_test.tsx index 2226fcaaf1..1976f38180 100644 --- a/frontend/nav/__tests__/index_test.tsx +++ b/frontend/nav/__tests__/index_test.tsx @@ -136,6 +136,26 @@ describe("", () => { expect(text).toContain("using a demo account"); }); + it("sets demo state when forced online", () => { + forceOnlineSpy.mockImplementation(() => true); + const p = fakeProps(); + renderNavBar(p); + expect(p.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_STATE, + payload: undefined, + }); + }); + + it("doesn't set demo state when not forced online", () => { + forceOnlineSpy.mockImplementation(() => false); + const p = fakeProps(); + renderNavBar(p); + expect(p.dispatch).not.toHaveBeenCalledWith({ + type: Actions.DEMO_SET_STATE, + payload: undefined, + }); + }); + it("displays links", () => { const { container } = renderNavBar(); const text = container.textContent || ""; diff --git a/frontend/nav/index.tsx b/frontend/nav/index.tsx index 5c82167b45..c0fabb23a0 100644 --- a/frontend/nav/index.tsx +++ b/frontend/nav/index.tsx @@ -191,6 +191,16 @@ const mapConnectionStatusStateToProps = }; const ConnectionStatus = (props: ConnectionStatusProps) => { + React.useEffect(() => { + if (forceOnline()) { + props.dispatch({ + type: Actions.DEMO_SET_STATE, + payload: undefined, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const data = connectivityData({ bot: props.bot, device: props.device, From 0c156f849788619fb3a4fe00701c8dac864cf2ca Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 29 Apr 2026 13:33:31 -0700 Subject: [PATCH 48/83] fix mock leak issue --- frontend/promo/__tests__/promo_test.tsx | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/frontend/promo/__tests__/promo_test.tsx b/frontend/promo/__tests__/promo_test.tsx index c97eb34c1a..b7ebe553d1 100644 --- a/frontend/promo/__tests__/promo_test.tsx +++ b/frontend/promo/__tests__/promo_test.tsx @@ -1,8 +1,3 @@ -jest.mock("../../three_d_garden/zoom_beacons_constants", () => ({ - ...jest.requireActual("../../three_d_garden/zoom_beacons_constants"), - getFocusFromUrlParams: jest.fn(), -})); - import React from "react"; import { act, render, screen, fireEvent, waitFor, @@ -10,21 +5,18 @@ import { import { getPromoPlantCapacities, getSeasonTimings, Promo } from "../promo"; import * as reactThreeFiber from "@react-three/fiber"; import * as gardenModelModule from "../../three_d_garden/garden_model"; -import { - getFocusFromUrlParams, -} from "../../three_d_garden/zoom_beacons_constants"; +import * as zoomBeaconConstants from + "../../three_d_garden/zoom_beacons_constants"; import { INITIAL, PRESETS } from "../../three_d_garden/config"; import { calculatePlantPositions } from "../plants"; -const getFocusFromUrlParamsMock = - getFocusFromUrlParams as jest.Mock; - describe("", () => { const originalSearch = window.location.search; const originalConsoleError = console.error; let canvasSpy: jest.SpyInstance; let gardenModelSpy: jest.SpyInstance; let pushStateSpy: jest.SpyInstance; + let focusFromUrlParamsSpy: jest.SpyInstance; beforeEach(() => { canvasSpy = jest.spyOn(reactThreeFiber, "Canvas") @@ -35,7 +27,9 @@ describe("", () => {
{config.promoSpread ? "spread" : "garden-model"}
); pushStateSpy = jest.spyOn(history, "pushState") .mockImplementation(jest.fn()); - getFocusFromUrlParamsMock.mockReturnValue(""); + focusFromUrlParamsSpy = jest + .spyOn(zoomBeaconConstants, "getFocusFromUrlParams") + .mockReturnValue(""); }); afterEach(() => { @@ -45,7 +39,7 @@ describe("", () => { canvasSpy.mockRestore(); gardenModelSpy.mockRestore(); pushStateSpy.mockRestore(); - getFocusFromUrlParamsMock.mockReset(); + focusFromUrlParamsSpy.mockRestore(); }); it("renders", () => { @@ -104,7 +98,7 @@ describe("", () => { }); it("clears active focus on Escape", async () => { - getFocusFromUrlParamsMock.mockReturnValue("What you can grow"); + focusFromUrlParamsSpy.mockReturnValue("What you can grow"); const addEventListenerSpy = jest.spyOn(window, "addEventListener"); const { unmount } = render(); try { From 5902f3f3c496741333d1f4b8df5efd1cb01351ee Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 29 Apr 2026 13:41:26 -0700 Subject: [PATCH 49/83] clean up test url assignment --- frontend/promo/__tests__/promo_test.tsx | 2 +- .../__tests__/zoom_beacons_constants_test.tsx | 4 +-- .../three_d_garden/bed/__tests__/bed_test.tsx | 30 +++++++++---------- frontend/tools/__tests__/index_test.tsx | 4 +-- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/frontend/promo/__tests__/promo_test.tsx b/frontend/promo/__tests__/promo_test.tsx index b7ebe553d1..4da989e1ed 100644 --- a/frontend/promo/__tests__/promo_test.tsx +++ b/frontend/promo/__tests__/promo_test.tsx @@ -33,7 +33,7 @@ describe("", () => { }); afterEach(() => { - history.replaceState(undefined, "", `/${originalSearch}`); + window.location.search = originalSearch; jest.useRealTimers(); console.error = originalConsoleError; canvasSpy.mockRestore(); diff --git a/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx b/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx index f84a679b3f..6f7d0e610d 100644 --- a/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx +++ b/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx @@ -110,14 +110,14 @@ describe("setUrlParam()", () => { }); it("sets URL param", () => { - history.replaceState(undefined, "", "/app/designer"); + window.location.search = ""; setUrlParam("focus", "What you can grow"); const url = getPushedUrl(); expect(url.searchParams.get("focus")).toEqual("What you can grow"); }); it("removes URL param", () => { - history.replaceState(undefined, "", "/app/designer?focus=What+you+can+grow"); + window.location.search = "?focus=What+you+can+grow"; setUrlParam("focus", ""); const url = getPushedUrl(); expect(url.searchParams.get("focus")).toBeNull(); diff --git a/frontend/three_d_garden/bed/__tests__/bed_test.tsx b/frontend/three_d_garden/bed/__tests__/bed_test.tsx index 119f5523c6..a0e8c5956b 100644 --- a/frontend/three_d_garden/bed/__tests__/bed_test.tsx +++ b/frontend/three_d_garden/bed/__tests__/bed_test.tsx @@ -132,7 +132,7 @@ describe("", () => { afterEach(() => { requestAnimationFrameSpy.mockRestore(); - history.replaceState(undefined, "", originalPathname); + location.pathname = originalPathname; }); const fakeProps = (): BedProps => ({ @@ -169,7 +169,7 @@ describe("", () => { ["renders", SpecialStatus.SAVED], ])("%s pointer point", (title, gridPointSpecialStatus) => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.points("add")); + location.pathname = Path.mock(Path.points("add")); mockIsMobile = false; const p = fakeProps(); p.addPlantProps = fakeAddPlantProps(); @@ -194,7 +194,7 @@ describe("", () => { it("adds a plant", () => { getModeSpy.mockReturnValue(Mode.clickToAdd); - history.replaceState(undefined, "", Path.cropSearch("mint")); + location.pathname = Path.mock(Path.cropSearch("mint")); const p = fakeProps(); p.addPlantProps = fakeAddPlantProps(); const { container } = render(); @@ -207,7 +207,7 @@ describe("", () => { it("doesn't add a drawn point", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.points("add")); + location.pathname = Path.mock(Path.points("add")); const p = fakeProps(); const addPlantProps = fakeAddPlantProps(); addPlantProps.designer.drawnPoint = undefined; @@ -220,7 +220,7 @@ describe("", () => { it("adds a drawn point: xy", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.points("add")); + location.pathname = Path.mock(Path.points("add")); mockPlantRef.current = { position: { set: mockSetPlantPosition } }; const p = fakeProps(); const addPlantProps = fakeAddPlantProps(); @@ -242,7 +242,7 @@ describe("", () => { it("adds a drawn point: radius", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.points("add")); + location.pathname = Path.mock(Path.points("add")); mockPlantRef.current = { position: { set: mockSetPlantPosition } }; const p = fakeProps(); const addPlantProps = fakeAddPlantProps(); @@ -264,7 +264,7 @@ describe("", () => { it("updates pointer plant position", () => { getModeSpy.mockReturnValue(Mode.clickToAdd); - history.replaceState(undefined, "", Path.mock(Path.cropSearch("mint"))); + location.pathname = Path.mock(Path.cropSearch("mint")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockXCrosshairRef.current = { position: { set: mockSetXCrosshairPosition } }; @@ -281,7 +281,7 @@ describe("", () => { it("handles missing ref", () => { getModeSpy.mockReturnValue(Mode.clickToAdd); - history.replaceState(undefined, "", Path.mock(Path.cropSearch("mint"))); + location.pathname = Path.mock(Path.cropSearch("mint")); mockIsMobile = false; mockPlantRef.current = undefined; mockXCrosshairRef.current = undefined; @@ -297,7 +297,7 @@ describe("", () => { it("handles missing crosshair refs", () => { getModeSpy.mockReturnValue(Mode.clickToAdd); - history.replaceState(undefined, "", Path.mock(Path.cropSearch("mint"))); + location.pathname = Path.mock(Path.cropSearch("mint")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockXCrosshairRef.current = undefined; @@ -314,7 +314,7 @@ describe("", () => { it("doesn't update pointer plant position: mobile", () => { getModeSpy.mockReturnValue(Mode.clickToAdd); - history.replaceState(undefined, "", Path.mock(Path.cropSearch("mint"))); + location.pathname = Path.mock(Path.cropSearch("mint")); mockIsMobile = true; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockXCrosshairRef.current = { position: { set: mockSetXCrosshairPosition } }; @@ -329,7 +329,7 @@ describe("", () => { it("doesn't update pointer point position", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.mock(Path.points("add"))); + location.pathname = Path.mock(Path.points("add")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockXCrosshairRef.current = { position: { set: mockSetXCrosshairPosition } }; @@ -345,7 +345,7 @@ describe("", () => { it("updates pointer point position", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.mock(Path.points("add"))); + location.pathname = Path.mock(Path.points("add")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockXCrosshairRef.current = { position: { set: mockSetXCrosshairPosition } }; @@ -367,7 +367,7 @@ describe("", () => { it("updates pointer point radius", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.mock(Path.points("add"))); + location.pathname = Path.mock(Path.points("add")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockRadiusRef.current = { scale: { set: mockSetRadiusScale } }; @@ -393,7 +393,7 @@ describe("", () => { it("doesn't update pointer point radius: no ref", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.mock(Path.points("add"))); + location.pathname = Path.mock(Path.points("add")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockRadiusRef.current = undefined; @@ -419,7 +419,7 @@ describe("", () => { it("doesn't update pointer point radius: already set", () => { getModeSpy.mockReturnValue(Mode.createPoint); - history.replaceState(undefined, "", Path.mock(Path.points("add"))); + location.pathname = Path.mock(Path.points("add")); mockIsMobile = false; mockPlantRef.current = { position: { set: mockSetPlantPosition } }; mockRadiusRef.current = { scale: { set: mockSetRadiusScale } }; diff --git a/frontend/tools/__tests__/index_test.tsx b/frontend/tools/__tests__/index_test.tsx index 8e23b00802..5833c1a01b 100644 --- a/frontend/tools/__tests__/index_test.tsx +++ b/frontend/tools/__tests__/index_test.tsx @@ -78,7 +78,7 @@ const findNodeByType = ( describe("", () => { afterEach(() => { - history.replaceState(undefined, "", Path.mock(originalPathname)); + location.pathname = originalPathname; jest.useRealTimers(); }); @@ -358,7 +358,7 @@ describe("", () => { beforeEach(() => { jest.clearAllMocks(); - history.replaceState(undefined, "", Path.mock(originalPathname)); + location.pathname = originalPathname; jest.useRealTimers(); jest.spyOn(crud, "edit").mockImplementation(jest.fn()); jest.spyOn(crud, "save").mockImplementation(jest.fn()); From 4c301223366bd085e2db2c021f6b96dd32d40d1e Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 29 Apr 2026 14:09:36 -0700 Subject: [PATCH 50/83] fix promo spread color --- frontend/three_d_garden/focus_transition.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/three_d_garden/focus_transition.tsx b/frontend/three_d_garden/focus_transition.tsx index eb3a8091f0..9504176d93 100644 --- a/frontend/three_d_garden/focus_transition.tsx +++ b/frontend/three_d_garden/focus_transition.tsx @@ -73,16 +73,22 @@ const materialState = (material: Material): MaterialState => ({ depthWrite: material.depthWrite, }); +const cloneMaterial = (material: Material) => { + const clone = material.clone(); + clone.onBeforeCompile = material.onBeforeCompile; + return clone; +}; + const cloneSlot = (slot: MaterialSlot): { clones: MaterialSlot; states: MaterialState[]; } | undefined => { if (Array.isArray(slot)) { - const clones = slot.map(material => material.clone()); + const clones = slot.map(cloneMaterial); return { clones, states: clones.map(materialState) }; } if (!isMaterial(slot)) { return undefined; } - const clone = slot.clone(); + const clone = cloneMaterial(slot); return { clones: clone, states: [materialState(clone)] }; }; From 35b37f93738a27703300177824c1213bb1a392e4 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 6 May 2026 18:01:43 -0700 Subject: [PATCH 51/83] switch ci to github actions --- .circleci/circle_envs => .ci_env | 0 .circleci/config.yml | 442 ---------------------------- .github/actions/setup-ci/action.yml | 56 ++++ .github/workflows/build.yml | 362 +++++++++++++++++++++++ example.env | 8 +- lib/tasks/coverage.rake | 6 +- scripts/fps.js | 43 ++- scripts/send-notification | 14 + 8 files changed, 475 insertions(+), 456 deletions(-) rename .circleci/circle_envs => .ci_env (100%) delete mode 100644 .circleci/config.yml create mode 100644 .github/actions/setup-ci/action.yml create mode 100644 .github/workflows/build.yml create mode 100755 scripts/send-notification diff --git a/.circleci/circle_envs b/.ci_env similarity index 100% rename from .circleci/circle_envs rename to .ci_env diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 96517414fa..0000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,442 +0,0 @@ -version: 2.1 - -executors: - build-executor: - machine: - image: ubuntu-2404:current - resource_class: xlarge - working_directory: /home/circleci/project - -commands: - start-commands: - steps: - - send-notification: - message: "CI run started" - - send-notification: - message: "<${CIRCLE_BUILD_URL}|logs>" - - run: - name: Create compare link - command: | - DEPLOYS_URL_API="https://api.github.com/repos/Farmbot/Farmbot-Web-App/deployments" - COMPARE_URL_WEB="https://github.com/Farmbot/Farmbot-Web-App/compare/" - data=$(curl -fsS "$DEPLOYS_URL_API") || data="[]" - last_sha=$(printf "%s" "$data" | python -c 'import json,sys; data=json.loads(sys.stdin.read() or "[]"); deploy_index=0; sha=(data[deploy_index] or {}).get("sha"); print(sha or "")') - compare_url="$COMPARE_URL_WEB$last_sha...${CIRCLE_SHA1}" - echo "$compare_url" - echo "export COMPARE_URL='$compare_url'" >> "$BASH_ENV" - - send-notification: - message: "<$COMPARE_URL|diff>" - send-notification: - parameters: - message: - type: string - when: - type: enum - enum: ["on_success", "on_fail", "always"] - default: "always" - steps: - - when: - condition: - equal: [staging, << pipeline.git.branch >>] - steps: - - run: - name: "Send notification: << parameters.message >>" - when: << parameters.when >> - command: | - if [ -n "$SLACK_WEBHOOK_URL" ]; then - curl -fsS -X POST -H "Content-Type: application/json" \ - --data "{\"text\":\"<< parameters.message >>\",\"channel\":\"#software\"}" \ - "$SLACK_WEBHOOK_URL" || true - fi - build-commands: - steps: - - run: - name: Build and Install Deps - command: | - mv .circleci/circle_envs .env - echo -e '\ndocker_volumes/db/pg_wal/*' >> .dockerignore - sudo docker compose build web - if sudo docker compose run --rm web bash -lc '\ - gem install bundler && \ - bundle install && \ - bun install \ - '; then - echo "export SETUP_OK=true" >> "$BASH_ENV" - else - echo "export SETUP_OK=false" >> "$BASH_ENV" - exit 1 - fi - - run: - name: Run setup tasks - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "export SETUP_OK=false" >> "$BASH_ENV" - exit 1 - fi - - if sudo docker compose run --rm web bash -lc '\ - bundle exec rails db:create && \ - bundle exec rails db:migrate && \ - bundle exec rake keys:generate \ - '; then - echo "export SETUP_OK=true" >> "$BASH_ENV" - else - echo "export SETUP_OK=false" >> "$BASH_ENV" - exit 1 - fi - rspec-commands: - steps: - - run: - name: Run Ruby Tests - command: | - sudo docker compose run --rm web bundle exec rspec spec --format progress --format RspecJunitFormatter --out test-results/rspec.xml - - run: - name: Check app coverage status - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose run --rm web bundle exec rake check_file_coverage:api || [ $CIRCLE_BRANCH == "staging" ] - when: always - - run: - name: Upload app coverage to Codecov - command: | - curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --keyring trustedkeys.gpg --import - curl -Os https://uploader.codecov.io/latest/linux/codecov - curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM - curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM.sig - gpgv codecov.SHA256SUM.sig codecov.SHA256SUM - shasum -a 256 -c codecov.SHA256SUM - chmod +x codecov - ./codecov -t $CODECOV_TOKEN -f coverage_api/coverage.xml - js-test-commands: - steps: - - run: - name: Run JS tests - when: always - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose run --rm web bun test --reporter=junit --reporter-outfile=test-results/junit.xml - echo 'export COVERAGE_AVAILABLE=true' >> $BASH_ENV - lint-commands: - steps: - - run: - name: Run Ruby Linters - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose run --rm web bun run ruby-check - when: always - - run: - name: Run JS Linters - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose run --rm web bun run linters - when: always - bun-build-commands: - steps: - - run: - name: Check bun build - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose run --rm web bundle exec rake assets:precompile 2>&1 | tee assets_precompile.log - printf -- '-%.0s' {1..50}; printf '\n' - if grep -Ei '^(warn|error):' assets_precompile.log; then - exit 1 - fi - when: always - coverage-commands: - steps: - - run: - name: Check frontend coverage status - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose run --rm -e CIRCLE_SHA1="$CIRCLE_SHA1" -e CIRCLE_BRANCH="$CIRCLE_BRANCH" -e CIRCLE_PULL_REQUEST="$CIRCLE_PULL_REQUEST" web bundle exec rake coverage:run || [ $CIRCLE_BRANCH == "staging" ] - when: always - - run: - name: Check frontend file coverage status - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - changed=$(git diff --name-only staging...HEAD | tr '\n' ',' | sed 's/,$//') || true - sudo docker compose run --rm -e CHANGED_FILES="$changed" web bundle exec rake check_file_coverage:fe || true - when: always - - run: - name: Report frontend coverage to Coveralls - command: | - if [ "$CIRCLE_BRANCH" == "staging" ]; then set +eo pipefail; fi - if [ "$COVERAGE_AVAILABLE" ] && [ "$COVERALLS_REPO_TOKEN" ] - then - curl -sLO https://github.com/coverallsapp/coverage-reporter/releases/latest/download/coveralls-linux.tar.gz - curl -sLO https://github.com/coverallsapp/coverage-reporter/releases/latest/download/coveralls-checksums.txt - cat coveralls-checksums.txt | grep coveralls-linux.tar.gz | sha256sum --check - tar -xzf coveralls-linux.tar.gz - ./coveralls report coverage_fe/lcov.info - fi - if [ "$CIRCLE_BRANCH" == "staging" ]; then echo; fi - when: always # change to `on_success` for a stricter comparison - - restore_cache: - keys: - - fe-coverage-staging- - - run: - name: Get current frontend coverage value - when: always - command: | - if [ -z "$COVERAGE_AVAILABLE" ]; then - echo "skipping" - exit 0 - fi - eval "$(awk -F: '/^LH:/ { covered += $2 } /^LF:/ { total += $2 } END { printf "COVERED=%d\nTOTAL=%d\n", covered, total }' coverage_fe/lcov.info)" - FE_VALUE=$(awk "BEGIN { printf \"%.2f\", ($COVERED / $TOTAL) * 100 }") - echo "Covered lines: $COVERED, Total lines: $TOTAL, Coverage: $FE_VALUE%" - echo "export COVERED=$COVERED" >> "$BASH_ENV" - echo "export TOTAL=$TOTAL" >> "$BASH_ENV" - echo "export FE_VALUE=$FE_VALUE" >> "$BASH_ENV" - - run: - name: Load previous frontend coverage value - when: always - command: | - if [ -f fe_coverage.csv ]; then - PREV_FE=$(python -c 'import csv; f=open("fe_coverage.csv", newline=""); r=csv.reader(f); next(r, None); rows=[float(row[0]) for row in r if row and row[0]]; print(f"{rows[-1]:.2f}" if rows else "98.7")') - else - PREV_FE=98.7 - fi - echo "Previous value: $PREV_FE%" - echo "export PREV_FE_VALUE=$PREV_FE" >> $BASH_ENV - - run: - name: Compare frontend coverage value - when: always - command: | - if [ -z "$COVERAGE_AVAILABLE" ]; then - echo "skipping" - exit 0 - fi - percent_change=$(python -c 'import os; fe=float(os.environ.get("FE_VALUE","0") or 0); prev=float(os.environ.get("PREV_FE_VALUE","0") or 0); print("n/a" if prev==0 else f"{((fe-prev)/prev)*100:.2f}")') - echo "$FE_VALUE% ($percent_change% change)" - echo "export PERCENT_FE_CHANGE=$percent_change" >> "$BASH_ENV" - - run: - name: Save new frontend coverage value - when: always - command: | - if [ -z "$COVERAGE_AVAILABLE" ]; then - echo "skipping" - exit 0 - fi - echo "$FE_VALUE" - if [ ! -f fe_coverage.csv ]; then - printf '%s\n' "percent,covered lines,total lines,percent change" > fe_coverage.csv - fi - printf '%s\n' "$FE_VALUE,$COVERED,$TOTAL,$PERCENT_FE_CHANGE" >> fe_coverage.csv - cat fe_coverage.csv - - save_cache: - key: fe-coverage-{{ .Branch }}-{{ epoch }} - paths: - - fe_coverage.csv - when: always - - store_artifacts: - path: fe_coverage.csv - destination: fe_coverage.csv - render-commands: - steps: - - run: - name: Start services - when: always - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose up -d - sudo docker compose ps - - run: - name: Install playwright - when: always - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - sudo docker compose exec -e PLAYWRIGHT_BROWSERS_PATH=0 web bunx playwright install chromium --with-deps - - run: - name: Wait for load - when: always - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - for i in $(seq 1 90); do - if curl -fsS "http://127.0.0.1:3000/promo" >/dev/null; then - echo "web is up" - exit 0 - fi - sleep 2 - done - - echo "timeout" - sudo docker compose logs --no-color --tail=300 web - exit 1 - - run: - name: Run playwright - when: always - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "skipping" - exit 0 - fi - attempts=2 - for _ in $(seq 1 "$attempts"); do - url="http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" - echo "Attempting FPS check via ${url}" - if ! sudo docker compose exec web curl -I "${url}" >/dev/null; then - continue - fi - - if ! fps_output=$(sudo docker compose exec -e PLAYWRIGHT_BROWSERS_PATH=0 web bun scripts/fps.js "${url}" "tmp/promo.png" "tmp/fps_plot.png"); then - echo "${fps_output}" - continue - fi - echo "${fps_output}" - - fps_value=$(echo "${fps_output}" | awk -F= '/^FPS_VALUE=/{print $2; exit}') - scene_metrics=$(echo "${fps_output}" | awk -F= '/^SCENE_METRICS=/{print $2; exit}') - echo "export FPS_VALUE=${fps_value}" >> "$BASH_ENV" - echo "export SCENE_METRICS=\"${scene_metrics}\"" >> "$BASH_ENV" - - exit 0 - done - - echo "FPS check failed for all URLs" - exit 1 - - restore_cache: - keys: - - fps_value-staging- - - run: - name: Load previous fps value - when: always - command: | - if [ -f fps_value.txt ]; then - PREV=$(cat fps_value.txt) - else - PREV=0.75 - fi - echo "Previous value: $PREV fps" - echo "export PREV_VALUE=$PREV" >> $BASH_ENV - - run: - name: Compare fps value - when: always - command: | - percent_change=$(python -c 'import os; fps=float(os.environ.get("FPS_VALUE","0") or 0); prev=float(os.environ.get("PREV_VALUE","0") or 0); print("n/a" if prev==0 else f"{((fps-prev)/prev)*100:.2f}")') - echo "$FPS_VALUE fps ($percent_change% change)" - echo "export PERCENT_CHANGE=$percent_change" >> "$BASH_ENV" - - run: - name: Save new fps value - command: | - echo "$FPS_VALUE" - echo "$FPS_VALUE" > fps_value.txt - if [ ! -f scene_metrics.csv ]; then - printf '%s\n' "epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes" > scene_metrics.csv - fi - printf '%s\n' "$SCENE_METRICS" >> scene_metrics.csv - cat scene_metrics.csv - - save_cache: - key: fps_value-{{ .Branch }}-{{ epoch }} - paths: - - fps_value.txt - - scene_metrics.csv - - send-notification: - message: "$FPS_VALUE fps (${PERCENT_CHANGE}% change)" - - run: - name: On failure - when: on_fail - command: | - if [ "$SETUP_OK" != "true" ]; then - echo "setup failed" - exit 0 - fi - sudo docker compose ps - sudo docker compose logs --no-color --tail=500 - - store_artifacts: - path: tmp/promo.png - destination: promo_xl.png - - store_artifacts: - path: tmp/fps_plot.png - destination: fps_plot.png - - store_artifacts: - path: scene_metrics.csv - destination: scene_metrics.csv - end-commands: - steps: - - run: - name: Fetch image artifact URL - command: | - project_slug="gh/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" - artifacts_json=$(curl -fsS \ - -H "Accept: application/json" \ - "https://circleci.com/api/v2/project/${project_slug}/${CIRCLE_BUILD_NUM}/artifacts") - mapfile -t artifact_urls < <(printf "%s" "$artifacts_json" | python -c 'import json,sys; print("\n".join(item.get("url", "") for item in (json.load(sys.stdin).get("items") or [])))') - url_for() { - for url in "${artifact_urls[@]}"; do [[ "$url" == *$1 ]] && { echo "$url"; return; }; done - } - screenshot_url="$(url_for "promo*.png")" - fps_plot_url="$(url_for "fps_plot.png")" - echo $screenshot_url - echo $fps_plot_url - echo "export SCREENSHOT_URL='$screenshot_url'" >> "$BASH_ENV" - echo "export FPS_PLOT_URL='$fps_plot_url'" >> "$BASH_ENV" - - send-notification: - message: "CI run succeeded" - when: on_success - - send-notification: - message: "<$SCREENSHOT_URL|screenshot>" - when: on_success - - send-notification: - message: "<$FPS_PLOT_URL|fps>" - when: on_success - - send-notification: - message: "CI run failed" - when: on_fail - - - -workflows: - version: 2 - build_and_test: - # max_auto_reruns: 1 - jobs: - - all - -jobs: - all: - executor: build-executor - steps: - - start-commands - - checkout - - run: mkdir -p test-results - - build-commands - - rspec-commands - - lint-commands - - bun-build-commands - - js-test-commands - - store_test_results: - path: test-results - - coverage-commands - - render-commands - - end-commands diff --git a/.github/actions/setup-ci/action.yml b/.github/actions/setup-ci/action.yml new file mode 100644 index 0000000000..d9034f81b1 --- /dev/null +++ b/.github/actions/setup-ci/action.yml @@ -0,0 +1,56 @@ +name: Set up CI +description: Prepare the FarmBot web Docker image and dependencies. +runs: + using: composite + steps: + - name: Set up environment file + shell: bash + run: mv .ci_env .env + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build web image + uses: docker/build-push-action@v7 + with: + context: . + file: docker_configs/api.Dockerfile + load: true + tags: farmbot_web:latest + cache-from: type=gha,scope=farmbot-web + cache-to: type=gha,mode=max,scope=farmbot-web + + - name: Cache bundle + uses: actions/cache@v5 + with: + path: docker_volumes/bundle_cache + key: bundle-${{ runner.os }}-${{ hashFiles('Gemfile.lock') }} + restore-keys: | + bundle-${{ runner.os }}- + + - name: Cache node modules + uses: actions/cache@v5 + with: + path: node_modules + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: | + bun-${{ runner.os }}- + + - name: Install deps + shell: bash + run: | + sudo docker compose run --rm web bash -lc ' + gem install bundler && + bundle install && + bun install + ' + + - name: Run setup tasks + shell: bash + run: | + sudo docker compose run --rm web bash -lc ' + bundle exec rails db:create && + bundle exec rails db:migrate && + bundle exec rake keys:generate + ' + mkdir -p test-results diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..8b23368c10 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,362 @@ +name: Test and Render + +on: + push: + pull_request: + +permissions: + contents: write + +env: + NOTIFY: ${{ github.ref_name == 'staging' }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 100 + + - name: Send notification + if: env.NOTIFY == 'true' + run: scripts/send-notification "CI run started" + + - name: Send notification + if: env.NOTIFY == 'true' + run: scripts/send-notification "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|logs>" + + - name: Create compare link + if: env.NOTIFY == 'true' + run: | + DEPLOYS_URL_API="https://api.github.com/repos/Farmbot/Farmbot-Web-App/deployments" + COMPARE_URL_WEB="https://github.com/Farmbot/Farmbot-Web-App/compare/" + data=$(curl -fsS "$DEPLOYS_URL_API") || data="[]" + last_sha=$(printf "%s" "$data" | python3 -c 'import json,sys; data=json.loads(sys.stdin.read() or "[]"); deploy_index=0; sha=(data[deploy_index] or {}).get("sha"); print(sha or "")') + compare_url="$COMPARE_URL_WEB$last_sha...${GITHUB_SHA}" + echo "$compare_url" + echo "COMPARE_URL=$compare_url" >> "$GITHUB_ENV" + + - name: Send notification + if: env.NOTIFY == 'true' + run: scripts/send-notification "<${COMPARE_URL}|diff>" + + - name: Set up CI + uses: ./.github/actions/setup-ci + + - name: Run Ruby linters + run: sudo docker compose run --rm web bun run ruby-check + + - name: Run Ruby tests + run: sudo docker compose run --rm web bundle exec rspec spec --format progress --format RspecJunitFormatter --out test-results/rspec.xml + + - name: Check app coverage status + if: always() + run: sudo docker compose run --rm web bundle exec rake check_file_coverage:api || [ "$NOTIFY" = "true" ] + + - name: Upload app coverage to Codecov + if: ${{ hashFiles('coverage_api/coverage.xml') != '' }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage_api/coverage.xml + fail_ci_if_error: false + + - name: Run JS linters + run: sudo docker compose run --rm web bun run linters + + - name: Check bun build + run: | + sudo docker compose run --rm web bundle exec rake assets:precompile 2>&1 | tee assets_precompile.log + printf -- '-%.0s' {1..50}; printf '\n' + if grep -Ei '^(warn|error):' assets_precompile.log; then + exit 1 + fi + + - name: Run JS tests + run: | + set +e + sudo docker compose run -T --rm web bun test --reporter=junit --reporter-outfile=test-results/junit.xml > test-results/js-test-output.log 2>&1 + status=$? + head -n 5 test-results/js-test-output.log + tail -n 5 test-results/js-test-output.log + if [ "$status" -eq 0 ]; then + echo "COVERAGE_AVAILABLE=true" >> "$GITHUB_ENV" + fi + exit $status + + - name: Check frontend coverage status + if: always() + env: + GITHUB_PULL_REQUEST: ${{ github.event.pull_request.html_url || '/0' }} + run: sudo docker compose run --rm -e GITHUB_SHA="$GITHUB_SHA" -e GITHUB_REF_NAME="$GITHUB_REF_NAME" -e GITHUB_PULL_REQUEST="$GITHUB_PULL_REQUEST" web bundle exec rake coverage:run || [ "$NOTIFY" = "true" ] + + - name: Check frontend file coverage status + if: always() + run: | + changed=$(git diff --name-only staging...HEAD | tr '\n' ',' | sed 's/,$//') || true + sudo docker compose run --rm -e CHANGED_FILES="$changed" web bundle exec rake check_file_coverage:fe || true + + - name: Report frontend coverage to Coveralls + if: env.COVERAGE_AVAILABLE == 'true' + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + file: coverage_fe/lcov.info + format: lcov + fail-on-error: false + + - name: Restore frontend coverage value + if: always() + uses: actions/cache/restore@v5 + with: + path: fe_coverage.csv + key: fe-coverage-${{ github.ref_name }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + fe-coverage-${{ github.ref_name }}- + + - name: Track frontend coverage value + if: always() + run: | + if [ "$COVERAGE_AVAILABLE" != "true" ]; then + echo "skipping" + exit 0 + fi + eval "$(awk -F: '/^LH:/ { covered += $2 } /^LF:/ { total += $2 } END { printf "COVERED=%d\nTOTAL=%d\n", covered, total }' coverage_fe/lcov.info)" + FE_VALUE=$(awk "BEGIN { printf \"%.2f\", ($COVERED / $TOTAL) * 100 }") + if [ -f fe_coverage.csv ]; then + PREV_FE=$(python3 -c 'import csv; f=open("fe_coverage.csv", newline=""); r=csv.reader(f); next(r, None); rows=[float(row[0]) for row in r if row and row[0]]; print(f"{rows[-1]:.2f}" if rows else "98.7")') + else + PREV_FE=98.7 + fi + percent_change=$(FE_VALUE="$FE_VALUE" PREV_FE_VALUE="$PREV_FE" python3 -c 'import os; fe=float(os.environ.get("FE_VALUE","0") or 0); prev=float(os.environ.get("PREV_FE_VALUE","0") or 0); print("n/a" if prev==0 else f"{((fe-prev)/prev)*100:.2f}")') + echo "Covered lines: $COVERED, Total lines: $TOTAL, Coverage: $FE_VALUE%" + echo "$FE_VALUE% ($percent_change% change)" + if [ ! -f fe_coverage.csv ]; then + printf '%s\n' "percent,covered lines,total lines,percent change" > fe_coverage.csv + fi + printf '%s\n' "$FE_VALUE,$COVERED,$TOTAL,$percent_change" >> fe_coverage.csv + cat fe_coverage.csv + + - name: Save frontend coverage value + if: ${{ always() && hashFiles('fe_coverage.csv') != '' }} + uses: actions/cache/save@v5 + with: + path: fe_coverage.csv + key: fe-coverage-${{ github.ref_name }}-${{ github.run_id }}-${{ github.run_attempt }} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results + path: test-results + if-no-files-found: warn + + - name: Upload coverage artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage + path: | + fe_coverage.csv + scene_metrics.csv + if-no-files-found: warn + + - name: On failure + if: failure() + run: | + sudo docker compose ps || true + sudo docker compose logs --no-color --tail=500 || true + + - name: Send notification + if: success() && env.NOTIFY == 'true' + run: scripts/send-notification "CI run succeeded" + + - name: Send notification + if: failure() && env.NOTIFY == 'true' + run: scripts/send-notification "CI run failed" + + render: + runs-on: gpu + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up CI + uses: ./.github/actions/setup-ci + + - name: Start services + run: | + sudo docker compose up -d + sudo docker compose ps + + - name: Install playwright + run: | + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="$HOME/.bun" + export PATH="$BUN_INSTALL/bin:$PATH" + export PLAYWRIGHT_BROWSERS_PATH=/tmp/playwright-browsers + echo "BUN_INSTALL=$BUN_INSTALL" >> "$GITHUB_ENV" + echo "$BUN_INSTALL/bin" >> "$GITHUB_PATH" + echo "PLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH" >> "$GITHUB_ENV" + bunx playwright install chromium --with-deps + + - name: Set up GPU + run: | + sudo modprobe nvidia || true + sudo modprobe nvidia_uvm || true + ls -la /dev/nvidia* || true + nvidia-smi + + - name: Wait for load + run: | + for i in $(seq 1 90); do + if curl -fsS "http://127.0.0.1:3000/promo" >/dev/null; then + echo "web is up" + exit 0 + fi + sleep 2 + done + echo "web did not become ready" + exit 1 + + - name: Run playwright + run: | + attempts=2 + for _ in $(seq 1 "$attempts"); do + url="http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" + echo "Attempting FPS check via ${url}" + if ! sudo docker compose exec -T web curl -I "${url}" >/dev/null; then + continue + fi + + if ! fps_output=$(bun scripts/fps.js "${url}" "/tmp/promo.png" "/tmp/fps_plot.png"); then + echo "${fps_output}" + continue + fi + echo "${fps_output}" + + fps_value=$(echo "${fps_output}" | awk -F= '/^FPS_VALUE=/{print $2; exit}') + scene_metrics=$(echo "${fps_output}" | awk -F= '/^SCENE_METRICS=/{print $2; exit}') + webgl_status=$(echo "${fps_output}" | awk -F= '/^WEBGL_STATUS=/{print $2; exit}') + webgl_vendor=$(echo "${fps_output}" | awk -F= '/^WEBGL_VENDOR=/{print $2; exit}') + webgl_renderer=$(echo "${fps_output}" | awk -F= '/^WEBGL_RENDERER=/{print $2; exit}') + echo "FPS_VALUE=${fps_value}" >> "$GITHUB_ENV" + echo "SCENE_METRICS=${scene_metrics}" >> "$GITHUB_ENV" + echo "WEBGL_STATUS=${webgl_status}" >> "$GITHUB_ENV" + echo "WEBGL_VENDOR=${webgl_vendor}" >> "$GITHUB_ENV" + echo "WEBGL_RENDERER=${webgl_renderer}" >> "$GITHUB_ENV" + + exit 0 + done + echo "FPS check failed" + exit 1 + + - name: Restore FPS value + uses: actions/cache/restore@v5 + with: + path: | + fps_value.txt + scene_metrics.csv + key: fps-value-${{ github.ref_name }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + fps-value-${{ github.ref_name }}- + + - name: Track FPS value + run: | + if [ -f fps_value.txt ]; then + PREV=$(cat fps_value.txt) + else + PREV=60 + fi + echo "Previous value: $PREV fps" + percent_change=$(FPS_VALUE="$FPS_VALUE" PREV_VALUE="$PREV" python3 -c 'import os; fps=float(os.environ.get("FPS_VALUE","0") or 0); prev=float(os.environ.get("PREV_VALUE","0") or 0); print("n/a" if prev==0 else f"{((fps-prev)/prev)*100:.2f}")') + echo "$FPS_VALUE fps ($percent_change% change)" + echo "PERCENT_CHANGE=$percent_change" >> "$GITHUB_ENV" + echo "$FPS_VALUE" > fps_value.txt + if [ ! -f scene_metrics.csv ]; then + printf '%s\n' "epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes" > scene_metrics.csv + fi + printf '%s\n' "$SCENE_METRICS" >> scene_metrics.csv + cat scene_metrics.csv + + - name: Save FPS value + if: ${{ hashFiles('fps_value.txt') != '' }} + uses: actions/cache/save@v5 + with: + path: | + fps_value.txt + scene_metrics.csv + key: fps-value-${{ github.ref_name }}-${{ github.run_id }}-${{ github.run_attempt }} + + - name: View values + run: | + echo $FPS_VALUE + echo $SCENE_METRICS + echo $WEBGL_STATUS + echo $WEBGL_VENDOR + echo $WEBGL_RENDERER + + - name: Send notification + if: env.NOTIFY == 'true' + run: scripts/send-notification "${FPS_VALUE} fps (${PERCENT_CHANGE}% change)" + + - name: Upload image artifacts + uses: actions/upload-artifact@v7 + with: + name: images + path: | + /tmp/promo.png + /tmp/fps_plot.png + if-no-files-found: warn + + - name: On failure + if: failure() + run: | + sudo docker compose ps || true + sudo docker compose logs --no-color --tail=500 || true + + - name: Publish image links + if: success() && env.NOTIFY == 'true' + env: + ARTIFACT_BRANCH: ci-artifacts + run: | + worktree=$(mktemp -d) + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git ls-remote --exit-code --heads origin "$ARTIFACT_BRANCH" >/dev/null; then + git fetch origin "$ARTIFACT_BRANCH" + git worktree add "$worktree" "origin/$ARTIFACT_BRANCH" + else + git worktree add --detach "$worktree" + git -C "$worktree" switch --orphan "$ARTIFACT_BRANCH" + fi + + mkdir -p "$worktree/build/latest" "$worktree/build/${GITHUB_RUN_ID}" + cp /tmp/promo.png "$worktree/build/latest/promo.png" + cp /tmp/fps_plot.png "$worktree/build/latest/fps_plot.png" + cp /tmp/promo.png "$worktree/build/${GITHUB_RUN_ID}/promo.png" + cp /tmp/fps_plot.png "$worktree/build/${GITHUB_RUN_ID}/fps_plot.png" + git -C "$worktree" add build + git -C "$worktree" commit -m "Update CI image artifacts for ${GITHUB_RUN_ID}" || true + git -C "$worktree" push origin "HEAD:$ARTIFACT_BRANCH" + + raw_base="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${ARTIFACT_BRANCH}/build/${GITHUB_RUN_ID}" + echo "SCREENSHOT_URL=${raw_base}/promo.png" >> "$GITHUB_ENV" + echo "FPS_PLOT_URL=${raw_base}/fps_plot.png" >> "$GITHUB_ENV" + + - name: Send notification + if: success() && env.NOTIFY == 'true' + run: | + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + for MESSAGE in \ + "<${SCREENSHOT_URL:-$RUN_URL}|screenshot>" \ + "<${FPS_PLOT_URL:-$RUN_URL}|fps>" + do + scripts/send-notification "$MESSAGE" + done diff --git a/example.env b/example.env index 995dca6818..fbe6c27733 100644 --- a/example.env +++ b/example.env @@ -132,10 +132,10 @@ ROLLBAR_ENV= # Do not add this key if you do not use DataDog on your server. DATADOG_CLIENT_TOKEN=?? -# Set by CircleCI, used by CI test coverage check -CIRCLE_SHA1= -CIRCLE_BRANCH= -CIRCLE_PULL_REQUEST= +# Set by GitHub Actions, used by CI test coverage check +GITHUB_SHA= +GITHUB_REF_NAME= +GITHUB_PULL_REQUEST= # Can be deleted unless you are using codecov. CODECOV_TOKEN= diff --git a/lib/tasks/coverage.rake b/lib/tasks/coverage.rake index 94e2820616..4ceb6a8441 100644 --- a/lib/tasks/coverage.rake +++ b/lib/tasks/coverage.rake @@ -8,10 +8,10 @@ REPO_URL = "https://api.github.com/repos/Farmbot/Farmbot-Web-App" LATEST_COV_URL = "https://coveralls.io/github/FarmBot/Farmbot-Web-App.json" COV_API_BUILDS_PER_PAGE = 5 COV_BUILDS_TO_FETCH = 20 -PULL_REQUEST = ENV.fetch("CIRCLE_PULL_REQUEST", "/0") -CURRENT_BRANCH = ENV.fetch("CIRCLE_BRANCH", "staging") # "staging" or "pull/11" +PULL_REQUEST = ENV.fetch("GITHUB_PULL_REQUEST", "/0") +CURRENT_BRANCH = ENV.fetch("GITHUB_REF_NAME", "staging") # "staging" or "pull/11" BASE_BRANCHES = ["main", "staging"] -CURRENT_COMMIT = ENV.fetch("CIRCLE_SHA1", "") +CURRENT_COMMIT = ENV.fetch("GITHUB_SHA", "") CSS_SELECTOR = ".fraction" FRACTION_DELIM = "/" REMOTE_COVERAGE_OVERRIDE = ENV.fetch('REMOTE_COVERAGE_OVERRIDE', '0').to_f diff --git a/scripts/fps.js b/scripts/fps.js index a8f3df744b..6ab0ea5958 100644 --- a/scripts/fps.js +++ b/scripts/fps.js @@ -10,10 +10,39 @@ const maxLoadingSamples = 240; const postLoadSamples = 10; const chosenPostLoadSample = 5; const sampleIntervalMs = 1000; +const ci = Boolean(process.env.CI); +const openWindow = Boolean(process.env.DISPLAY && process.env.OPEN_WINDOW); +const chromiumArgs = [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--ignore-gpu-blocklist', + '--enable-features=Vulkan', + '--use-gl=angle', + '--enable-gpu-rasterization', + '--enable-zero-copy', + ...((openWindow && !ci) ? [] : ['--use-angle=vulkan']), +]; const pageIsLoading = page => page.evaluate(() => Boolean(document.querySelector('.three-d-load-progress'))); +const webglRenderer = page => + page.evaluate(() => { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + if (!gl) { return { status: 'unavailable' }; } + + const debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); + if (!debugInfo) { return { status: 'available' }; } + + return { + status: 'available', + vendor: gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL), + renderer: gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL), + }; + }); + function printUsage() { console.log([ 'Usage: bun scripts/fps.js [screenshot_path] [fps_plot_path]', @@ -26,19 +55,19 @@ function printUsage() { } async function main() { + console.log(`Launching Chromium with args: ${chromiumArgs.join(' ')}`); const browser = await chromium.launch({ - headless: true, - args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--enable-gpu', - ], + headless: !openWindow, + args: chromiumArgs, }); const page = await browser.newPage(); page.setDefaultTimeout(120_000); try { await page.goto(url, { waitUntil: 'domcontentloaded' }); + const renderer = await webglRenderer(page); + console.log(`WEBGL_STATUS=${renderer.status}`); + if (renderer.vendor) { console.log(`WEBGL_VENDOR=${renderer.vendor}`); } + if (renderer.renderer) { console.log(`WEBGL_RENDERER=${renderer.renderer}`); } await page.waitForFunction(() => typeof window.__fps !== 'undefined'); let lastSample = 0; diff --git a/scripts/send-notification b/scripts/send-notification new file mode 100755 index 0000000000..26a8e719f7 --- /dev/null +++ b/scripts/send-notification @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +message="$*" +if [ -z "${SLACK_WEBHOOK_URL:-}" ]; then + exit 0 +fi + +MESSAGE="$message" python3 - <<'PY' | curl -fsS -X POST -H "Content-Type: application/json" --data @- "$SLACK_WEBHOOK_URL" || true +import json +import os + +print(json.dumps({"text": os.environ["MESSAGE"], "channel": "#software"})) +PY From be4ce51fc1d828a4a799ef722504492daa3c9898 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 7 May 2026 12:51:27 -0700 Subject: [PATCH 52/83] upgrade deps --- Gemfile.lock | 24 +++++++------- bun.lock | 88 +++++++++++++++++++++++++--------------------------- package.json | 30 +++++++++--------- 3 files changed, 69 insertions(+), 73 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b657a9cb39..01335685fc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -82,7 +82,7 @@ GEM uri (>= 0.13.1) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) - amq-protocol (2.7.0) + amq-protocol (2.8.0) ast (2.4.3) base64 (0.3.0) bcrypt (3.1.22) @@ -135,7 +135,7 @@ GEM e2mmap (0.1.0) erb (6.0.4) erubi (1.13.1) - factory_bot (6.5.6) + factory_bot (6.6.0) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) factory_bot (~> 6.5) @@ -160,9 +160,9 @@ GEM mini_mime (~> 1.1) representable (~> 3.0) retriable (~> 3.1) - google-apis-iamcredentials_v1 (0.26.0) + google-apis-iamcredentials_v1 (0.27.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.61.0) + google-apis-storage_v1 (0.62.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -200,7 +200,7 @@ GEM prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.19.4) + json (2.19.5) jsonapi-renderer (0.2.2) jwt (3.1.2) base64 @@ -236,10 +236,10 @@ GEM marcel (1.1.0) method_source (1.1.0) mini_mime (1.1.5) - minitest (6.0.5) + minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) - multi_json (1.20.1) + multi_json (1.21.1) mutations (0.9.2) activesupport mutex_m (0.3.0) @@ -255,14 +255,14 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.2-aarch64-linux-gnu) + nokogiri (1.19.3-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-gnu) + nokogiri (1.19.3-x86_64-linux-gnu) racc (~> 1.4) orm_adapter (0.5.0) os (1.1.4) ostruct (0.6.3) - parallel (2.0.1) + parallel (2.1.0) parser (3.3.11.1) ast (~> 2.4.1) racc @@ -359,7 +359,7 @@ GEM railties (>= 7.0) retriable (3.4.1) rexml (3.4.4) - rollbar (3.7.0) + rollbar (3.8.0) rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -435,7 +435,7 @@ GEM tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2026.1) + tzinfo-data (1.2026.2) tzinfo (>= 1.0.0) uber (0.1.0) unicode-display_width (3.2.0) diff --git a/bun.lock b/bun.lock index 21b258a6e8..1336c494a5 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "@monaco-editor/react": "4.7.0", "@react-spring/three": "10.0.3", "@react-three/drei": "10.7.7", - "@react-three/fiber": "9.6.0", + "@react-three/fiber": "9.6.1", "@rollbar/react": "1.0.0", "@types/bun": "1.3.13", "@types/lodash": "4.17.24", @@ -22,10 +22,10 @@ "@types/react-dom": "19.2.3", "@types/react-test-renderer": "19.1.0", "@types/redux-immutable-state-invariant": "2.1.4", - "@types/three": "0.184.0", + "@types/three": "0.184.1", "@types/ws": "8.18.1", "@xterm/xterm": "6.0.0", - "axios": "1.15.2", + "axios": "1.16.0", "bowser": "2.14.1", "browser-speech": "1.1.1", "delaunator": "5.1.0", @@ -33,7 +33,7 @@ "farmbot": "15.9.3", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.0.7", + "i18next": "26.0.10", "lodash": "4.18.1", "markdown-it": "14.1.1", "markdown-it-emoji": "3.0.0", @@ -44,11 +44,11 @@ "promise-timeout": "1.3.0", "punycode": "2.3.1", "querystring-es3": "0.2.1", - "react": "19.2.5", + "react": "19.2.6", "react-color": "2.19.3", - "react-dom": "19.2.5", + "react-dom": "19.2.6", "react-redux": "9.2.0", - "react-router": "7.14.2", + "react-router": "7.15.0", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", @@ -71,14 +71,14 @@ "@types/jest": "30.0.0", "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", - "@typescript-eslint/eslint-plugin": "8.59.0", - "@typescript-eslint/parser": "8.59.0", - "eslint": "10.2.1", + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "eslint": "10.3.0", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", "eslint-plugin-jest": "29.15.2", "eslint-plugin-no-null": "1.0.2", - "eslint-plugin-promise": "7.2.1", + "eslint-plugin-promise": "7.3.0", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", "happy-dom": "20.9.0", @@ -86,19 +86,19 @@ "jest-canvas-mock": "2.5.2", "jest-cli": "30.3.0", "jest-environment-jsdom": "30.3.0", - "jest-junit": "16.0.0", + "jest-junit": "17.0.0", "jest-skipped-reporter": "0.0.5", "jshint": "2.13.6", "madge": "8.0.0", "path-browserify": "1.0.1", "playwright": "1.59.1", - "postcss": "8.5.10", + "postcss": "8.5.14", "postcss-scss": "4.0.9", "raf": "3.4.1", - "react-test-renderer": "19.2.5", + "react-test-renderer": "19.2.6", "sass": "1.99.0", "sass-lint": "1.13.1", - "stylelint": "17.9.0", + "stylelint": "17.11.0", "stylelint-config-standard-scss": "17.0.0", "ts-jest": "29.4.9", "tslint": "6.1.3", @@ -394,7 +394,7 @@ "@react-three/eslint-plugin": ["@react-three/eslint-plugin@0.1.2", "", { "dependencies": { "@babel/runtime": "^7.17.8", "eslint": "^8.12.0" } }, "sha512-jenNIhvt+/1fb3NDr3M5vwF06U9euX6kI2SuAFltVKdQP2nzUPY+zdati2Rd67ewAKn0jfljQWT7DWIe6siChg=="], - "@react-three/fiber": ["@react-three/fiber@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA=="], + "@react-three/fiber": ["@react-three/fiber@9.6.1", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg=="], "@rollbar/react": ["@rollbar/react@1.0.0", "", { "dependencies": { "tiny-invariant": "^1.1.0" }, "peerDependencies": { "prop-types": "^15.7.2", "react": "16.x || 17.x || 18.x || 19.x", "rollbar": "^2.26.4 || ^3.0.0-alpha.3" } }, "sha512-e3S9K9k1BLNuqAFA/AD8XH4kcypClYWoMBuW5LO7fnu5Jy1/qiRFIgS5cFZpAFWQqJaSPQAqrLP6n41sH08X9A=="], @@ -508,7 +508,7 @@ "@types/suncalc": ["@types/suncalc@1.9.2", "", {}, "sha512-ATAGBHHfA1TlE2tjfidLyTcysjoT2JHHEAmWRULh73SU9UTn++j5fqHEW16X6Y/2Li87jEQXzgu4R/OOdlDqzw=="], - "@types/three": ["@types/three@0.184.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA=="], + "@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], @@ -526,25 +526,25 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.0", "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.0", "@typescript-eslint/tsconfig-utils": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -676,7 +676,7 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.15.2", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="], + "axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="], "babel-jest": ["babel-jest@30.3.0", "", { "dependencies": { "@jest/transform": "30.3.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ=="], @@ -984,7 +984,7 @@ "escope": ["escope@3.6.0", "", { "dependencies": { "es6-map": "^0.1.3", "es6-weak-map": "^2.0.1", "esrecurse": "^4.1.0", "estraverse": "^4.1.1" } }, "sha512-75IUQsusDdalQEW/G/2esa87J7raqdJF+Ca0/Xm5C3Q58Nr4yVYjZGp/P1+2xiEVgXRrA39dpRb8LcshajbqDQ=="], - "eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="], + "eslint": ["eslint@10.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="], "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], @@ -998,7 +998,7 @@ "eslint-plugin-no-null": ["eslint-plugin-no-null@1.0.2", "", { "peerDependencies": { "eslint": ">=3.0.0" } }, "sha512-uRDiz88zCO/2rzGfgG15DBjNsgwWtWiSo4Ezy7zzajUgpnFIqd1TjepKeRmJZHEfBGu58o2a8S0D7vglvvhkVA=="], - "eslint-plugin-promise": ["eslint-plugin-promise@7.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, "sha512-SWKjd+EuvWkYaS+uN2csvj0KoP43YTu7+phKQ5v+xw6+A0gutVX2yqCeCkC3uLCJFiPfR2dD8Es5L7yUsmvEaA=="], + "eslint-plugin-promise": ["eslint-plugin-promise@7.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, "sha512-6uGiOR0INuujr6PEQmeSSP7GbIMJ/ebEXXiEzb/nOj68LknH5Pxzb/AbZivmr6VE6TkTE8rTjRK9zhKpK6HsRA=="], "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], @@ -1092,7 +1092,7 @@ "flatted": ["flatted@3.3.2", "", {}, "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA=="], - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], @@ -1226,7 +1226,7 @@ "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - "i18next": ["i18next@26.0.7", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-f7tL/iw0VQsx4nC5oNxBM2RjM8alNys5KzyiQTU6A9TI5TI89py4/Ez1cKFvHiLWsvzOXvuGUES+Kk/A2WiANQ=="], + "i18next": ["i18next@26.0.10", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-k3yGPAlWR2RdMYoVXJoDZDT87qeHIWKH7gVksdZMpRty7QX/D9QZeYGvN08KGbKHke9wn01eYT+EEsrqX/YTlw=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -1408,7 +1408,7 @@ "jest-haste-map": ["jest-haste-map@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", "jest-util": "30.3.0", "jest-worker": "30.3.0", "picomatch": "^4.0.3", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.3" } }, "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA=="], - "jest-junit": ["jest-junit@16.0.0", "", { "dependencies": { "mkdirp": "^1.0.4", "strip-ansi": "^6.0.1", "uuid": "^8.3.2", "xml": "^1.0.1" } }, "sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ=="], + "jest-junit": ["jest-junit@17.0.0", "", { "dependencies": { "mkdirp": "^1.0.4", "strip-ansi": "^6.0.1", "uuid": "^14.0.0", "xml": "^1.0.1" } }, "sha512-RYWCkq4j59gUXj5DsgbIE7xFBZzu1gtibPhyjSjMmGaOTLnqlXhg7x9zuGCwgbCuMAyoyvk0Mi8wSrRR5uOeLA=="], "jest-leak-detector": ["jest-leak-detector@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "pretty-format": "30.3.0" } }, "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ=="], @@ -1730,7 +1730,7 @@ "possible-typed-array-names": ["possible-typed-array-names@1.0.0", "", {}, "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q=="], - "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], "postcss-media-query-parser": ["postcss-media-query-parser@0.2.3", "", {}, "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig=="], @@ -1790,23 +1790,23 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": "cli.js" }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], "react-color": ["react-color@2.19.3", "", { "dependencies": { "@icons/material": "^0.2.4", "lodash": "^4.17.15", "lodash-es": "^4.17.15", "material-colors": "^1.2.1", "prop-types": "^15.5.10", "reactcss": "^1.2.0", "tinycolor2": "^1.4.1" }, "peerDependencies": { "react": "*" } }, "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA=="], - "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], - "react-is": ["react-is@19.2.5", "", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="], + "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], "react-popper": ["react-popper@2.3.0", "", { "dependencies": { "react-fast-compare": "^3.0.1", "warning": "^4.0.2" }, "peerDependencies": { "@popperjs/core": "^2.0.0", "react": "^16.8.0 || ^17 || ^18", "react-dom": "^16.8.0 || ^17 || ^18" } }, "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q=="], "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" } }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], - "react-router": ["react-router@7.14.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw=="], + "react-router": ["react-router@7.15.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ=="], - "react-test-renderer": ["react-test-renderer@19.2.5", "", { "dependencies": { "react-is": "^19.2.5", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-kwViRpdISMTpcpy5B6TSewfJzRjnajihRaj57ZmOWKD+SPN6k9LUM13O0pfOuW8ir6B6OOiAXwCRqOoVxRNykA=="], + "react-test-renderer": ["react-test-renderer@19.2.6", "", { "dependencies": { "react-is": "^19.2.6", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw=="], "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], @@ -1990,7 +1990,7 @@ "string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="], - "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + "string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -2020,7 +2020,7 @@ "strip-json-comments": ["strip-json-comments@1.0.4", "", { "bin": "cli.js" }, "sha512-AOPG8EBc5wAikaG1/7uFCNFJwnKOuQwFTpYBdTW6OvWHeZBQBrAA/amefHGrEiOnCPcLFZK6FUPtWVKpQVIRgg=="], - "stylelint": ["stylelint@17.9.0", "", { "dependencies": { "@csstools/css-calc": "^3.2.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.1", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.2", "global-modules": "^2.0.0", "globby": "^16.2.0", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", "is-plain-object": "^5.0.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.5.9", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.0", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" } }, "sha512-xO0jeY6z1/urFL5L/BZLmB1yYlbRiRMQnYH6ArZIDWJ+SZXGssOY7XoYb1JIv/L220+EBnwwJXJS4Mt/F96SvA=="], + "stylelint": ["stylelint@17.11.0", "", { "dependencies": { "@csstools/css-calc": "^3.2.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.1", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.2", "global-modules": "^2.0.0", "globby": "^16.2.0", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", "is-plain-object": "^5.0.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.5.13", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.1", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" } }, "sha512-/3czzmbF9XdGWvReDF3Ex4R23Ajolo7j8RB2bFNEqk6Ht356nlpVV+G5bG2Qt8AW1ofJzXztBRDnAtd7cgowWA=="], "stylelint-config-recommended": ["stylelint-config-recommended@18.0.0", "", { "peerDependencies": { "stylelint": "^17.0.0" } }, "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg=="], @@ -2178,7 +2178,7 @@ "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], - "uuid": ["uuid@8.3.2", "", { "bin": "dist/bin/uuid" }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], @@ -2482,8 +2482,6 @@ "eslint-plugin-jest/@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA=="], - "eslint-plugin-promise/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], - "eslint-plugin-react/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -2960,8 +2958,6 @@ "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.0", "@typescript-eslint/tsconfig-utils": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA=="], - "eslint-plugin-promise/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "expand-brackets/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], diff --git a/package.json b/package.json index d56de716f7..53d67b7c21 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@monaco-editor/react": "4.7.0", "@react-spring/three": "10.0.3", "@react-three/drei": "10.7.7", - "@react-three/fiber": "9.6.0", + "@react-three/fiber": "9.6.1", "@rollbar/react": "1.0.0", "@types/bun": "1.3.13", "@types/lodash": "4.17.24", @@ -55,10 +55,10 @@ "@types/react-dom": "19.2.3", "@types/react-test-renderer": "19.1.0", "@types/redux-immutable-state-invariant": "2.1.4", - "@types/three": "0.184.0", + "@types/three": "0.184.1", "@types/ws": "8.18.1", "@xterm/xterm": "6.0.0", - "axios": "1.15.2", + "axios": "1.16.0", "bowser": "2.14.1", "browser-speech": "1.1.1", "delaunator": "5.1.0", @@ -66,7 +66,7 @@ "farmbot": "15.9.3", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.0.7", + "i18next": "26.0.10", "lodash": "4.18.1", "markdown-it": "14.1.1", "markdown-it-emoji": "3.0.0", @@ -77,11 +77,11 @@ "promise-timeout": "1.3.0", "punycode": "2.3.1", "querystring-es3": "0.2.1", - "react": "19.2.5", + "react": "19.2.6", "react-color": "2.19.3", - "react-dom": "19.2.5", + "react-dom": "19.2.6", "react-redux": "9.2.0", - "react-router": "7.14.2", + "react-router": "7.15.0", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", @@ -104,14 +104,14 @@ "@types/jest": "30.0.0", "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", - "@typescript-eslint/eslint-plugin": "8.59.0", - "@typescript-eslint/parser": "8.59.0", - "eslint": "10.2.1", + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "eslint": "10.3.0", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", "eslint-plugin-jest": "29.15.2", "eslint-plugin-no-null": "1.0.2", - "eslint-plugin-promise": "7.2.1", + "eslint-plugin-promise": "7.3.0", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", "happy-dom": "20.9.0", @@ -119,19 +119,19 @@ "jest-canvas-mock": "2.5.2", "jest-cli": "30.3.0", "jest-environment-jsdom": "30.3.0", - "jest-junit": "16.0.0", + "jest-junit": "17.0.0", "jest-skipped-reporter": "0.0.5", "jshint": "2.13.6", "madge": "8.0.0", "path-browserify": "1.0.1", "playwright": "1.59.1", - "postcss": "8.5.10", + "postcss": "8.5.14", "postcss-scss": "4.0.9", "raf": "3.4.1", - "react-test-renderer": "19.2.5", + "react-test-renderer": "19.2.6", "sass": "1.99.0", "sass-lint": "1.13.1", - "stylelint": "17.9.0", + "stylelint": "17.11.0", "stylelint-config-standard-scss": "17.0.0", "ts-jest": "29.4.9", "tslint": "6.1.3" From 4f5ed679e2a7aab4d9e18309e470cabf6c9f7726 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 7 May 2026 12:51:36 -0700 Subject: [PATCH 53/83] upgrade workflow --- .github/actions/setup-ci/action.yml | 16 ++ .github/workflows/build.yml | 365 ++-------------------------- .github/workflows/render.yml | 222 +++++++++++++++++ .github/workflows/test.yml | 203 ++++++++++++++++ bunfig.toml | 4 +- scripts/ci/percent-change | 7 + scripts/{ => ci}/send-notification | 0 scripts/fps.js | 54 ++-- scripts/fps_plot.js | 135 ---------- scripts/metric_plot.js | 358 +++++++++++++++++++++++++++ 10 files changed, 861 insertions(+), 503 deletions(-) create mode 100644 .github/workflows/render.yml create mode 100644 .github/workflows/test.yml create mode 100755 scripts/ci/percent-change rename scripts/{ => ci}/send-notification (100%) delete mode 100644 scripts/fps_plot.js create mode 100644 scripts/metric_plot.js diff --git a/.github/actions/setup-ci/action.yml b/.github/actions/setup-ci/action.yml index d9034f81b1..d1cad902ff 100644 --- a/.github/actions/setup-ci/action.yml +++ b/.github/actions/setup-ci/action.yml @@ -1,5 +1,10 @@ name: Set up CI description: Prepare the FarmBot web Docker image and dependencies. +inputs: + save-docker-cache: + description: Save Docker build layers to the GitHub Actions cache. + required: false + default: "false" runs: using: composite steps: @@ -11,6 +16,17 @@ runs: uses: docker/setup-buildx-action@v4 - name: Build web image + if: inputs.save-docker-cache != 'true' + uses: docker/build-push-action@v7 + with: + context: . + file: docker_configs/api.Dockerfile + load: true + tags: farmbot_web:latest + cache-from: type=gha,scope=farmbot-web + + - name: Build web image and save Docker cache + if: inputs.save-docker-cache == 'true' uses: docker/build-push-action@v7 with: context: . diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b23368c10..a5fca287e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,362 +1,25 @@ -name: Test and Render +name: build on: push: pull_request: permissions: - contents: write - -env: - NOTIFY: ${{ github.ref_name == 'staging' }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + contents: read jobs: test: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 100 - - - name: Send notification - if: env.NOTIFY == 'true' - run: scripts/send-notification "CI run started" - - - name: Send notification - if: env.NOTIFY == 'true' - run: scripts/send-notification "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|logs>" - - - name: Create compare link - if: env.NOTIFY == 'true' - run: | - DEPLOYS_URL_API="https://api.github.com/repos/Farmbot/Farmbot-Web-App/deployments" - COMPARE_URL_WEB="https://github.com/Farmbot/Farmbot-Web-App/compare/" - data=$(curl -fsS "$DEPLOYS_URL_API") || data="[]" - last_sha=$(printf "%s" "$data" | python3 -c 'import json,sys; data=json.loads(sys.stdin.read() or "[]"); deploy_index=0; sha=(data[deploy_index] or {}).get("sha"); print(sha or "")') - compare_url="$COMPARE_URL_WEB$last_sha...${GITHUB_SHA}" - echo "$compare_url" - echo "COMPARE_URL=$compare_url" >> "$GITHUB_ENV" - - - name: Send notification - if: env.NOTIFY == 'true' - run: scripts/send-notification "<${COMPARE_URL}|diff>" - - - name: Set up CI - uses: ./.github/actions/setup-ci - - - name: Run Ruby linters - run: sudo docker compose run --rm web bun run ruby-check - - - name: Run Ruby tests - run: sudo docker compose run --rm web bundle exec rspec spec --format progress --format RspecJunitFormatter --out test-results/rspec.xml - - - name: Check app coverage status - if: always() - run: sudo docker compose run --rm web bundle exec rake check_file_coverage:api || [ "$NOTIFY" = "true" ] - - - name: Upload app coverage to Codecov - if: ${{ hashFiles('coverage_api/coverage.xml') != '' }} - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage_api/coverage.xml - fail_ci_if_error: false - - - name: Run JS linters - run: sudo docker compose run --rm web bun run linters - - - name: Check bun build - run: | - sudo docker compose run --rm web bundle exec rake assets:precompile 2>&1 | tee assets_precompile.log - printf -- '-%.0s' {1..50}; printf '\n' - if grep -Ei '^(warn|error):' assets_precompile.log; then - exit 1 - fi - - - name: Run JS tests - run: | - set +e - sudo docker compose run -T --rm web bun test --reporter=junit --reporter-outfile=test-results/junit.xml > test-results/js-test-output.log 2>&1 - status=$? - head -n 5 test-results/js-test-output.log - tail -n 5 test-results/js-test-output.log - if [ "$status" -eq 0 ]; then - echo "COVERAGE_AVAILABLE=true" >> "$GITHUB_ENV" - fi - exit $status - - - name: Check frontend coverage status - if: always() - env: - GITHUB_PULL_REQUEST: ${{ github.event.pull_request.html_url || '/0' }} - run: sudo docker compose run --rm -e GITHUB_SHA="$GITHUB_SHA" -e GITHUB_REF_NAME="$GITHUB_REF_NAME" -e GITHUB_PULL_REQUEST="$GITHUB_PULL_REQUEST" web bundle exec rake coverage:run || [ "$NOTIFY" = "true" ] - - - name: Check frontend file coverage status - if: always() - run: | - changed=$(git diff --name-only staging...HEAD | tr '\n' ',' | sed 's/,$//') || true - sudo docker compose run --rm -e CHANGED_FILES="$changed" web bundle exec rake check_file_coverage:fe || true - - - name: Report frontend coverage to Coveralls - if: env.COVERAGE_AVAILABLE == 'true' - uses: coverallsapp/github-action@v2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - file: coverage_fe/lcov.info - format: lcov - fail-on-error: false - - - name: Restore frontend coverage value - if: always() - uses: actions/cache/restore@v5 - with: - path: fe_coverage.csv - key: fe-coverage-${{ github.ref_name }}-${{ github.run_id }}-${{ github.run_attempt }} - restore-keys: | - fe-coverage-${{ github.ref_name }}- - - - name: Track frontend coverage value - if: always() - run: | - if [ "$COVERAGE_AVAILABLE" != "true" ]; then - echo "skipping" - exit 0 - fi - eval "$(awk -F: '/^LH:/ { covered += $2 } /^LF:/ { total += $2 } END { printf "COVERED=%d\nTOTAL=%d\n", covered, total }' coverage_fe/lcov.info)" - FE_VALUE=$(awk "BEGIN { printf \"%.2f\", ($COVERED / $TOTAL) * 100 }") - if [ -f fe_coverage.csv ]; then - PREV_FE=$(python3 -c 'import csv; f=open("fe_coverage.csv", newline=""); r=csv.reader(f); next(r, None); rows=[float(row[0]) for row in r if row and row[0]]; print(f"{rows[-1]:.2f}" if rows else "98.7")') - else - PREV_FE=98.7 - fi - percent_change=$(FE_VALUE="$FE_VALUE" PREV_FE_VALUE="$PREV_FE" python3 -c 'import os; fe=float(os.environ.get("FE_VALUE","0") or 0); prev=float(os.environ.get("PREV_FE_VALUE","0") or 0); print("n/a" if prev==0 else f"{((fe-prev)/prev)*100:.2f}")') - echo "Covered lines: $COVERED, Total lines: $TOTAL, Coverage: $FE_VALUE%" - echo "$FE_VALUE% ($percent_change% change)" - if [ ! -f fe_coverage.csv ]; then - printf '%s\n' "percent,covered lines,total lines,percent change" > fe_coverage.csv - fi - printf '%s\n' "$FE_VALUE,$COVERED,$TOTAL,$percent_change" >> fe_coverage.csv - cat fe_coverage.csv - - - name: Save frontend coverage value - if: ${{ always() && hashFiles('fe_coverage.csv') != '' }} - uses: actions/cache/save@v5 - with: - path: fe_coverage.csv - key: fe-coverage-${{ github.ref_name }}-${{ github.run_id }}-${{ github.run_attempt }} - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v7 - with: - name: test-results - path: test-results - if-no-files-found: warn - - - name: Upload coverage artifacts - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage - path: | - fe_coverage.csv - scene_metrics.csv - if-no-files-found: warn - - - name: On failure - if: failure() - run: | - sudo docker compose ps || true - sudo docker compose logs --no-color --tail=500 || true - - - name: Send notification - if: success() && env.NOTIFY == 'true' - run: scripts/send-notification "CI run succeeded" - - - name: Send notification - if: failure() && env.NOTIFY == 'true' - run: scripts/send-notification "CI run failed" + uses: ./.github/workflows/test.yml + permissions: + contents: read + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} render: - runs-on: gpu - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up CI - uses: ./.github/actions/setup-ci - - - name: Start services - run: | - sudo docker compose up -d - sudo docker compose ps - - - name: Install playwright - run: | - curl -fsSL https://bun.sh/install | bash - export BUN_INSTALL="$HOME/.bun" - export PATH="$BUN_INSTALL/bin:$PATH" - export PLAYWRIGHT_BROWSERS_PATH=/tmp/playwright-browsers - echo "BUN_INSTALL=$BUN_INSTALL" >> "$GITHUB_ENV" - echo "$BUN_INSTALL/bin" >> "$GITHUB_PATH" - echo "PLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH" >> "$GITHUB_ENV" - bunx playwright install chromium --with-deps - - - name: Set up GPU - run: | - sudo modprobe nvidia || true - sudo modprobe nvidia_uvm || true - ls -la /dev/nvidia* || true - nvidia-smi - - - name: Wait for load - run: | - for i in $(seq 1 90); do - if curl -fsS "http://127.0.0.1:3000/promo" >/dev/null; then - echo "web is up" - exit 0 - fi - sleep 2 - done - echo "web did not become ready" - exit 1 - - - name: Run playwright - run: | - attempts=2 - for _ in $(seq 1 "$attempts"); do - url="http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" - echo "Attempting FPS check via ${url}" - if ! sudo docker compose exec -T web curl -I "${url}" >/dev/null; then - continue - fi - - if ! fps_output=$(bun scripts/fps.js "${url}" "/tmp/promo.png" "/tmp/fps_plot.png"); then - echo "${fps_output}" - continue - fi - echo "${fps_output}" - - fps_value=$(echo "${fps_output}" | awk -F= '/^FPS_VALUE=/{print $2; exit}') - scene_metrics=$(echo "${fps_output}" | awk -F= '/^SCENE_METRICS=/{print $2; exit}') - webgl_status=$(echo "${fps_output}" | awk -F= '/^WEBGL_STATUS=/{print $2; exit}') - webgl_vendor=$(echo "${fps_output}" | awk -F= '/^WEBGL_VENDOR=/{print $2; exit}') - webgl_renderer=$(echo "${fps_output}" | awk -F= '/^WEBGL_RENDERER=/{print $2; exit}') - echo "FPS_VALUE=${fps_value}" >> "$GITHUB_ENV" - echo "SCENE_METRICS=${scene_metrics}" >> "$GITHUB_ENV" - echo "WEBGL_STATUS=${webgl_status}" >> "$GITHUB_ENV" - echo "WEBGL_VENDOR=${webgl_vendor}" >> "$GITHUB_ENV" - echo "WEBGL_RENDERER=${webgl_renderer}" >> "$GITHUB_ENV" - - exit 0 - done - echo "FPS check failed" - exit 1 - - - name: Restore FPS value - uses: actions/cache/restore@v5 - with: - path: | - fps_value.txt - scene_metrics.csv - key: fps-value-${{ github.ref_name }}-${{ github.run_id }}-${{ github.run_attempt }} - restore-keys: | - fps-value-${{ github.ref_name }}- - - - name: Track FPS value - run: | - if [ -f fps_value.txt ]; then - PREV=$(cat fps_value.txt) - else - PREV=60 - fi - echo "Previous value: $PREV fps" - percent_change=$(FPS_VALUE="$FPS_VALUE" PREV_VALUE="$PREV" python3 -c 'import os; fps=float(os.environ.get("FPS_VALUE","0") or 0); prev=float(os.environ.get("PREV_VALUE","0") or 0); print("n/a" if prev==0 else f"{((fps-prev)/prev)*100:.2f}")') - echo "$FPS_VALUE fps ($percent_change% change)" - echo "PERCENT_CHANGE=$percent_change" >> "$GITHUB_ENV" - echo "$FPS_VALUE" > fps_value.txt - if [ ! -f scene_metrics.csv ]; then - printf '%s\n' "epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes" > scene_metrics.csv - fi - printf '%s\n' "$SCENE_METRICS" >> scene_metrics.csv - cat scene_metrics.csv - - - name: Save FPS value - if: ${{ hashFiles('fps_value.txt') != '' }} - uses: actions/cache/save@v5 - with: - path: | - fps_value.txt - scene_metrics.csv - key: fps-value-${{ github.ref_name }}-${{ github.run_id }}-${{ github.run_attempt }} - - - name: View values - run: | - echo $FPS_VALUE - echo $SCENE_METRICS - echo $WEBGL_STATUS - echo $WEBGL_VENDOR - echo $WEBGL_RENDERER - - - name: Send notification - if: env.NOTIFY == 'true' - run: scripts/send-notification "${FPS_VALUE} fps (${PERCENT_CHANGE}% change)" - - - name: Upload image artifacts - uses: actions/upload-artifact@v7 - with: - name: images - path: | - /tmp/promo.png - /tmp/fps_plot.png - if-no-files-found: warn - - - name: On failure - if: failure() - run: | - sudo docker compose ps || true - sudo docker compose logs --no-color --tail=500 || true - - - name: Publish image links - if: success() && env.NOTIFY == 'true' - env: - ARTIFACT_BRANCH: ci-artifacts - run: | - worktree=$(mktemp -d) - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - if git ls-remote --exit-code --heads origin "$ARTIFACT_BRANCH" >/dev/null; then - git fetch origin "$ARTIFACT_BRANCH" - git worktree add "$worktree" "origin/$ARTIFACT_BRANCH" - else - git worktree add --detach "$worktree" - git -C "$worktree" switch --orphan "$ARTIFACT_BRANCH" - fi - - mkdir -p "$worktree/build/latest" "$worktree/build/${GITHUB_RUN_ID}" - cp /tmp/promo.png "$worktree/build/latest/promo.png" - cp /tmp/fps_plot.png "$worktree/build/latest/fps_plot.png" - cp /tmp/promo.png "$worktree/build/${GITHUB_RUN_ID}/promo.png" - cp /tmp/fps_plot.png "$worktree/build/${GITHUB_RUN_ID}/fps_plot.png" - git -C "$worktree" add build - git -C "$worktree" commit -m "Update CI image artifacts for ${GITHUB_RUN_ID}" || true - git -C "$worktree" push origin "HEAD:$ARTIFACT_BRANCH" - - raw_base="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${ARTIFACT_BRANCH}/build/${GITHUB_RUN_ID}" - echo "SCREENSHOT_URL=${raw_base}/promo.png" >> "$GITHUB_ENV" - echo "FPS_PLOT_URL=${raw_base}/fps_plot.png" >> "$GITHUB_ENV" - - - name: Send notification - if: success() && env.NOTIFY == 'true' - run: | - RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - for MESSAGE in \ - "<${SCREENSHOT_URL:-$RUN_URL}|screenshot>" \ - "<${FPS_PLOT_URL:-$RUN_URL}|fps>" - do - scripts/send-notification "$MESSAGE" - done + needs: test + uses: ./.github/workflows/render.yml + permissions: + contents: write + secrets: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml new file mode 100644 index 0000000000..c093262276 --- /dev/null +++ b/.github/workflows/render.yml @@ -0,0 +1,222 @@ +name: render + +on: + workflow_call: + secrets: + SLACK_WEBHOOK_URL: + required: false + +env: + NOTIFY: ${{ github.ref_name == 'staging' }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + PROMO_IMAGE: promo + FPS_SAMPLES_NAME: fps_samples + SCENE_METRICS_NAME: scene_metrics + FE_COVERAGE_NAME: fe_coverage + CACHE_KEY_SUFFIX: ${{ github.run_id }}-${{ github.run_attempt }} + FPS_METRICS_CACHE_KEY: fps-metrics-${{ github.ref_name }} + FE_COVERAGE_CACHE_KEY: fe-coverage-p-${{ github.ref_name }} + +jobs: + render: + runs-on: gpu + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up CI + uses: ./.github/actions/setup-ci + + - name: Start services + run: | + sudo docker compose up -d + sudo docker compose ps + + - name: Install playwright + run: | + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="$HOME/.bun" + export PATH="$BUN_INSTALL/bin:$PATH" + export PLAYWRIGHT_BROWSERS_PATH=/tmp/playwright-browsers + echo "BUN_INSTALL=$BUN_INSTALL" >> "$GITHUB_ENV" + echo "$BUN_INSTALL/bin" >> "$GITHUB_PATH" + echo "PLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH" >> "$GITHUB_ENV" + bunx playwright install chromium --with-deps + + - name: Set up GPU + run: | + sudo modprobe nvidia || true + sudo modprobe nvidia_uvm || true + ls -la /dev/nvidia* || true + nvidia-smi + + - name: Wait for load + run: | + for i in $(seq 1 90); do + if curl -fsS "http://127.0.0.1:3000/promo" >/dev/null; then + echo "web is up" + exit 0 + fi + sleep 2 + done + echo "web did not become ready" + exit 1 + + - name: Run playwright + run: | + attempts=2 + for _ in $(seq 1 "$attempts"); do + url="http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" + echo "Attempting FPS check via ${url}" + if ! sudo docker compose exec -T web curl -I "${url}" >/dev/null; then + continue + fi + + if ! fps_output=$(bun scripts/fps.js "${url}" "/tmp/$PROMO_IMAGE.png" "/tmp/$FPS_SAMPLES_NAME.csv"); then + echo "${fps_output}" + continue + fi + echo "${fps_output}" + + fps_value=$(echo "${fps_output}" | awk -F= '/^FPS_VALUE=/{print $2; exit}') + scene_metrics=$(echo "${fps_output}" | awk -F= '/^SCENE_METRICS=/{print $2; exit}') + echo "FPS_VALUE=${fps_value}" >> "$GITHUB_ENV" + echo "SCENE_METRICS=${scene_metrics}" >> "$GITHUB_ENV" + + exit 0 + done + echo "FPS check failed" + exit 1 + + - name: Restore FPS metrics + uses: actions/cache/restore@v5 + with: + path: | + fps_value.txt + /tmp/${{ env.SCENE_METRICS_NAME }}.csv + key: ${{ env.FPS_METRICS_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} + restore-keys: | + ${{ env.FPS_METRICS_CACHE_KEY }}- + + - name: Track FPS value + run: | + if [ -f fps_value.txt ]; then + PREV=$(cat fps_value.txt) + else + PREV=60 + fi + echo "Previous value: $PREV fps" + percent_change=$(CURRENT_VALUE="$FPS_VALUE" PREVIOUS_VALUE="$PREV" scripts/ci/percent-change) + echo "$FPS_VALUE fps ($percent_change% change)" + echo "PERCENT_CHANGE=$percent_change" >> "$GITHUB_ENV" + echo "$FPS_VALUE" > fps_value.txt + scene_metrics_csv="/tmp/$SCENE_METRICS_NAME.csv" + if [ ! -f "$scene_metrics_csv" ]; then + printf '%s\n' "epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes" > "$scene_metrics_csv" + fi + printf '%s\n' "$SCENE_METRICS" >> "$scene_metrics_csv" + cat "$scene_metrics_csv" + + - name: Restore frontend coverage value + if: always() + uses: actions/cache/restore@v5 + with: + path: ${{ env.FE_COVERAGE_NAME }}.csv + key: ${{ env.FE_COVERAGE_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} + restore-keys: | + ${{ env.FE_COVERAGE_CACHE_KEY }}- + + - name: Plot metric CSVs + run: | + for metric in "$FPS_SAMPLES_NAME" "$SCENE_METRICS_NAME"; do + bun scripts/metric_plot.js "/tmp/$metric.csv" + done + if [ -f "$FE_COVERAGE_NAME.csv" ]; then + bun scripts/metric_plot.js "$FE_COVERAGE_NAME.csv" + fi + + - name: Save FPS metrics + if: ${{ hashFiles('fps_value.txt') != '' }} + uses: actions/cache/save@v5 + with: + path: | + fps_value.txt + /tmp/${{ env.SCENE_METRICS_NAME }}.csv + key: ${{ env.FPS_METRICS_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} + + - name: View values + run: | + echo $FPS_VALUE + echo $SCENE_METRICS + + - name: Send notification + if: env.NOTIFY == 'true' + run: scripts/ci/send-notification "${FPS_VALUE} fps (${PERCENT_CHANGE}% change)" + + - name: Upload image artifacts + uses: actions/upload-artifact@v7 + with: + name: images + path: | + /tmp/${{ env.PROMO_IMAGE }}.png + /tmp/${{ env.FPS_SAMPLES_NAME }}.png + /tmp/${{ env.SCENE_METRICS_NAME }}.png + /tmp/${{ env.FE_COVERAGE_NAME }}.png + if-no-files-found: warn + + - name: Upload csv artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: scene-metrics-csv + path: | + /tmp/${{ env.FPS_SAMPLES_NAME }}.csv + /tmp/${{ env.SCENE_METRICS_NAME }}.csv + if-no-files-found: warn + + - name: On failure + if: failure() + run: | + sudo docker compose ps || true + sudo docker compose logs --no-color --tail=500 || true + + - name: Publish image links + if: success() && env.NOTIFY == 'true' + env: + ARTIFACT_BRANCH: ci-artifacts + run: | + worktree=$(mktemp -d) + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git ls-remote --exit-code --heads origin "$ARTIFACT_BRANCH" >/dev/null; then + git fetch origin "$ARTIFACT_BRANCH" + git worktree add "$worktree" "origin/$ARTIFACT_BRANCH" + else + git worktree add --detach "$worktree" + git -C "$worktree" switch --orphan "$ARTIFACT_BRANCH" + fi + + mkdir -p "$worktree/build/latest" "$worktree/build/${GITHUB_RUN_ID}" + for image in "$PROMO_IMAGE" "$FPS_SAMPLES_NAME"; do + cp "/tmp/$image.png" "$worktree/build/latest/$image.png" + cp "/tmp/$image.png" "$worktree/build/${GITHUB_RUN_ID}/$image.png" + done + git -C "$worktree" add build + git -C "$worktree" commit -m "Update CI image artifacts for ${GITHUB_RUN_ID}" || true + git -C "$worktree" push origin "HEAD:$ARTIFACT_BRANCH" + + raw_base="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${ARTIFACT_BRANCH}/build/${GITHUB_RUN_ID}" + echo "SCREENSHOT_URL=${raw_base}/${PROMO_IMAGE}.png" >> "$GITHUB_ENV" + echo "FPS_PLOT_URL=${raw_base}/${FPS_SAMPLES_NAME}.png" >> "$GITHUB_ENV" + + - name: Send notification + if: success() && env.NOTIFY == 'true' + run: | + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + for MESSAGE in \ + "<${SCREENSHOT_URL:-$RUN_URL}|screenshot>" \ + "<${FPS_PLOT_URL:-$RUN_URL}|fps>" \ + do + scripts/ci/send-notification "$MESSAGE" + done diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..3a62c36bd3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,203 @@ +name: test + +on: + workflow_call: + secrets: + CODECOV_TOKEN: + required: false + SLACK_WEBHOOK_URL: + required: false + +env: + NOTIFY: ${{ github.ref_name == 'staging' }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + FE_COVERAGE_NAME: fe_coverage + CACHE_KEY_SUFFIX: ${{ github.run_id }}-${{ github.run_attempt }} + FE_COVERAGE_CACHE_KEY: fe-coverage-p-${{ github.ref_name }} + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 100 + + - name: Send notification + if: env.NOTIFY == 'true' + run: scripts/ci/send-notification "CI run started" + + - name: Send notification + if: env.NOTIFY == 'true' + run: scripts/ci/send-notification "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|logs>" + + - name: Create compare link + if: env.NOTIFY == 'true' + run: | + DEPLOYS_URL_API="https://api.github.com/repos/Farmbot/Farmbot-Web-App/deployments" + COMPARE_URL_WEB="https://github.com/Farmbot/Farmbot-Web-App/compare/" + data=$(curl -fsS "$DEPLOYS_URL_API") || data="[]" + last_sha=$(DEPLOYS_DATA="$data" python3 - <<'PY' + import json + import os + + data = json.loads(os.environ.get("DEPLOYS_DATA") or "[]") + deploy_index = 0 + sha = (data[deploy_index] or {}).get("sha") if data else "" + print(sha or "") + PY + ) + compare_url="$COMPARE_URL_WEB$last_sha...${GITHUB_SHA}" + echo "$compare_url" + echo "COMPARE_URL=$compare_url" >> "$GITHUB_ENV" + + - name: Send notification + if: env.NOTIFY == 'true' + run: scripts/ci/send-notification "<${COMPARE_URL}|diff>" + + - name: Set up CI + uses: ./.github/actions/setup-ci + with: + save-docker-cache: "true" + + - name: Run Ruby linters + run: sudo docker compose run --rm web bun run ruby-check + + - name: Run Ruby tests + run: sudo docker compose run --rm web bundle exec rspec spec --format progress --format RspecJunitFormatter --out test-results/rspec.xml + + - name: Check app coverage status + if: always() + run: sudo docker compose run --rm web bundle exec rake check_file_coverage:api || [ "$NOTIFY" = "true" ] + + - name: Upload app coverage to Codecov + if: ${{ hashFiles('coverage_api/coverage.xml') != '' }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage_api/coverage.xml + fail_ci_if_error: false + + - name: Run JS linters + run: sudo docker compose run --rm web bun run linters + + - name: Check bun build + run: | + sudo docker compose run --rm web bundle exec rake assets:precompile 2>&1 | tee assets_precompile.log + printf -- '-%.0s' {1..50}; printf '\n' + if grep -Ei '^(warn|error):' assets_precompile.log; then + exit 1 + fi + + - name: Run JS tests + run: | + set +e + sudo docker compose run -T --rm web bun test --reporter=junit --reporter-outfile=test-results/junit.xml + status=$? + if [ "$status" -eq 0 ]; then + echo "COVERAGE_AVAILABLE=true" >> "$GITHUB_ENV" + fi + exit $status + + - name: Check frontend coverage status + if: always() + env: + GITHUB_PULL_REQUEST: ${{ github.event.pull_request.html_url || '/0' }} + run: sudo docker compose run --rm -e GITHUB_SHA="$GITHUB_SHA" -e GITHUB_REF_NAME="$GITHUB_REF_NAME" -e GITHUB_PULL_REQUEST="$GITHUB_PULL_REQUEST" web bundle exec rake coverage:run || [ "$NOTIFY" = "true" ] + + - name: Check frontend file coverage status + if: always() + run: | + changed=$(git diff --name-only staging...HEAD | tr '\n' ',' | sed 's/,$//') || true + sudo docker compose run --rm -e CHANGED_FILES="$changed" web bundle exec rake check_file_coverage:fe || true + + - name: Report frontend coverage to Coveralls + if: env.COVERAGE_AVAILABLE == 'true' + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ github.token }} + file: coverage_fe/lcov.info + format: lcov + fail-on-error: false + + - name: Restore frontend coverage value + if: always() + uses: actions/cache/restore@v5 + with: + path: ${{ env.FE_COVERAGE_NAME }}.csv + key: ${{ env.FE_COVERAGE_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} + restore-keys: | + ${{ env.FE_COVERAGE_CACHE_KEY }}- + + - name: Track frontend coverage value + if: always() + run: | + if [ "$COVERAGE_AVAILABLE" != "true" ]; then + echo "skipping" + exit 0 + fi + eval "$(awk -F: '/^LH:/ { covered += $2 } /^LF:/ { total += $2 } END { printf "COVERED=%d\nTOTAL=%d\n", covered, total }' coverage_fe/lcov.info)" + FE_VALUE=$(awk "BEGIN { printf \"%.2f\", ($COVERED / $TOTAL) * 100 }") + fe_coverage_csv="$FE_COVERAGE_NAME.csv" + if [ -f "$fe_coverage_csv" ]; then + PREV_FE=$(python3 - <<'PY' + import csv + import os + + with open(f"{os.environ['FE_COVERAGE_NAME']}.csv", newline="") as f: + reader = csv.reader(f) + next(reader, None) + rows = [float(row[0]) for row in reader if row and row[0]] + print(f"{rows[-1]:.2f}" if rows else "99.4") + PY + ) + else + PREV_FE=99.4 + fi + percent_change=$(CURRENT_VALUE="$FE_VALUE" PREVIOUS_VALUE="$PREV_FE" scripts/ci/percent-change) + echo "Covered lines: $COVERED, Total lines: $TOTAL, Coverage: $FE_VALUE%" + echo "$FE_VALUE% ($percent_change% change)" + if [ ! -f "$fe_coverage_csv" ]; then + printf '%s\n' "percent,covered lines,total lines,percent change" > "$fe_coverage_csv" + fi + printf '%s\n' "$FE_VALUE,$COVERED,$TOTAL,$percent_change" >> "$fe_coverage_csv" + cat "$fe_coverage_csv" + + - name: Save frontend coverage value + if: ${{ always() && hashFiles(format('{0}.csv', env.FE_COVERAGE_NAME)) != '' }} + uses: actions/cache/save@v5 + with: + path: ${{ env.FE_COVERAGE_NAME }}.csv + key: ${{ env.FE_COVERAGE_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results + path: test-results + if-no-files-found: warn + + - name: Upload coverage artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: fe-coverage-csv + path: ${{ env.FE_COVERAGE_NAME }}.csv + if-no-files-found: warn + + - name: On failure + if: failure() + run: | + sudo docker compose ps || true + sudo docker compose logs --no-color --tail=500 || true + + - name: Send notification + if: success() && env.NOTIFY == 'true' + run: scripts/ci/send-notification "CI run succeeded" + + - name: Send notification + if: failure() && env.NOTIFY == 'true' + run: scripts/ci/send-notification "CI run failed" diff --git a/bunfig.toml b/bunfig.toml index 0458dc7807..a2f23dfad2 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -3,7 +3,7 @@ preload = [ "./frontend/__test_support__/happydom.ts", "./frontend/__test_support__/bun_test_setup.ts" ] -coverageReporter = ["text", "lcov"] +coverageReporter = ["lcov"] coverageDir = "coverage_fe" coverage = true onlyFailures = true @@ -13,7 +13,7 @@ coveragePathIgnorePatterns = [ ] [test.reporter] -dots = true +dots = false [env] file = false diff --git a/scripts/ci/percent-change b/scripts/ci/percent-change new file mode 100755 index 0000000000..53f761c391 --- /dev/null +++ b/scripts/ci/percent-change @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +import os + +current = float(os.environ.get("CURRENT_VALUE", "0") or 0) +previous = float(os.environ.get("PREVIOUS_VALUE", "0") or 0) + +print("n/a" if previous == 0 else f"{((current - previous) / previous) * 100:.2f}") diff --git a/scripts/send-notification b/scripts/ci/send-notification similarity index 100% rename from scripts/send-notification rename to scripts/ci/send-notification diff --git a/scripts/fps.js b/scripts/fps.js index 6ab0ea5958..a3d82f3640 100644 --- a/scripts/fps.js +++ b/scripts/fps.js @@ -1,11 +1,10 @@ const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); -const { saveFpsPlot } = require('./fps_plot'); const url = process.argv[2]; -const screenshotPath = process.argv[3] || 'tmp/fps.png'; -const plotPath = process.argv[4] || 'tmp/fps_plot.png'; +const screenshotPath = process.argv[3] || '/tmp/fps.png'; +const samplesCsvPath = process.argv[4] || '/tmp/fps_samples.csv'; const maxLoadingSamples = 240; const postLoadSamples = 10; const chosenPostLoadSample = 5; @@ -17,6 +16,8 @@ const chromiumArgs = [ '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--ignore-gpu-blocklist', + '--disable-frame-rate-limit', + '--disable-gpu-vsync', '--enable-features=Vulkan', '--use-gl=angle', '--enable-gpu-rasterization', @@ -45,15 +46,38 @@ const webglRenderer = page => function printUsage() { console.log([ - 'Usage: bun scripts/fps.js [screenshot_path] [fps_plot_path]', + 'Usage: bun scripts/fps.js [screenshot_path] [fps_samples_csv_path]', '', 'Arguments:', - ' url Page URL that exposes window.__fps and window.__scene_metrics.', - ' screenshot_path Optional full-page screenshot PNG path. Default: tmp/fps.png', - ' fps_plot_path Optional FPS plot PNG path. Default: tmp/fps_plot.png', + ' url Page URL that exposes window.__fps and window.__scene_metrics.', + ' screenshot_path Optional full-page screenshot PNG path. Default: /tmp/fps.png', + ' fps_samples_csv_path Optional FPS samples CSV path. Default: /tmp/fps_samples.csv', ].join('\n')); } +const csvField = value => { + const stringValue = String(value); + return /[",\n\r]/.test(stringValue) + ? `"${stringValue.replace(/"/g, '""')}"` + : stringValue; +}; + +function saveFpsSamplesCsv(samples, destination) { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const rows = [ + ['elapsed seconds', 'fps', 'loading', 'chosen'], + ...samples.map(sample => [ + Number(sample.elapsedSeconds).toFixed(3), + Number(sample.fps).toFixed(2), + sample.loading ? 'true' : 'false', + sample.chosen ? 'true' : 'false', + ]), + ]; + fs.writeFileSync(destination, `${rows + .map(row => row.map(csvField).join(',')) + .join('\n')}\n`); +} + async function main() { console.log(`Launching Chromium with args: ${chromiumArgs.join(' ')}`); const browser = await chromium.launch({ @@ -71,7 +95,6 @@ async function main() { await page.waitForFunction(() => typeof window.__fps !== 'undefined'); let lastSample = 0; - let lastSampleIndex = -1; let loadingSampleCount = 0; let postLoadCount = 0; const sampleValues = []; @@ -83,20 +106,23 @@ async function main() { const n = Number(v); const elapsedSeconds = (Date.now() - startMs) / 1000; loading = await pageIsLoading(page); - sampleValues.push({ fps: n, elapsedSeconds, loading }); + let chosen = false; if (loading) { loadingSampleCount++; } else { postLoadCount++; if (postLoadCount === chosenPostLoadSample) { lastSample = n; - lastSampleIndex = i; + chosen = true; } } + const sample = { fps: n, elapsedSeconds, loading, chosen }; + sampleValues.push(sample); const status = loading ? 'loading' : `loaded ${postLoadCount}/${postLoadSamples}`; - console.log(`Sample ${i + 1} (${status}, ${elapsedSeconds.toFixed(2)}s): ${n.toFixed(2)}fps`); + const chosenMarker = chosen ? ' <---' : ''; + console.log(`Sample ${i + 1} (${status}, ${elapsedSeconds.toFixed(2)}s): ${n.toFixed(2)}fps${chosenMarker}`); if (postLoadCount >= postLoadSamples) { break; } if (loadingSampleCount >= maxLoadingSamples) { break; } await page.waitForTimeout(sampleIntervalMs); @@ -117,10 +143,8 @@ async function main() { timeout: 60_000, }); console.log(`FPS_SCREENSHOT=${screenshotPath}`); - await saveFpsPlot(browser, sampleValues, plotPath, { - highlightIndex: lastSampleIndex, - }); - console.log(`FPS_PLOT=${plotPath}`); + saveFpsSamplesCsv(sampleValues, samplesCsvPath); + console.log(`FPS_SAMPLES_CSV=${samplesCsvPath}`); } catch (err) { console.error('Failed to read window.__fps:', err.message || err); process.exitCode = 1; diff --git a/scripts/fps_plot.js b/scripts/fps_plot.js deleted file mode 100644 index 965dcb8f6f..0000000000 --- a/scripts/fps_plot.js +++ /dev/null @@ -1,135 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const formatStat = value => value.toFixed(2); -const formatPoint = value => Number(value.toFixed(2)); -const finiteSamples = samples => samples - .map((sample, index) => { - const value = typeof sample === 'object' ? sample.fps : sample; - const elapsedSeconds = typeof sample === 'object' - ? Number(sample.elapsedSeconds) - : index; - return { - value: Number(value), - elapsedSeconds, - index, - loading: typeof sample === 'object' ? sample.loading : undefined, - }; - }) - .filter(({ value }) => Number.isFinite(value)); - -function buildFpsPlotSvg(samples, options = {}) { - const width = options.width || 640; - const height = options.height || 320; - const highlightIndex = options.highlightIndex; - const margin = { top: 52, right: 24, bottom: 44, left: 54 }; - const plotWidth = width - margin.left - margin.right; - const plotHeight = height - margin.top - margin.bottom; - const finite = finiteSamples(samples); - const values = finite.map(({ value }) => value); - const times = finite - .map(({ elapsedSeconds }) => elapsedSeconds) - .filter(value => Number.isFinite(value)); - const minSample = values.length ? Math.min(...values) : 0; - const maxSample = values.length ? Math.max(...values) : 0; - const avgSample = values.length - ? values.reduce((total, value) => total + value, 0) / values.length - : 0; - const lastSample = values.length ? values[values.length - 1] : 0; - const minValue = Math.min(0, minSample); - const maxValue = Math.max(1, maxSample); - const valueRange = maxValue - minValue || 1; - const minTime = 0; - const maxTime = Math.max(1, times.length ? Math.max(...times) : samples.length - 1); - const timeRange = maxTime - minTime || 1; - const xFor = elapsedSeconds => margin.left + ( - (elapsedSeconds - minTime) / timeRange - ) * plotWidth; - const yFor = value => margin.top + ((maxValue - value) / valueRange) * plotHeight; - const points = finite - .map(({ value, elapsedSeconds }) => - `${formatPoint(xFor(elapsedSeconds))},${formatPoint(yFor(value))}`) - .join(' '); - const circles = finite - .map(({ value, elapsedSeconds, index }) => { - const highlighted = index === highlightIndex; - return [ - ``, - ].join(''); - }) - .join(''); - const gridValues = [maxValue, (maxValue + minValue) / 2, minValue]; - const grid = gridValues - .map(value => { - const y = formatPoint(yFor(value)); - return [ - ``, - `${formatStat(value)}`, - ].join(''); - }) - .join(''); - const tickInterval = maxTime <= 10 ? 1 : maxTime <= 100 ? 10 : 100; - const xTicks = Array.from({ length: Math.floor(maxTime / tickInterval) + 1 }, - (_value, index) => { - const elapsedSeconds = index * tickInterval; - const x = formatPoint(xFor(elapsedSeconds)); - return [ - ``, - `${elapsedSeconds}`, - ].join(''); - }) - .join(''); - const firstLoaded = finite.find(({ loading }) => loading === false); - const loadedMarker = firstLoaded - ? [ - ``, - `Loaded`, - ].join('') - : ''; - const stats = values.length - ? `min ${formatStat(minSample)} avg ${formatStat(avgSample)} max ${formatStat(maxSample)} last ${formatStat(lastSample)}` - : 'No valid samples'; - - return ` - - - - - - - - - FPS samples - ${stats} - ${grid} - - - ${xTicks} - ${loadedMarker} - ${points ? `` : ''} - ${circles} - Seconds - - -`; -} - -async function saveFpsPlot(browser, samples, destination, options = {}) { - fs.mkdirSync(path.dirname(destination), { recursive: true }); - const plotPage = await browser.newPage({ viewport: { width: 640, height: 320 } }); - try { - await plotPage.setContent(buildFpsPlotSvg(samples, options), { waitUntil: 'load' }); - await plotPage.locator('svg').screenshot({ - path: destination, - timeout: 60_000, - }); - } finally { - await plotPage.close(); - } -} - -module.exports = { - buildFpsPlotSvg, - saveFpsPlot, -}; diff --git a/scripts/metric_plot.js b/scripts/metric_plot.js new file mode 100644 index 0000000000..7d9bc5d4e9 --- /dev/null +++ b/scripts/metric_plot.js @@ -0,0 +1,358 @@ +const fs = require('fs'); +const path = require('path'); + +const escapeSvgText = value => String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +const formatStat = value => value.toFixed(2); +const formatPoint = value => Number(value.toFixed(2)); +const seriesColors = [ + '#0969da', + '#1a7f37', + '#cf222e', + '#8250df', + '#bf8700', + '#0a7ea4', + '#d12470', + '#57606a', + '#953800', + '#116329', +]; +const normalizeMetricSamples = (samples, valueKey = 'value') => samples + .map((sample, index) => { + const value = typeof sample === 'object' ? sample[valueKey] : sample; + const x = typeof sample === 'object' + ? Number(sample.x ?? sample.elapsedSeconds ?? index) + : index; + return { + value: Number(value), + x, + index, + loading: typeof sample === 'object' ? sample.loading : undefined, + }; + }) + .filter(({ value, x }) => Number.isFinite(value) && Number.isFinite(x)); + +const normalizeSeries = (series, valueKey) => series + .map((item, index) => ({ + name: item.name || `Series ${index + 1}`, + color: item.color || seriesColors[index % seriesColors.length], + samples: normalizeMetricSamples(item.samples || [], valueKey), + })) + .filter(item => item.samples.length); + +function buildMetricPlotSvg(samples, options = {}) { + const inputSamples = samples || []; + const width = options.width || 640; + const height = options.height || 320; + const highlightIndex = options.highlightIndex; + const title = escapeSvgText(options.title || 'Metric samples'); + const xLabel = escapeSvgText(options.xLabel || 'Samples'); + const valueKey = options.valueKey || 'value'; + const series = normalizeSeries(options.series || [{ + name: options.seriesName || title, + samples: inputSamples, + }], valueKey); + const multiSeries = series.length > 1; + const margin = { + top: multiSeries ? 76 : 52, + right: 24, + bottom: multiSeries ? 52 : 44, + left: 54, + }; + const plotWidth = width - margin.left - margin.right; + const plotHeight = height - margin.top - margin.bottom; + const finite = series.flatMap(item => item.samples); + const values = finite.map(({ value }) => value); + const xValues = finite + .map(({ x }) => x) + .filter(value => Number.isFinite(value)); + const minSample = values.length ? Math.min(...values) : 0; + const maxSample = values.length ? Math.max(...values) : 0; + const avgSample = values.length + ? values.reduce((total, value) => total + value, 0) / values.length + : 0; + const lastSample = values.length ? values[values.length - 1] : 0; + const minValue = values.length ? minSample : 0; + const maxValue = Math.max(1, maxSample); + const valueRange = maxValue - minValue || 1; + const minX = Math.min(0, xValues.length ? Math.min(...xValues) : 0); + const maxX = Math.max(1, xValues.length ? Math.max(...xValues) : inputSamples.length - 1); + const xRange = maxX - minX || 1; + const xFor = x => margin.left + ( + (x - minX) / xRange + ) * plotWidth; + const yFor = value => margin.top + ((maxValue - value) / valueRange) * plotHeight; + const lines = series + .map(item => { + const points = item.samples + .map(({ value, x }) => + `${formatPoint(xFor(x))},${formatPoint(yFor(value))}`) + .join(' '); + return points + ? `` + : ''; + }) + .join(''); + const circles = multiSeries + ? '' + : (series[0]?.samples || []) + .map(({ value, x, index }) => { + const highlighted = index === highlightIndex; + return [ + ``, + ].join(''); + }) + .join(''); + const gridValues = [maxValue, (maxValue + minValue) / 2, minValue]; + const grid = gridValues + .map(value => { + const y = formatPoint(yFor(value)); + return [ + ``, + `${formatStat(value)}`, + ].join(''); + }) + .join(''); + const tickInterval = maxX <= 10 ? 1 : maxX <= 100 ? 10 : 100; + const xTicks = Array.from({ length: Math.floor(maxX / tickInterval) + 1 }, + (_value, index) => { + const tickValue = index * tickInterval; + const x = formatPoint(xFor(tickValue)); + return [ + ``, + `${tickValue}`, + ].join(''); + }) + .join(''); + const firstLoaded = series[0]?.samples.find(({ loading }) => loading === false); + const loadedMarker = firstLoaded + ? [ + ``, + `Loaded`, + ].join('') + : ''; + const stats = escapeSvgText(values.length + ? `min ${formatStat(minSample)} avg ${formatStat(avgSample)} max ${formatStat(maxSample)} last ${formatStat(lastSample)}` + : 'No valid samples'); + const legend = multiSeries + ? series + .map((item, index) => { + const x = margin.left + (index % 5) * 112; + const y = 48 + Math.floor(index / 5) * 16; + return [ + ``, + `${escapeSvgText(item.name)}`, + ].join(''); + }) + .join('') + : ''; + + return ` + + + + + + + + + ${title} + ${stats} + ${legend} + ${grid} + + + ${xTicks} + ${loadedMarker} + ${lines} + ${circles} + ${xLabel} + + +`; +} + +async function saveMetricPlot(browser, samples, destination, options = {}) { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const plotPage = await browser.newPage({ viewport: { width: 640, height: 320 } }); + try { + await plotPage.setContent(buildMetricPlotSvg(samples, options), { waitUntil: 'load' }); + await plotPage.locator('svg').screenshot({ + path: destination, + timeout: 60_000, + }); + } finally { + await plotPage.close(); + } +} + +const parseCsvLine = line => { + const fields = []; + let field = ''; + let quoted = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"' && quoted && line[i + 1] === '"') { + field += '"'; + i++; + } else if (char === '"') { + quoted = !quoted; + } else if (char === ',' && !quoted) { + fields.push(field.trim()); + field = ''; + } else { + field += char; + } + } + fields.push(field.trim()); + return fields; +}; + +const parseCsv = content => { + const lines = content.split(/\r?\n/).filter(line => line.trim()); + const headers = lines.length ? parseCsvLine(lines[0]) : []; + const rows = lines.slice(1).map(line => { + const fields = parseCsvLine(line); + return Object.fromEntries(headers.map((header, index) => [header, fields[index]])); + }); + return { headers, rows }; +}; + +const inferCsvPlot = ({ headers, rows }, filename = '') => { + const basename = path.basename(filename); + const firstHeader = headers[0]; + const xHeader = headers.find(header => + ['elapsed seconds', 'elapsedSeconds'].includes(header)); + const sceneMetricExcludedHeaders = ['epoch', 'Points', 'Lines']; + const sceneMetricValueFor = (row, header) => { + const value = Number(row[header]); + if (header === 'FPS') { return value ? 1000 / value : NaN; } + return header === 'Triangles' ? value / 1000 : value; + }; + const valueFor = (row, header) => Number(row[header]); + const sceneMetricLabelFor = header => { + if (header === 'FPS') { return 'Frame time (ms)'; } + return header === 'Triangles' ? 'Triangles (k)' : header; + }; + const sampleFor = (row, index, valueHeader) => ({ + x: xHeader ? row[xHeader] : index, + value: valueFor(row, valueHeader), + loading: row.loading === undefined ? undefined : row.loading === 'true', + }); + const plottableHeaders = headers + .filter(header => !sceneMetricExcludedHeaders.includes(header)) + .filter(header => rows.some(row => Number.isFinite(sceneMetricValueFor(row, header)))); + + if (headers.includes('percent')) { + return { + title: 'Frontend coverage', + xLabel: 'Runs', + samples: rows.map((row, index) => sampleFor(row, index, 'percent')), + }; + } + if (headers.includes('FPS')) { + if (basename === 'scene_metrics.csv') { + return { + title: 'Scene metrics', + xLabel: 'Runs', + series: plottableHeaders.map(header => ({ + name: sceneMetricLabelFor(header), + samples: rows.map((row, index) => ({ + x: index, + value: sceneMetricValueFor(row, header), + })), + })), + }; + } + return { + title: 'FPS samples', + xLabel: 'Runs', + samples: rows.map((row, index) => sampleFor(row, index, 'FPS')), + }; + } + if (headers.includes('fps')) { + const highlightIndex = rows.findIndex(row => row.chosen === 'true'); + return { + title: 'FPS samples', + xLabel: xHeader ? 'Seconds' : 'Samples', + samples: rows.map((row, index) => sampleFor(row, index, 'fps')), + ...(highlightIndex >= 0 ? { highlightIndex } : {}), + }; + } + throw new Error(`No plottable metric found in ${basename || firstHeader || 'CSV'}`); +}; + +const buildCsvPlotSvg = (content, options = {}) => { + const inferred = inferCsvPlot(parseCsv(content), options.filename); + return buildMetricPlotSvg(inferred.samples, { ...inferred, ...options }); +}; + +async function saveCsvPlot(browser, csvPath, destination, options = {}) { + const content = fs.readFileSync(csvPath, 'utf8'); + const inferred = inferCsvPlot(parseCsv(content), csvPath); + return saveMetricPlot(browser, inferred.samples, destination, { + ...inferred, + ...options, + }); +} + +function printUsage() { + console.log([ + 'Usage: bun scripts/metric_plot.js [plot_path]', + '', + 'Supported CSV inputs:', + ' fps_samples.csv Plots the fps column against elapsed seconds.', + ' fe_coverage.csv Plots the percent column.', + ' scene_metrics.csv Plots numeric columns.', + '', + 'Arguments:', + ' csv_path CSV file to plot.', + ' plot_path Optional PNG output path. Default: /tmp/.png', + ].join('\n')); +} + +async function main() { + const csvPath = process.argv[2]; + if (!csvPath || csvPath === '-h' || csvPath === '--help') { + printUsage(); + process.exitCode = csvPath ? 0 : 1; + return; + } + + const destination = process.argv[3] + || path.join('/tmp', `${path.basename(csvPath, '.csv')}.png`); + const { chromium } = require('playwright'); + const browser = await chromium.launch({ + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + ], + }); + try { + await saveCsvPlot(browser, csvPath, destination); + console.log(`CSV_PLOT=${destination}`); + } finally { + await browser.close(); + } +} + +module.exports = { + buildCsvPlotSvg, + buildMetricPlotSvg, + escapeSvgText, + inferCsvPlot, + parseCsv, + saveCsvPlot, + saveMetricPlot, +}; + +if (require.main === module) { + main().catch((err) => { + console.error('Failed to plot CSV:', err.message || err); + process.exitCode = 1; + }); +} From 11e58c29f42a7f6bce052f9cc579196d30575249 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 7 May 2026 13:43:34 -0700 Subject: [PATCH 54/83] fix workflow syntax --- .github/workflows/render.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml index c093262276..d5bc301a3f 100644 --- a/.github/workflows/render.yml +++ b/.github/workflows/render.yml @@ -104,7 +104,7 @@ jobs: if [ -f fps_value.txt ]; then PREV=$(cat fps_value.txt) else - PREV=60 + PREV=100 fi echo "Previous value: $PREV fps" percent_change=$(CURRENT_VALUE="$FPS_VALUE" PREVIOUS_VALUE="$PREV" scripts/ci/percent-change) @@ -216,7 +216,7 @@ jobs: RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" for MESSAGE in \ "<${SCREENSHOT_URL:-$RUN_URL}|screenshot>" \ - "<${FPS_PLOT_URL:-$RUN_URL}|fps>" \ + "<${FPS_PLOT_URL:-$RUN_URL}|fps>" do scripts/ci/send-notification "$MESSAGE" done From da8134d39c986d14dee987bc9e0f4eecf6d7b5c6 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 8 May 2026 16:19:13 -0700 Subject: [PATCH 55/83] add url param option to demo and update ci plots --- .../__tests__/try_farmbot_test.tsx | 32 +++++++++- frontend/try_farmbot/try_farmbot.tsx | 8 ++- scripts/fps.js | 48 +++++++++----- scripts/fps_stress_resources.js | 62 +++++++++++++++++++ scripts/metric_plot.js | 59 ++++++++++++++---- 5 files changed, 178 insertions(+), 31 deletions(-) create mode 100644 scripts/fps_stress_resources.js diff --git a/frontend/try_farmbot/__tests__/try_farmbot_test.tsx b/frontend/try_farmbot/__tests__/try_farmbot_test.tsx index 65db47e1ac..b15d127c46 100644 --- a/frontend/try_farmbot/__tests__/try_farmbot_test.tsx +++ b/frontend/try_farmbot/__tests__/try_farmbot_test.tsx @@ -1,25 +1,34 @@ const mockMqttClient = { - on: jest.fn(), + on: jest.fn((ev: string, cb: Function) => ev == "connect" && cb()), subscribe: jest.fn(), }; const mockConnect = jest.fn(() => mockMqttClient); +const mockPost = jest.fn(() => Promise.resolve("OK")); import mqtt from "mqtt"; +import axios from "axios"; import React from "react"; -import { render } from "@testing-library/react"; -import { DEMO_LOADING, TryFarmbot } from "../try_farmbot"; +import { act, render } from "@testing-library/react"; +import { + DEMO_LOADING, getTryFarmbotProductLine, TryFarmbot, +} from "../try_farmbot"; let mqttConnectSpy: jest.SpyInstance; +let axiosPostSpy: jest.SpyInstance; beforeEach(() => { mqttConnectSpy = jest.spyOn(mqtt, "connect") .mockImplementation(mockConnect as never); + axiosPostSpy = jest.spyOn(axios, "post") + .mockImplementation(mockPost as never); jest.clearAllMocks(); }); afterEach(() => { + location.search = ""; mqttConnectSpy.mockRestore(); + axiosPostSpy.mockRestore(); }); describe("", () => { it("renders OK", () => { @@ -43,4 +52,21 @@ describe("", () => { const { container } = render(); expect(container.textContent?.toLowerCase()).toContain("loading"); }); + + it("gets a product line from the URL", () => { + location.search = "?productLine=express_xl_1.2"; + expect(getTryFarmbotProductLine()).toEqual("express_xl_1.2"); + }); + + it("requests an account for the provided product line", async () => { + location.search = "?productLine=express_xl_1.2"; + + render(); + await act(async () => { await Promise.resolve(); }); + + expect(mockPost).toHaveBeenCalledWith("/api/demo_account", { + secret: expect.any(String), + product_line: "express_xl_1.2", + }); + }); }); diff --git a/frontend/try_farmbot/try_farmbot.tsx b/frontend/try_farmbot/try_farmbot.tsx index 3173db43a2..f86878b294 100644 --- a/frontend/try_farmbot/try_farmbot.tsx +++ b/frontend/try_farmbot/try_farmbot.tsx @@ -1,11 +1,17 @@ import React from "react"; import { DemoIframe } from "../demo/demo_iframe"; +import { getUrlQuery } from "../util/urls"; export const DEMO_LOADING =

Loading...

; +export const getTryFarmbotProductLine = () => getUrlQuery("productLine"); + export class TryFarmbot extends DemoIframe { componentDidMount = () => { - this.requestAccount(); + const productLine = getTryFarmbotProductLine(); + productLine + ? this.setState({ productLine }, this.requestAccount) + : this.requestAccount(); }; render = () => { diff --git a/scripts/fps.js b/scripts/fps.js index a3d82f3640..1d8b12cab1 100644 --- a/scripts/fps.js +++ b/scripts/fps.js @@ -1,13 +1,13 @@ const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); +const { prepareStressResources } = require('./fps_stress_resources'); const url = process.argv[2]; const screenshotPath = process.argv[3] || '/tmp/fps.png'; const samplesCsvPath = process.argv[4] || '/tmp/fps_samples.csv'; const maxLoadingSamples = 240; const postLoadSamples = 10; -const chosenPostLoadSample = 5; const sampleIntervalMs = 1000; const ci = Boolean(process.env.CI); const openWindow = Boolean(process.env.DISPLAY && process.env.OPEN_WINDOW); @@ -65,12 +65,12 @@ const csvField = value => { function saveFpsSamplesCsv(samples, destination) { fs.mkdirSync(path.dirname(destination), { recursive: true }); const rows = [ - ['elapsed seconds', 'fps', 'loading', 'chosen'], + ['elapsed seconds', 'fps', 'loading', 'averaged'], ...samples.map(sample => [ Number(sample.elapsedSeconds).toFixed(3), Number(sample.fps).toFixed(2), sample.loading ? 'true' : 'false', - sample.chosen ? 'true' : 'false', + sample.averaged ? 'true' : 'false', ]), ]; fs.writeFileSync(destination, `${rows @@ -88,41 +88,54 @@ async function main() { page.setDefaultTimeout(120_000); try { await page.goto(url, { waitUntil: 'domcontentloaded' }); + await prepareStressResources(page, url); const renderer = await webglRenderer(page); console.log(`WEBGL_STATUS=${renderer.status}`); if (renderer.vendor) { console.log(`WEBGL_VENDOR=${renderer.vendor}`); } if (renderer.renderer) { console.log(`WEBGL_RENDERER=${renderer.renderer}`); } await page.waitForFunction(() => typeof window.__fps !== 'undefined'); - let lastSample = 0; + let averagePostLoadSample = 0; let loadingSampleCount = 0; let postLoadCount = 0; + const postLoadValues = []; const sampleValues = []; let loading = true; const maxSamples = maxLoadingSamples + postLoadSamples; const startMs = Date.now(); + let loadedSeen = false; for (let i = 0; i < maxSamples; i++) { const v = await page.evaluate(() => window.__fps); const n = Number(v); const elapsedSeconds = (Date.now() - startMs) / 1000; - loading = await pageIsLoading(page); - let chosen = false; + const pageLoading = await pageIsLoading(page); + if (pageLoading) { + loadedSeen = false; + loading = true; + } else { + loading = !loadedSeen; + loadedSeen = true; + } + let averaged = false; if (loading) { loadingSampleCount++; } else { postLoadCount++; - if (postLoadCount === chosenPostLoadSample) { - lastSample = n; - chosen = true; - } + postLoadValues.push(n); + averaged = true; } - const sample = { fps: n, elapsedSeconds, loading, chosen }; + const sample = { + fps: n, + elapsedSeconds, + loading, + averaged, + }; sampleValues.push(sample); const status = loading ? 'loading' : `loaded ${postLoadCount}/${postLoadSamples}`; - const chosenMarker = chosen ? ' <---' : ''; - console.log(`Sample ${i + 1} (${status}, ${elapsedSeconds.toFixed(2)}s): ${n.toFixed(2)}fps${chosenMarker}`); + const averagedMarker = averaged ? ' <---' : ''; + console.log(`Sample ${i + 1} (${status}, ${elapsedSeconds.toFixed(2)}s): ${n.toFixed(2)}fps${averagedMarker}`); if (postLoadCount >= postLoadSamples) { break; } if (loadingSampleCount >= maxLoadingSamples) { break; } await page.waitForTimeout(sampleIntervalMs); @@ -130,10 +143,13 @@ async function main() { if (loading) { throw new Error(`3D load did not finish after ${sampleValues.length} samples`); } - if (!Number.isFinite(lastSample)) { - throw new Error(`Post-load sample ${chosenPostLoadSample} was not a valid FPS value`); + averagePostLoadSample = + postLoadValues.reduce((total, value) => total + value, 0) + / postLoadValues.length; + if (!Number.isFinite(averagePostLoadSample)) { + throw new Error('Average post-load FPS was not a valid value'); } - console.log(`FPS_VALUE=${lastSample.toFixed(2)}`); + console.log(`FPS_VALUE=${averagePostLoadSample.toFixed(2)}`); const data = await page.evaluate(() => window.__scene_metrics); console.log(`SCENE_METRICS=${data}`); fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); diff --git a/scripts/fps_stress_resources.js b/scripts/fps_stress_resources.js new file mode 100644 index 0000000000..7d64deb95f --- /dev/null +++ b/scripts/fps_stress_resources.js @@ -0,0 +1,62 @@ +const stressResourceTimeoutMs = 180_000; +const stressResourceSettleMs = 5_000; + +const productLineFromUrl = value => new URL(value).searchParams.get('productLine'); + +const stressResourceCountFromProductLine = productLine => { + const match = productLine && productLine.match(/_stress_(\d+)$/); + return match ? Number(match[1]) : undefined; +}; + +const stressResourceCountFromUrl = value => + stressResourceCountFromProductLine(productLineFromUrl(value)); + +const isStressTryFarmbotUrl = value => + new URL(value).pathname.includes('/try_farmbot') + && Number.isFinite(stressResourceCountFromUrl(value)); + +const appUrlFor = value => new URL('/app/designer/plants', value).toString(); + +const waitForStressResources = async (page, stressResourceCount) => { + await page.waitForFunction(() => localStorage.getItem('session'), { + timeout: stressResourceTimeoutMs, + }); + await page.waitForFunction(async expected => { + const session = JSON.parse(localStorage.getItem('session')); + const headers = { Authorization: session.token.encoded }; + const json = path => fetch(path, { headers }).then(response => { + if (!response.ok) { throw new Error(`${response.status} ${path}`); } + return response.json(); + }); + const [points, images, sensorReadings] = await Promise.all([ + json('/api/points'), + json('/api/images'), + json('/api/sensor_readings'), + ]); + const plants = points.filter(point => point.pointer_type == 'Plant').length; + const weeds = points.filter(point => point.pointer_type == 'Weed').length; + const soilHeightPoints = points + .filter(point => point.name == 'Soil Height').length; + return plants >= expected + && weeds >= expected + && soilHeightPoints >= expected + && images.length >= expected + && sensorReadings.length >= expected; + }, stressResourceCount, { timeout: stressResourceTimeoutMs }); +}; + +const prepareStressResources = async (page, url) => { + if (!isStressTryFarmbotUrl(url)) { return; } + const stressResourceCount = stressResourceCountFromUrl(url); + console.log(`Waiting for ${stressResourceCount} stress resources...`); + await waitForStressResources(page, stressResourceCount); + console.log(`Stress resources found. Waiting ${stressResourceSettleMs}ms...`); + await page.waitForTimeout(stressResourceSettleMs); + const appUrl = appUrlFor(url); + console.log(`Stress resources loaded. Navigating to ${appUrl}`); + await page.goto(appUrl, { waitUntil: 'domcontentloaded' }); +}; + +module.exports = { + prepareStressResources, +}; diff --git a/scripts/metric_plot.js b/scripts/metric_plot.js index 7d9bc5d4e9..fc250ee76f 100644 --- a/scripts/metric_plot.js +++ b/scripts/metric_plot.js @@ -31,6 +31,7 @@ const normalizeMetricSamples = (samples, valueKey = 'value') => samples x, index, loading: typeof sample === 'object' ? sample.loading : undefined, + averaged: typeof sample === 'object' ? sample.averaged : undefined, }; }) .filter(({ value, x }) => Number.isFinite(value) && Number.isFinite(x)); @@ -66,17 +67,25 @@ function buildMetricPlotSvg(samples, options = {}) { const plotHeight = height - margin.top - margin.bottom; const finite = series.flatMap(item => item.samples); const values = finite.map(({ value }) => value); + const statSamples = options.statsAfterLoaded + ? finite.filter(({ loading }) => loading === false) + : finite; + const statValues = statSamples.map(({ value }) => value); const xValues = finite .map(({ x }) => x) .filter(value => Number.isFinite(value)); - const minSample = values.length ? Math.min(...values) : 0; - const maxSample = values.length ? Math.max(...values) : 0; - const avgSample = values.length - ? values.reduce((total, value) => total + value, 0) / values.length + const minSample = statValues.length ? Math.min(...statValues) : 0; + const maxSample = statValues.length ? Math.max(...statValues) : 0; + const avgSample = statValues.length + ? statValues.reduce((total, value) => total + value, 0) / statValues.length : 0; - const lastSample = values.length ? values[values.length - 1] : 0; - const minValue = values.length ? minSample : 0; - const maxValue = Math.max(1, maxSample); + const lastSample = statValues.length ? statValues[statValues.length - 1] : 0; + const yMinSample = values.length ? Math.min(...values) : 0; + const minValue = values.length ? yMinSample : 0; + const yMaxSample = values.length > 1 + ? [...values].sort((a, b) => b - a)[1] + : maxSample; + const maxValue = Math.max(1, yMaxSample); const valueRange = maxValue - minValue || 1; const minX = Math.min(0, xValues.length ? Math.min(...xValues) : 0); const maxX = Math.max(1, xValues.length ? Math.max(...xValues) : inputSamples.length - 1); @@ -84,7 +93,10 @@ function buildMetricPlotSvg(samples, options = {}) { const xFor = x => margin.left + ( (x - minX) / xRange ) * plotWidth; - const yFor = value => margin.top + ((maxValue - value) / valueRange) * plotHeight; + const yFor = value => { + const y = margin.top + ((maxValue - value) / valueRange) * plotHeight; + return Math.max(margin.top, Math.min(height - margin.bottom, y)); + }; const lines = series .map(item => { const points = item.samples @@ -135,9 +147,21 @@ function buildMetricPlotSvg(samples, options = {}) { `Loaded`, ].join('') : ''; - const stats = escapeSvgText(values.length - ? `min ${formatStat(minSample)} avg ${formatStat(avgSample)} max ${formatStat(maxSample)} last ${formatStat(lastSample)}` - : 'No valid samples'); + const averageValue = Number(options.averageValue); + const averageLine = Number.isFinite(averageValue) + ? [ + ``, + `avg ${formatStat(averageValue)}`, + ].join('') + : ''; + const displayedAverage = Number.isFinite(averageValue) + ? averageValue + : avgSample; + const stats = options.hideStats + ? '' + : escapeSvgText(statValues.length + ? `min ${formatStat(minSample)} avg ${formatStat(displayedAverage)} max ${formatStat(maxSample)} last ${formatStat(lastSample)}` + : 'No valid samples'); const legend = multiSeries ? series .map((item, index) => { @@ -168,6 +192,7 @@ function buildMetricPlotSvg(samples, options = {}) { ${xTicks} ${loadedMarker} + ${averageLine} ${lines} ${circles} ${xLabel} @@ -242,6 +267,7 @@ const inferCsvPlot = ({ headers, rows }, filename = '') => { x: xHeader ? row[xHeader] : index, value: valueFor(row, valueHeader), loading: row.loading === undefined ? undefined : row.loading === 'true', + averaged: row.averaged === undefined ? undefined : row.averaged === 'true', }); const plottableHeaders = headers .filter(header => !sceneMetricExcludedHeaders.includes(header)) @@ -259,6 +285,7 @@ const inferCsvPlot = ({ headers, rows }, filename = '') => { return { title: 'Scene metrics', xLabel: 'Runs', + hideStats: true, series: plottableHeaders.map(header => ({ name: sceneMetricLabelFor(header), samples: rows.map((row, index) => ({ @@ -275,11 +302,21 @@ const inferCsvPlot = ({ headers, rows }, filename = '') => { }; } if (headers.includes('fps')) { + const averagedValues = rows + .filter(row => row.averaged === 'true') + .map(row => Number(row.fps)) + .filter(Number.isFinite); + const averageValue = averagedValues.length + ? averagedValues.reduce((total, value) => total + value, 0) + / averagedValues.length + : undefined; const highlightIndex = rows.findIndex(row => row.chosen === 'true'); return { title: 'FPS samples', xLabel: xHeader ? 'Seconds' : 'Samples', samples: rows.map((row, index) => sampleFor(row, index, 'fps')), + statsAfterLoaded: true, + ...(Number.isFinite(averageValue) ? { averageValue } : {}), ...(highlightIndex >= 0 ? { highlightIndex } : {}), }; } From e81a48feb07e2573b6bbcd248d0ddf82872a461a Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 13 May 2026 15:16:22 -0700 Subject: [PATCH 56/83] render multiple urls in ci --- .github/workflows/render.yml | 70 +++++++++++++--------------- .github/workflows/test.yml | 4 +- scripts/ci/render-env | 4 ++ scripts/ci/run-playwright-render | 78 ++++++++++++++++++++++++++++++++ scripts/fps.js | 2 +- scripts/metric_plot.js | 3 +- 6 files changed, 119 insertions(+), 42 deletions(-) create mode 100644 scripts/ci/render-env create mode 100755 scripts/ci/run-playwright-render diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml index d5bc301a3f..fc9ed02a72 100644 --- a/.github/workflows/render.yml +++ b/.github/workflows/render.yml @@ -9,8 +9,6 @@ on: env: NOTIFY: ${{ github.ref_name == 'staging' }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - PROMO_IMAGE: promo - FPS_SAMPLES_NAME: fps_samples SCENE_METRICS_NAME: scene_metrics FE_COVERAGE_NAME: fe_coverage CACHE_KEY_SUFFIX: ${{ github.run_id }}-${{ github.run_attempt }} @@ -28,6 +26,14 @@ jobs: - name: Set up CI uses: ./.github/actions/setup-ci + - name: Set render service hosts + run: | + runner_ip=$(ip route get 1.1.1.1 | awk '{for (i=1; i<=NF; i++) if ($i == "src") {print $(i+1); exit}}') + echo "Runner IP: $runner_ip" + sed -i "s/^API_HOST=.*/API_HOST=$runner_ip/" .env + sed -i "s/^MQTT_HOST=.*/MQTT_HOST=$runner_ip/" .env + sudo docker compose run --rm web bundle exec rails runner RmqConfigWriter.render + - name: Start services run: | sudo docker compose up -d @@ -63,38 +69,28 @@ jobs: echo "web did not become ready" exit 1 - - name: Run playwright + - name: Wait for MQTT auth run: | - attempts=2 - for _ in $(seq 1 "$attempts"); do - url="http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" - echo "Attempting FPS check via ${url}" - if ! sudo docker compose exec -T web curl -I "${url}" >/dev/null; then - continue - fi - - if ! fps_output=$(bun scripts/fps.js "${url}" "/tmp/$PROMO_IMAGE.png" "/tmp/$FPS_SAMPLES_NAME.csv"); then - echo "${fps_output}" - continue + admin_password=$(awk -F= '/^ADMIN_PASSWORD=/{print $2; exit}' .env) + for i in $(seq 1 90); do + if timeout 5s sudo docker compose exec -T mqtt rabbitmqctl authenticate_user admin "$admin_password" >/dev/null; then + echo "mqtt auth is up" + exit 0 fi - echo "${fps_output}" - - fps_value=$(echo "${fps_output}" | awk -F= '/^FPS_VALUE=/{print $2; exit}') - scene_metrics=$(echo "${fps_output}" | awk -F= '/^SCENE_METRICS=/{print $2; exit}') - echo "FPS_VALUE=${fps_value}" >> "$GITHUB_ENV" - echo "SCENE_METRICS=${scene_metrics}" >> "$GITHUB_ENV" - - exit 0 + sleep 2 done - echo "FPS check failed" + echo "mqtt auth did not become ready" exit 1 + - name: Run playwright + run: scripts/ci/run-playwright-render + - name: Restore FPS metrics uses: actions/cache/restore@v5 with: path: | fps_value.txt - /tmp/${{ env.SCENE_METRICS_NAME }}.csv + /tmp/${{ env.SCENE_METRICS_NAME }}*.csv key: ${{ env.FPS_METRICS_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} restore-keys: | ${{ env.FPS_METRICS_CACHE_KEY }}- @@ -111,12 +107,6 @@ jobs: echo "$FPS_VALUE fps ($percent_change% change)" echo "PERCENT_CHANGE=$percent_change" >> "$GITHUB_ENV" echo "$FPS_VALUE" > fps_value.txt - scene_metrics_csv="/tmp/$SCENE_METRICS_NAME.csv" - if [ ! -f "$scene_metrics_csv" ]; then - printf '%s\n' "epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes" > "$scene_metrics_csv" - fi - printf '%s\n' "$SCENE_METRICS" >> "$scene_metrics_csv" - cat "$scene_metrics_csv" - name: Restore frontend coverage value if: always() @@ -128,9 +118,11 @@ jobs: ${{ env.FE_COVERAGE_CACHE_KEY }}- - name: Plot metric CSVs + if: always() run: | - for metric in "$FPS_SAMPLES_NAME" "$SCENE_METRICS_NAME"; do - bun scripts/metric_plot.js "/tmp/$metric.csv" + for csv in /tmp/*.csv; do + [ -f "$csv" ] || continue + bun scripts/metric_plot.js "$csv" done if [ -f "$FE_COVERAGE_NAME.csv" ]; then bun scripts/metric_plot.js "$FE_COVERAGE_NAME.csv" @@ -142,10 +134,11 @@ jobs: with: path: | fps_value.txt - /tmp/${{ env.SCENE_METRICS_NAME }}.csv + /tmp/${{ env.SCENE_METRICS_NAME }}*.csv key: ${{ env.FPS_METRICS_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} - name: View values + if: always() run: | echo $FPS_VALUE echo $SCENE_METRICS @@ -155,13 +148,12 @@ jobs: run: scripts/ci/send-notification "${FPS_VALUE} fps (${PERCENT_CHANGE}% change)" - name: Upload image artifacts + if: always() uses: actions/upload-artifact@v7 with: name: images path: | - /tmp/${{ env.PROMO_IMAGE }}.png - /tmp/${{ env.FPS_SAMPLES_NAME }}.png - /tmp/${{ env.SCENE_METRICS_NAME }}.png + /tmp/*.png /tmp/${{ env.FE_COVERAGE_NAME }}.png if-no-files-found: warn @@ -171,8 +163,7 @@ jobs: with: name: scene-metrics-csv path: | - /tmp/${{ env.FPS_SAMPLES_NAME }}.csv - /tmp/${{ env.SCENE_METRICS_NAME }}.csv + /tmp/*.csv if-no-files-found: warn - name: On failure @@ -186,6 +177,7 @@ jobs: env: ARTIFACT_BRANCH: ci-artifacts run: | + . scripts/ci/render-env worktree=$(mktemp -d) git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" @@ -202,6 +194,8 @@ jobs: cp "/tmp/$image.png" "$worktree/build/latest/$image.png" cp "/tmp/$image.png" "$worktree/build/${GITHUB_RUN_ID}/$image.png" done + cp "/tmp/$FPS_SAMPLES_NAME.csv" "$worktree/build/latest/$FPS_SAMPLES_NAME.csv" + cp "/tmp/$FPS_SAMPLES_NAME.csv" "$worktree/build/${GITHUB_RUN_ID}/$FPS_SAMPLES_NAME.csv" git -C "$worktree" add build git -C "$worktree" commit -m "Update CI image artifacts for ${GITHUB_RUN_ID}" || true git -C "$worktree" push origin "HEAD:$ARTIFACT_BRANCH" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a62c36bd3..44ade5acae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: run: sudo docker compose run --rm web bundle exec rspec spec --format progress --format RspecJunitFormatter --out test-results/rspec.xml - name: Check app coverage status - if: always() + if: ${{ always() && hashFiles('coverage_api/index.html') != '' }} run: sudo docker compose run --rm web bundle exec rake check_file_coverage:api || [ "$NOTIFY" = "true" ] - name: Upload app coverage to Codecov @@ -102,7 +102,7 @@ jobs: exit $status - name: Check frontend coverage status - if: always() + if: ${{ always() && (hashFiles('coverage_fe/index.html') != '' || hashFiles('coverage_fe/coverage-final.json') != '' || hashFiles('coverage_fe/lcov.info') != '') }} env: GITHUB_PULL_REQUEST: ${{ github.event.pull_request.html_url || '/0' }} run: sudo docker compose run --rm -e GITHUB_SHA="$GITHUB_SHA" -e GITHUB_REF_NAME="$GITHUB_REF_NAME" -e GITHUB_PULL_REQUEST="$GITHUB_PULL_REQUEST" web bundle exec rake coverage:run || [ "$NOTIFY" = "true" ] diff --git a/scripts/ci/render-env b/scripts/ci/render-env new file mode 100644 index 0000000000..922d1ccd26 --- /dev/null +++ b/scripts/ci/render-env @@ -0,0 +1,4 @@ +: "${PROMO_IMAGE:=promo}" +: "${FPS_SAMPLES_NAME:=fps_samples}" +: "${SCENE_METRICS_NAME:=scene_metrics}" +: "${SCENE_METRICS_HEADER:=epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes}" diff --git a/scripts/ci/run-playwright-render b/scripts/ci/run-playwright-render new file mode 100755 index 0000000000..6120606f6f --- /dev/null +++ b/scripts/ci/run-playwright-render @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -f scripts/ci/render-env ]; then + set -a + # shellcheck disable=SC1091 + . scripts/ci/render-env + set +a +fi + +if [ -z "${GITHUB_ACTIONS:-}" ]; then + export OPEN_WINDOW="${OPEN_WINDOW:-1}" +fi + +attempts="${ATTEMPTS:-2}" +: "${PROMO_IMAGE:?PROMO_IMAGE is required}" +: "${FPS_SAMPLES_NAME:?FPS_SAMPLES_NAME is required}" +: "${SCENE_METRICS_NAME:?SCENE_METRICS_NAME is required}" +: "${SCENE_METRICS_HEADER:?SCENE_METRICS_HEADER is required}" +GITHUB_ENV="${GITHUB_ENV:-/tmp/render-github-env}" + +urls=( + "promo|http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" + "app|http://localhost:3000/try_farmbot?productLine=genesis_1.8" + # "stress|http://localhost:3000/try_farmbot?productLine=genesis_xl_1.8_stress_250" +) + +for entry in "${urls[@]}"; do + name="${entry%%|*}" + url="${entry#*|}" + if [ "$name" = "promo" ]; then + screenshot_path="/tmp/$PROMO_IMAGE.png" + fps_samples_path="/tmp/$FPS_SAMPLES_NAME.csv" + scene_metrics_path="/tmp/$SCENE_METRICS_NAME.csv" + else + screenshot_path="/tmp/${PROMO_IMAGE}_${name}.png" + fps_samples_path="/tmp/${FPS_SAMPLES_NAME}_${name}.csv" + scene_metrics_path="/tmp/${SCENE_METRICS_NAME}_${name}.csv" + fi + + for _ in $(seq 1 "$attempts"); do + echo "Attempting FPS check via ${url}" + if ! sudo docker compose exec -T web curl -I "${url}" >/dev/null; then + continue + fi + + if ! fps_output=$(bun scripts/fps.js "${url}" "$screenshot_path" "$fps_samples_path"); then + echo "${fps_output}" + continue + fi + echo "${fps_output}" + + fps_value=$(echo "${fps_output}" | awk -F= '/^FPS_VALUE=/{print $2; exit}') + scene_metrics=$(echo "${fps_output}" | awk -F= '/^SCENE_METRICS=/{print $2; exit}') + if [ ! -f "$scene_metrics_path" ]; then + printf '%s\n' "$SCENE_METRICS_HEADER" > "$scene_metrics_path" + fi + printf '%s\n' "$scene_metrics" >> "$scene_metrics_path" + echo $scene_metrics_path + + if [ "$name" = "promo" ]; then + echo "FPS_VALUE=${fps_value}" >> "$GITHUB_ENV" + echo "SCENE_METRICS=${scene_metrics}" >> "$GITHUB_ENV" + fi + + continue 2 + done + + echo "FPS check failed for ${url}" + exit 1 +done + +if [ -z "${GITHUB_ACTIONS:-}" ]; then + for csv in /tmp/*.csv; do + [ -f "$csv" ] || continue + bun scripts/metric_plot.js "$csv" + done +fi diff --git a/scripts/fps.js b/scripts/fps.js index 1d8b12cab1..4bde083565 100644 --- a/scripts/fps.js +++ b/scripts/fps.js @@ -85,7 +85,7 @@ async function main() { args: chromiumArgs, }); const page = await browser.newPage(); - page.setDefaultTimeout(120_000); + page.setDefaultTimeout(60_000); try { await page.goto(url, { waitUntil: 'domcontentloaded' }); await prepareStressResources(page, url); diff --git a/scripts/metric_plot.js b/scripts/metric_plot.js index fc250ee76f..f4214a9786 100644 --- a/scripts/metric_plot.js +++ b/scripts/metric_plot.js @@ -249,6 +249,7 @@ const parseCsv = content => { const inferCsvPlot = ({ headers, rows }, filename = '') => { const basename = path.basename(filename); + const isSceneMetricsCsv = /^scene_metrics(?:_[^/]+)?\.csv$/.test(basename); const firstHeader = headers[0]; const xHeader = headers.find(header => ['elapsed seconds', 'elapsedSeconds'].includes(header)); @@ -281,7 +282,7 @@ const inferCsvPlot = ({ headers, rows }, filename = '') => { }; } if (headers.includes('FPS')) { - if (basename === 'scene_metrics.csv') { + if (isSceneMetricsCsv) { return { title: 'Scene metrics', xLabel: 'Runs', From 148d17e69c9f57c4d588672c63382946f1d06ee1 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 13 May 2026 18:47:30 -0700 Subject: [PATCH 57/83] upgrade deps --- Gemfile.lock | 8 +- app/controllers/api/alerts_controller.rb | 2 +- app/controllers/api/curves_controller.rb | 2 +- app/controllers/api/farm_events_controller.rb | 4 +- .../api/farmware_envs_controller.rb | 2 +- .../api/farmware_installations_controller.rb | 2 +- app/controllers/api/folders_controller.rb | 2 +- app/controllers/api/images_controller.rb | 2 +- app/controllers/api/logs_controller.rb | 2 +- app/controllers/api/peripherals_controller.rb | 2 +- .../api/pin_bindings_controller.rb | 2 +- .../api/plant_templates_controller.rb | 2 +- .../api/point_groups_controller.rb | 2 +- app/controllers/api/points_controller.rb | 6 +- app/controllers/api/regimens_controller.rb | 2 +- .../api/saved_gardens_controller.rb | 2 +- .../api/sensor_readings_controller.rb | 2 +- app/controllers/api/sensors_controller.rb | 2 +- .../api/sequence_versions_controller.rb | 2 +- app/controllers/api/sequences_controller.rb | 4 +- app/controllers/api/telemetries_controller.rb | 2 +- app/controllers/api/tools_controller.rb | 2 +- .../api/webcam_feeds_controller.rb | 2 +- .../api/wizard_step_results_controller.rb | 2 +- bun.lock | 328 +++++++++++------- package.json | 22 +- 26 files changed, 242 insertions(+), 170 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 01335685fc..28d9b27b99 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.18) + action_text-trix (2.1.19) railties actioncable (8.1.3) actionpack (= 8.1.3) @@ -119,7 +119,7 @@ GEM delayed_job_active_record (4.1.11) activerecord (>= 3.0, < 9.0) delayed_job (>= 3.0, < 5) - devise (5.0.3) + devise (5.0.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 7.0) @@ -171,7 +171,7 @@ GEM base64 (~> 0.2) faraday (>= 1.0, < 3.a) google-cloud-errors (1.6.0) - google-cloud-storage (1.59.0) + google-cloud-storage (1.60.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (>= 0.18, < 2) @@ -397,7 +397,7 @@ GEM rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) - rubocop-rails (2.34.3) + rubocop-rails (2.35.0) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) diff --git a/app/controllers/api/alerts_controller.rb b/app/controllers/api/alerts_controller.rb index 41298b2a23..9fbd2cbee0 100644 --- a/app/controllers/api/alerts_controller.rb +++ b/app/controllers/api/alerts_controller.rb @@ -11,7 +11,7 @@ def destroy private def alert - @alert ||= current_device.alerts.find(params[:id]) + @alert ||= current_device.alerts.find(params.expect(:id)) end end end diff --git a/app/controllers/api/curves_controller.rb b/app/controllers/api/curves_controller.rb index 9e8ea776b0..9fe6d36ea9 100644 --- a/app/controllers/api/curves_controller.rb +++ b/app/controllers/api/curves_controller.rb @@ -23,7 +23,7 @@ def destroy private def curve - @curve ||= current_device.curves.find(params[:id]) + @curve ||= current_device.curves.find(params.expect(:id)) end end end diff --git a/app/controllers/api/farm_events_controller.rb b/app/controllers/api/farm_events_controller.rb index 280efc2f3a..6d045e4c48 100644 --- a/app/controllers/api/farm_events_controller.rb +++ b/app/controllers/api/farm_events_controller.rb @@ -7,7 +7,7 @@ def index end def show - render json: current_device.farm_events.find(params[:id]) + render json: current_device.farm_events.find(params.expect(:id)) end def create @@ -31,7 +31,7 @@ def destroy private def farm_event - @farm_event ||= FarmEvent.find(params[:id]) + @farm_event ||= FarmEvent.find(params.expect(:id)) end end end diff --git a/app/controllers/api/farmware_envs_controller.rb b/app/controllers/api/farmware_envs_controller.rb index d26c394488..d2f0c48d7b 100644 --- a/app/controllers/api/farmware_envs_controller.rb +++ b/app/controllers/api/farmware_envs_controller.rb @@ -31,7 +31,7 @@ def destroy private def farmware_env - farmware_envs.find(params[:id]) + farmware_envs.find(params.expect(:id)) end def farmware_envs diff --git a/app/controllers/api/farmware_installations_controller.rb b/app/controllers/api/farmware_installations_controller.rb index ef511f04e0..9542a0c513 100644 --- a/app/controllers/api/farmware_installations_controller.rb +++ b/app/controllers/api/farmware_installations_controller.rb @@ -31,7 +31,7 @@ def farmware_installations end def farmware_installation - @farmware_installation ||= farmware_installations.find(params[:id]) + @farmware_installation ||= farmware_installations.find(params.expect(:id)) end end end diff --git a/app/controllers/api/folders_controller.rb b/app/controllers/api/folders_controller.rb index 68756d9235..61f2b4f4ea 100644 --- a/app/controllers/api/folders_controller.rb +++ b/app/controllers/api/folders_controller.rb @@ -25,7 +25,7 @@ def destroy private def folder - folders.find(params[:id]) + folders.find(params.expect(:id)) end def folders diff --git a/app/controllers/api/images_controller.rb b/app/controllers/api/images_controller.rb index 209f7b10f6..72a0d36c92 100644 --- a/app/controllers/api/images_controller.rb +++ b/app/controllers/api/images_controller.rb @@ -44,7 +44,7 @@ def policy_class end def image - @image ||= Image.where(device: current_device).find(params[:id]) + @image ||= Image.where(device: current_device).find(params.expect(:id)) end end end diff --git a/app/controllers/api/logs_controller.rb b/app/controllers/api/logs_controller.rb index c54a568a02..fcd0227bfc 100644 --- a/app/controllers/api/logs_controller.rb +++ b/app/controllers/api/logs_controller.rb @@ -49,7 +49,7 @@ def destroy if params[:id] == "all" render json: (current_device.logs.destroy_all && "") else - render json: (current_device.logs.find(params[:id]).destroy! && "") + render json: (current_device.logs.find(params.expect(:id)).destroy! && "") end end diff --git a/app/controllers/api/peripherals_controller.rb b/app/controllers/api/peripherals_controller.rb index d9ed7e98e9..f7b2730848 100644 --- a/app/controllers/api/peripherals_controller.rb +++ b/app/controllers/api/peripherals_controller.rb @@ -25,7 +25,7 @@ def destroy private def peripheral - @peripheral ||= current_device.peripherals.find(params[:id]) + @peripheral ||= current_device.peripherals.find(params.expect(:id)) end end end diff --git a/app/controllers/api/pin_bindings_controller.rb b/app/controllers/api/pin_bindings_controller.rb index 79d630e036..b6e24985de 100644 --- a/app/controllers/api/pin_bindings_controller.rb +++ b/app/controllers/api/pin_bindings_controller.rb @@ -31,7 +31,7 @@ def pin_bindings end def pin_binding - @pin_binding ||= pin_bindings.find(params[:id]) + @pin_binding ||= pin_bindings.find(params.expect(:id)) end end end diff --git a/app/controllers/api/plant_templates_controller.rb b/app/controllers/api/plant_templates_controller.rb index 0618edcc9a..2991139e38 100644 --- a/app/controllers/api/plant_templates_controller.rb +++ b/app/controllers/api/plant_templates_controller.rb @@ -25,7 +25,7 @@ def plant_templates end def plant_template - @plant_template ||= plant_templates.find(params[:id]) + @plant_template ||= plant_templates.find(params.expect(:id)) end end end diff --git a/app/controllers/api/point_groups_controller.rb b/app/controllers/api/point_groups_controller.rb index bd45be617a..24e2987e9d 100644 --- a/app/controllers/api/point_groups_controller.rb +++ b/app/controllers/api/point_groups_controller.rb @@ -25,7 +25,7 @@ def destroy private def the_point_group - your_point_groups.find(params[:id]) + your_point_groups.find(params.expect(:id)) end def your_point_groups diff --git a/app/controllers/api/points_controller.rb b/app/controllers/api/points_controller.rb index 9e785c5c9d..4097271e6d 100644 --- a/app/controllers/api/points_controller.rb +++ b/app/controllers/api/points_controller.rb @@ -21,7 +21,7 @@ def index end def show - render json: points.find(params[:id]) + render json: points.find(params.expect(:id)) end def search @@ -39,14 +39,14 @@ def update def destroy # TODO: We don't need to do batch requests like this any more. # This should be removed when possible. -RC 1 AUG 2018 - ids = params[:id].to_s.split(",").map(&:to_i) + ids = params.expect(:id).to_s.split(",").map(&:to_i) mutate Points::Destroy.run({ point_ids: ids }, device_params) end private def point - @point ||= points.find(params[:id]) + @point ||= points.find(params.expect(:id)) end def points(filter = params[:filter]) diff --git a/app/controllers/api/regimens_controller.rb b/app/controllers/api/regimens_controller.rb index 2f3405781a..ac589029ee 100644 --- a/app/controllers/api/regimens_controller.rb +++ b/app/controllers/api/regimens_controller.rb @@ -25,7 +25,7 @@ def destroy private def the_regimen - your_regimens.find(params[:id]) + your_regimens.find(params.expect(:id)) end def your_regimens diff --git a/app/controllers/api/saved_gardens_controller.rb b/app/controllers/api/saved_gardens_controller.rb index d0c77894a6..813f2e53d1 100644 --- a/app/controllers/api/saved_gardens_controller.rb +++ b/app/controllers/api/saved_gardens_controller.rb @@ -36,7 +36,7 @@ def gardens end def garden - @garden ||= gardens.find(params[:id]) + @garden ||= gardens.find(params.expect(:id)) end end end diff --git a/app/controllers/api/sensor_readings_controller.rb b/app/controllers/api/sensor_readings_controller.rb index 7e6f050e40..645cf6c8dd 100644 --- a/app/controllers/api/sensor_readings_controller.rb +++ b/app/controllers/api/sensor_readings_controller.rb @@ -33,7 +33,7 @@ def readings end def reading - @reading ||= readings.find(params[:id]) + @reading ||= readings.find(params.expect(:id)) end end end diff --git a/app/controllers/api/sensors_controller.rb b/app/controllers/api/sensors_controller.rb index aff19605e0..7d401a1dd7 100644 --- a/app/controllers/api/sensors_controller.rb +++ b/app/controllers/api/sensors_controller.rb @@ -23,7 +23,7 @@ def destroy private def sensor - @sensor ||= current_device.sensors.find(params[:id]) + @sensor ||= current_device.sensors.find(params.expect(:id)) end end end diff --git a/app/controllers/api/sequence_versions_controller.rb b/app/controllers/api/sequence_versions_controller.rb index e9d0ffdc23..5b91b6b03e 100644 --- a/app/controllers/api/sequence_versions_controller.rb +++ b/app/controllers/api/sequence_versions_controller.rb @@ -24,7 +24,7 @@ def version_meta end def sequence_version - @sequence_version ||= SequenceVersion.find(params[:id]) + @sequence_version ||= SequenceVersion.find(params.expect(:id)) end end end diff --git a/app/controllers/api/sequences_controller.rb b/app/controllers/api/sequences_controller.rb index bb147cbadd..bd9e46ee5c 100644 --- a/app/controllers/api/sequences_controller.rb +++ b/app/controllers/api/sequences_controller.rb @@ -75,7 +75,7 @@ def upgrade private def sequence_version - @sequence_version ||= SequenceVersion.find(params[:sequence_version_id]) + @sequence_version ||= SequenceVersion.find(params.expect(:sequence_version_id)) end def sequence_params @@ -84,7 +84,7 @@ def sequence_params # Retrieve a single sequence record directly associated with the current device def sequence - @sequence ||= Sequence.with_usage_reports.find_by!(id: params[:id], device: current_device) + @sequence ||= Sequence.with_usage_reports.find_by!(id: params.expect(:id), device: current_device) end end end diff --git a/app/controllers/api/telemetries_controller.rb b/app/controllers/api/telemetries_controller.rb index b774ab6535..154ec7f8a2 100644 --- a/app/controllers/api/telemetries_controller.rb +++ b/app/controllers/api/telemetries_controller.rb @@ -14,7 +14,7 @@ def destroy if params[:id] == "all" render json: (current_device.telemetries.destroy_all && "") else - render json: (current_device.telemetries.find(params[:id]).destroy! && "") + render json: (current_device.telemetries.find(params.expect(:id)).destroy! && "") end end diff --git a/app/controllers/api/tools_controller.rb b/app/controllers/api/tools_controller.rb index b7812759f1..4abd130b38 100644 --- a/app/controllers/api/tools_controller.rb +++ b/app/controllers/api/tools_controller.rb @@ -33,7 +33,7 @@ def tools end def tool - @tool ||= Tool.join_tool_slot_and_find_by_id(params[:id].to_i) + @tool ||= Tool.join_tool_slot_and_find_by_id(params.expect(:id).to_i) end end end diff --git a/app/controllers/api/webcam_feeds_controller.rb b/app/controllers/api/webcam_feeds_controller.rb index 82ae43f80a..ee20954cf1 100644 --- a/app/controllers/api/webcam_feeds_controller.rb +++ b/app/controllers/api/webcam_feeds_controller.rb @@ -25,7 +25,7 @@ def destroy private def webcam - webcams.find(params[:id]) + webcams.find(params.expect(:id)) end def webcams diff --git a/app/controllers/api/wizard_step_results_controller.rb b/app/controllers/api/wizard_step_results_controller.rb index 8f752bb31c..0b5a77a9f1 100644 --- a/app/controllers/api/wizard_step_results_controller.rb +++ b/app/controllers/api/wizard_step_results_controller.rb @@ -25,7 +25,7 @@ def update_params end def wizard_step_result - @wizard_step_result ||= wizard_step_results.find(params[:id]) + @wizard_step_result ||= wizard_step_results.find(params.expect(:id)) end def wizard_step_results diff --git a/bun.lock b/bun.lock index 1336c494a5..0f1d5edb75 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,14 @@ "": { "name": "farmbot-web-frontend", "dependencies": { - "@blueprintjs/core": "6.12.1", - "@blueprintjs/select": "6.1.10", + "@blueprintjs/core": "6.13.0", + "@blueprintjs/select": "6.1.11", "@monaco-editor/react": "4.7.0", "@react-spring/three": "10.0.3", "@react-three/drei": "10.7.7", "@react-three/fiber": "9.6.1", "@rollbar/react": "1.0.0", - "@types/bun": "1.3.13", + "@types/bun": "1.3.14", "@types/lodash": "4.17.24", "@types/markdown-it": "14.1.2", "@types/markdown-it-emoji": "3.0.1", @@ -25,7 +25,7 @@ "@types/three": "0.184.1", "@types/ws": "8.18.1", "@xterm/xterm": "6.0.0", - "axios": "1.16.0", + "axios": "1.16.1", "bowser": "2.14.1", "browser-speech": "1.1.1", "delaunator": "5.1.0", @@ -33,7 +33,7 @@ "farmbot": "15.9.3", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.0.10", + "i18next": "26.1.0", "lodash": "4.18.1", "markdown-it": "14.1.1", "markdown-it-emoji": "3.0.0", @@ -71,8 +71,8 @@ "@types/jest": "30.0.0", "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", - "@typescript-eslint/eslint-plugin": "8.59.2", - "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", "eslint": "10.3.0", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", @@ -82,16 +82,16 @@ "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", "happy-dom": "20.9.0", - "jest": "30.3.0", + "jest": "30.4.2", "jest-canvas-mock": "2.5.2", - "jest-cli": "30.3.0", - "jest-environment-jsdom": "30.3.0", + "jest-cli": "30.4.2", + "jest-environment-jsdom": "30.4.1", "jest-junit": "17.0.0", "jest-skipped-reporter": "0.0.5", "jshint": "2.13.6", "madge": "8.0.0", "path-browserify": "1.0.1", - "playwright": "1.59.1", + "playwright": "1.60.0", "postcss": "8.5.14", "postcss-scss": "4.0.9", "raf": "3.4.1", @@ -186,11 +186,11 @@ "@blueprintjs/colors": ["@blueprintjs/colors@5.1.16", "", { "dependencies": { "tslib": "~2.6.2" } }, "sha512-P9uX0Aj2TP9+6aUcori1iPl4snxM/Vgq0LZbhUl1l5bHTgNxxwm/0+IoS/SlQg93HBRl8KTAM1evEqtPbwV10A=="], - "@blueprintjs/core": ["@blueprintjs/core@6.12.1", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/icons": "^6.9.1", "@floating-ui/react": "^0.27.13", "@popperjs/core": "^2.11.8", "classnames": "^2.3.1", "normalize.css": "^8.0.1", "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"], "bin": { "upgrade-blueprint-2.0.0-rename": "scripts/upgrade-blueprint-2.0.0-rename.sh", "upgrade-blueprint-3.0.0-rename": "scripts/upgrade-blueprint-3.0.0-rename.sh" } }, "sha512-HVWCbNjW6MLFrcWBGOMMsbn+Q4AqddDixCkNjXnO2Hjh7CQ/y27pPuZuEEIs0c90ADgLZaIL0d78B0RofFX1OQ=="], + "@blueprintjs/core": ["@blueprintjs/core@6.13.0", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/icons": "^6.10.0", "@floating-ui/react": "^0.27.13", "@popperjs/core": "^2.11.8", "classnames": "^2.3.1", "normalize.css": "^8.0.1", "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"], "bin": { "upgrade-blueprint-2.0.0-rename": "scripts/upgrade-blueprint-2.0.0-rename.sh", "upgrade-blueprint-3.0.0-rename": "scripts/upgrade-blueprint-3.0.0-rename.sh" } }, "sha512-2gKXoe7ve0X1zSK0Svqmp6rwrLXvvN1QghByVHksTuTXwBisJahBgzcbeG8h9eg7VAXbUXYE1vvlUlQdn8LWvw=="], - "@blueprintjs/icons": ["@blueprintjs/icons@6.9.1", "", { "dependencies": { "change-case": "^4.1.2", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-Zy4SY0jDaUoDJ9nMUM5PVaAuZx1ViQbEgaXElySb4MOdhxJMM97OY2YKL6sdJspZOsPPsuBG2DABd81JU3GSdA=="], + "@blueprintjs/icons": ["@blueprintjs/icons@6.10.0", "", { "dependencies": { "change-case": "^4.1.2", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-KjNviCpxjJOcIVgNUOlw2FCGp8T5o0SZW56alL6veDHyHnMP48jaW8cVCRvKvZqGmUL+d9faLis1CJzI20E+kg=="], - "@blueprintjs/select": ["@blueprintjs/select@6.1.10", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/core": "^6.12.1", "@blueprintjs/icons": "^6.9.1", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-QWvehtSZEgOboLCeC/UTvcNI3o7roRC3cMIJ9tXXxKcwRygzvDVAWL8AshKSrHW3Vpcl0/6koya1Yi+GO70vYg=="], + "@blueprintjs/select": ["@blueprintjs/select@6.1.11", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/core": "^6.13.0", "@blueprintjs/icons": "^6.10.0", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-Zf32IiagGYFHzfp2N3qIqF+6dV4K8Lk0XzVDSnGGLhYdOChpzOp0K9aisfXHbqtDjDtH25d9UvqlVqp7TmT8wQ=="], "@cacheable/memory": ["@cacheable/memory@2.0.8", "", { "dependencies": { "@cacheable/utils": "^2.4.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw=="], @@ -274,43 +274,43 @@ "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], - "@jest/console": ["@jest/console@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "jest-message-util": "30.3.0", "jest-util": "30.3.0", "slash": "^3.0.0" } }, "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww=="], + "@jest/console": ["@jest/console@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "jest-message-util": "30.4.1", "jest-util": "30.4.1", "slash": "^3.0.0" } }, "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA=="], - "@jest/core": ["@jest/core@30.3.0", "", { "dependencies": { "@jest/console": "30.3.0", "@jest/pattern": "30.0.1", "@jest/reporters": "30.3.0", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-changed-files": "30.3.0", "jest-config": "30.3.0", "jest-haste-map": "30.3.0", "jest-message-util": "30.3.0", "jest-regex-util": "30.0.1", "jest-resolve": "30.3.0", "jest-resolve-dependencies": "30.3.0", "jest-runner": "30.3.0", "jest-runtime": "30.3.0", "jest-snapshot": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0", "jest-watcher": "30.3.0", "pretty-format": "30.3.0", "slash": "^3.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw=="], + "@jest/core": ["@jest/core@30.4.2", "", { "dependencies": { "@jest/console": "30.4.1", "@jest/pattern": "30.4.0", "@jest/reporters": "30.4.1", "@jest/test-result": "30.4.1", "@jest/transform": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-changed-files": "30.4.1", "jest-config": "30.4.2", "jest-haste-map": "30.4.1", "jest-message-util": "30.4.1", "jest-regex-util": "30.4.0", "jest-resolve": "30.4.1", "jest-resolve-dependencies": "30.4.2", "jest-runner": "30.4.2", "jest-runtime": "30.4.2", "jest-snapshot": "30.4.1", "jest-util": "30.4.1", "jest-validate": "30.4.1", "jest-watcher": "30.4.1", "pretty-format": "30.4.1", "slash": "^3.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ=="], "@jest/diff-sequences": ["@jest/diff-sequences@30.0.1", "", {}, "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw=="], - "@jest/environment": ["@jest/environment@30.3.0", "", { "dependencies": { "@jest/fake-timers": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "jest-mock": "30.3.0" } }, "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw=="], + "@jest/environment": ["@jest/environment@30.4.1", "", { "dependencies": { "@jest/fake-timers": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "jest-mock": "30.4.1" } }, "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w=="], - "@jest/environment-jsdom-abstract": ["@jest/environment-jsdom-abstract@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/fake-timers": "30.3.0", "@jest/types": "30.3.0", "@types/jsdom": "^21.1.7", "@types/node": "*", "jest-mock": "30.3.0", "jest-util": "30.3.0" }, "peerDependencies": { "canvas": "^3.0.0", "jsdom": "*" }, "optionalPeers": ["canvas"] }, "sha512-0hNFs5N6We3DMCwobzI0ydhkY10sT1tZSC0AAiy+0g2Dt/qEWgrcV5BrMxPczhe41cxW4qm6X+jqZaUdpZIajA=="], + "@jest/environment-jsdom-abstract": ["@jest/environment-jsdom-abstract@30.4.1", "", { "dependencies": { "@jest/environment": "30.4.1", "@jest/fake-timers": "30.4.1", "@jest/types": "30.4.1", "@types/jsdom": "^21.1.7", "@types/node": "*", "jest-mock": "30.4.1", "jest-util": "30.4.1" }, "peerDependencies": { "canvas": "^3.0.0", "jsdom": "*" }, "optionalPeers": ["canvas"] }, "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA=="], - "@jest/expect": ["@jest/expect@30.3.0", "", { "dependencies": { "expect": "30.3.0", "jest-snapshot": "30.3.0" } }, "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg=="], + "@jest/expect": ["@jest/expect@30.4.1", "", { "dependencies": { "expect": "30.4.1", "jest-snapshot": "30.4.1" } }, "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA=="], "@jest/expect-utils": ["@jest/expect-utils@30.0.5", "", { "dependencies": { "@jest/get-type": "30.0.1" } }, "sha512-F3lmTT7CXWYywoVUGTCmom0vXq3HTTkaZyTAzIy+bXSBizB7o5qzlC9VCtq0arOa8GqmNsbg/cE9C6HLn7Szew=="], - "@jest/fake-timers": ["@jest/fake-timers@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", "jest-message-util": "30.3.0", "jest-mock": "30.3.0", "jest-util": "30.3.0" } }, "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ=="], + "@jest/fake-timers": ["@jest/fake-timers@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-util": "30.4.1" } }, "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ=="], "@jest/get-type": ["@jest/get-type@30.0.1", "", {}, "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw=="], - "@jest/globals": ["@jest/globals@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/expect": "30.3.0", "@jest/types": "30.3.0", "jest-mock": "30.3.0" } }, "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA=="], + "@jest/globals": ["@jest/globals@30.4.1", "", { "dependencies": { "@jest/environment": "30.4.1", "@jest/expect": "30.4.1", "@jest/types": "30.4.1", "jest-mock": "30.4.1" } }, "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q=="], - "@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + "@jest/pattern": ["@jest/pattern@30.4.0", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.4.0" } }, "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg=="], - "@jest/reporters": ["@jest/reporters@30.3.0", "", { "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "30.3.0", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", "jest-message-util": "30.3.0", "jest-util": "30.3.0", "jest-worker": "30.3.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw=="], + "@jest/reporters": ["@jest/reporters@30.4.1", "", { "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "30.4.1", "@jest/test-result": "30.4.1", "@jest/transform": "30.4.1", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", "jest-message-util": "30.4.1", "jest-util": "30.4.1", "jest-worker": "30.4.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA=="], "@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], - "@jest/snapshot-utils": ["@jest/snapshot-utils@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" } }, "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g=="], + "@jest/snapshot-utils": ["@jest/snapshot-utils@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" } }, "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA=="], "@jest/source-map": ["@jest/source-map@24.9.0", "", { "dependencies": { "callsites": "^3.0.0", "graceful-fs": "^4.1.15", "source-map": "^0.6.0" } }, "sha512-/Xw7xGlsZb4MJzNDgB7PW5crou5JqWiBQaz6xyPd3ArOg2nfn/PunV8+olXbbEZzNl591o5rWKE9BRDaFAuIBg=="], - "@jest/test-result": ["@jest/test-result@30.3.0", "", { "dependencies": { "@jest/console": "30.3.0", "@jest/types": "30.3.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" } }, "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ=="], + "@jest/test-result": ["@jest/test-result@30.4.1", "", { "dependencies": { "@jest/console": "30.4.1", "@jest/types": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" } }, "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw=="], - "@jest/test-sequencer": ["@jest/test-sequencer@30.3.0", "", { "dependencies": { "@jest/test-result": "30.3.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "slash": "^3.0.0" } }, "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA=="], + "@jest/test-sequencer": ["@jest/test-sequencer@30.4.1", "", { "dependencies": { "@jest/test-result": "30.4.1", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "slash": "^3.0.0" } }, "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw=="], "@jest/transform": ["@jest/transform@30.3.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "jest-regex-util": "30.0.1", "jest-util": "30.3.0", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A=="], - "@jest/types": ["@jest/types@30.3.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw=="], + "@jest/types": ["@jest/types@30.4.1", "", { "dependencies": { "@jest/pattern": "30.4.0", "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -412,7 +412,7 @@ "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], - "@sinonjs/fake-timers": ["@sinonjs/fake-timers@15.1.1", "", { "dependencies": { "@sinonjs/commons": "^3.0.1" } }, "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw=="], + "@sinonjs/fake-timers": ["@sinonjs/fake-timers@15.4.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.1" } }, "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -444,7 +444,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], - "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/css-font-loading-module": ["@types/css-font-loading-module@0.0.7", "", {}, "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q=="], @@ -526,25 +526,25 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.3", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.3", "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.3", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.3", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.3", "@typescript-eslint/tsconfig-utils": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -612,7 +612,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], @@ -676,7 +676,7 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="], + "axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], "babel-jest": ["babel-jest@30.3.0", "", { "dependencies": { "@jest/transform": "30.3.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ=="], @@ -722,7 +722,7 @@ "builtin-modules": ["builtin-modules@1.1.1", "", {}, "sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ=="], - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "cache-base": ["cache-base@1.0.1", "", { "dependencies": { "collection-visit": "^1.0.0", "component-emitter": "^1.2.1", "get-value": "^2.0.6", "has-value": "^1.0.0", "isobject": "^3.0.1", "set-value": "^2.0.0", "to-object-path": "^0.3.0", "union-value": "^1.0.0", "unset-value": "^1.0.0" } }, "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ=="], @@ -1222,11 +1222,11 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - "i18next": ["i18next@26.0.10", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-k3yGPAlWR2RdMYoVXJoDZDT87qeHIWKH7gVksdZMpRty7QX/D9QZeYGvN08KGbKHke9wn01eYT+EEsrqX/YTlw=="], + "i18next": ["i18next@26.1.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-dIU6td04DvQuIqVst5S9g0GviTmhZ0DYD4b9ociVGJmuCa5vZ2de/t+Enf4olvj87mF8Y2lwjNQBwC9QZsvzKQ=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -1384,33 +1384,33 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "jest": ["jest@30.3.0", "", { "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", "import-local": "^3.2.0", "jest-cli": "30.3.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": "./bin/jest.js" }, "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg=="], + "jest": ["jest@30.4.2", "", { "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", "import-local": "^3.2.0", "jest-cli": "30.4.2" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": "./bin/jest.js" }, "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ=="], "jest-canvas-mock": ["jest-canvas-mock@2.5.2", "", { "dependencies": { "cssfontparser": "^1.2.1", "moo-color": "^1.0.2" } }, "sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A=="], - "jest-changed-files": ["jest-changed-files@30.3.0", "", { "dependencies": { "execa": "^5.1.1", "jest-util": "30.3.0", "p-limit": "^3.1.0" } }, "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA=="], + "jest-changed-files": ["jest-changed-files@30.4.1", "", { "dependencies": { "execa": "^5.1.1", "jest-util": "30.4.1", "p-limit": "^3.1.0" } }, "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg=="], - "jest-circus": ["jest-circus@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/expect": "30.3.0", "@jest/test-result": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", "jest-each": "30.3.0", "jest-matcher-utils": "30.3.0", "jest-message-util": "30.3.0", "jest-runtime": "30.3.0", "jest-snapshot": "30.3.0", "jest-util": "30.3.0", "p-limit": "^3.1.0", "pretty-format": "30.3.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA=="], + "jest-circus": ["jest-circus@30.4.2", "", { "dependencies": { "@jest/environment": "30.4.1", "@jest/expect": "30.4.1", "@jest/test-result": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", "jest-each": "30.4.1", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-runtime": "30.4.2", "jest-snapshot": "30.4.1", "jest-util": "30.4.1", "p-limit": "^3.1.0", "pretty-format": "30.4.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ=="], - "jest-cli": ["jest-cli@30.3.0", "", { "dependencies": { "@jest/core": "30.3.0", "@jest/test-result": "30.3.0", "@jest/types": "30.3.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", "jest-config": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0", "yargs": "^17.7.2" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "./bin/jest.js" } }, "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw=="], + "jest-cli": ["jest-cli@30.4.2", "", { "dependencies": { "@jest/core": "30.4.2", "@jest/test-result": "30.4.1", "@jest/types": "30.4.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", "jest-config": "30.4.2", "jest-util": "30.4.1", "jest-validate": "30.4.1", "yargs": "^17.7.2" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "./bin/jest.js" } }, "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q=="], - "jest-config": ["jest-config@30.3.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", "@jest/pattern": "30.0.1", "@jest/test-sequencer": "30.3.0", "@jest/types": "30.3.0", "babel-jest": "30.3.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "jest-circus": "30.3.0", "jest-docblock": "30.2.0", "jest-environment-node": "30.3.0", "jest-regex-util": "30.0.1", "jest-resolve": "30.3.0", "jest-runner": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0", "parse-json": "^5.2.0", "pretty-format": "30.3.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, "peerDependencies": { "@types/node": "*", "esbuild-register": ">=3.4.0", "ts-node": ">=9.0.0" }, "optionalPeers": ["@types/node", "esbuild-register", "ts-node"] }, "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w=="], + "jest-config": ["jest-config@30.4.2", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", "@jest/pattern": "30.4.0", "@jest/test-sequencer": "30.4.1", "@jest/types": "30.4.1", "babel-jest": "30.4.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "jest-circus": "30.4.2", "jest-docblock": "30.4.0", "jest-environment-node": "30.4.1", "jest-regex-util": "30.4.0", "jest-resolve": "30.4.1", "jest-runner": "30.4.2", "jest-util": "30.4.1", "jest-validate": "30.4.1", "parse-json": "^5.2.0", "pretty-format": "30.4.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, "peerDependencies": { "@types/node": "*", "esbuild-register": ">=3.4.0", "ts-node": ">=9.0.0" }, "optionalPeers": ["@types/node", "esbuild-register", "ts-node"] }, "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg=="], "jest-diff": ["jest-diff@30.0.5", "", { "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.0.1", "chalk": "^4.1.2", "pretty-format": "30.0.5" } }, "sha512-1UIqE9PoEKaHcIKvq2vbibrCog4Y8G0zmOxgQUVEiTqwR5hJVMCoDsN1vFvI5JvwD37hjueZ1C4l2FyGnfpE0A=="], - "jest-docblock": ["jest-docblock@30.2.0", "", { "dependencies": { "detect-newline": "^3.1.0" } }, "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA=="], + "jest-docblock": ["jest-docblock@30.4.0", "", { "dependencies": { "detect-newline": "^3.1.0" } }, "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA=="], - "jest-each": ["jest-each@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.3.0", "chalk": "^4.1.2", "jest-util": "30.3.0", "pretty-format": "30.3.0" } }, "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA=="], + "jest-each": ["jest-each@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.4.1", "chalk": "^4.1.2", "jest-util": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA=="], - "jest-environment-jsdom": ["jest-environment-jsdom@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/environment-jsdom-abstract": "30.3.0", "jsdom": "^26.1.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-RLEOJy6ip1lpw0yqJ8tB3i88FC7VBz7i00Zvl2qF71IdxjS98gC9/0SPWYIBVXHm5hgCYK0PAlSlnHGGy9RoMg=="], + "jest-environment-jsdom": ["jest-environment-jsdom@30.4.1", "", { "dependencies": { "@jest/environment": "30.4.1", "@jest/environment-jsdom-abstract": "30.4.1", "jsdom": "^26.1.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA=="], - "jest-environment-node": ["jest-environment-node@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/fake-timers": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "jest-mock": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0" } }, "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ=="], + "jest-environment-node": ["jest-environment-node@30.4.1", "", { "dependencies": { "@jest/environment": "30.4.1", "@jest/fake-timers": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "jest-mock": "30.4.1", "jest-util": "30.4.1", "jest-validate": "30.4.1" } }, "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw=="], - "jest-haste-map": ["jest-haste-map@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", "jest-util": "30.3.0", "jest-worker": "30.3.0", "picomatch": "^4.0.3", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.3" } }, "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA=="], + "jest-haste-map": ["jest-haste-map@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "jest-worker": "30.4.1", "picomatch": "^4.0.3", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.3" } }, "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw=="], "jest-junit": ["jest-junit@17.0.0", "", { "dependencies": { "mkdirp": "^1.0.4", "strip-ansi": "^6.0.1", "uuid": "^14.0.0", "xml": "^1.0.1" } }, "sha512-RYWCkq4j59gUXj5DsgbIE7xFBZzu1gtibPhyjSjMmGaOTLnqlXhg7x9zuGCwgbCuMAyoyvk0Mi8wSrRR5uOeLA=="], - "jest-leak-detector": ["jest-leak-detector@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "pretty-format": "30.3.0" } }, "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ=="], + "jest-leak-detector": ["jest-leak-detector@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "pretty-format": "30.4.1" } }, "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ=="], "jest-matcher-utils": ["jest-matcher-utils@30.0.5", "", { "dependencies": { "@jest/get-type": "30.0.1", "chalk": "^4.1.2", "jest-diff": "30.0.5", "pretty-format": "30.0.5" } }, "sha512-uQgGWt7GOrRLP1P7IwNWwK1WAQbq+m//ZY0yXygyfWp0rJlksMSLQAA4wYQC3b6wl3zfnchyTx+k3HZ5aPtCbQ=="], @@ -1420,27 +1420,27 @@ "jest-pnp-resolver": ["jest-pnp-resolver@1.2.3", "", { "peerDependencies": { "jest-resolve": "*" } }, "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w=="], - "jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], + "jest-regex-util": ["jest-regex-util@30.4.0", "", {}, "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg=="], - "jest-resolve": ["jest-resolve@30.3.0", "", { "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "jest-pnp-resolver": "^1.2.3", "jest-util": "30.3.0", "jest-validate": "30.3.0", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" } }, "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g=="], + "jest-resolve": ["jest-resolve@30.4.1", "", { "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-pnp-resolver": "^1.2.3", "jest-util": "30.4.1", "jest-validate": "30.4.1", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" } }, "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q=="], - "jest-resolve-dependencies": ["jest-resolve-dependencies@30.3.0", "", { "dependencies": { "jest-regex-util": "30.0.1", "jest-snapshot": "30.3.0" } }, "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw=="], + "jest-resolve-dependencies": ["jest-resolve-dependencies@30.4.2", "", { "dependencies": { "jest-regex-util": "30.4.0", "jest-snapshot": "30.4.1" } }, "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ=="], - "jest-runner": ["jest-runner@30.3.0", "", { "dependencies": { "@jest/console": "30.3.0", "@jest/environment": "30.3.0", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-docblock": "30.2.0", "jest-environment-node": "30.3.0", "jest-haste-map": "30.3.0", "jest-leak-detector": "30.3.0", "jest-message-util": "30.3.0", "jest-resolve": "30.3.0", "jest-runtime": "30.3.0", "jest-util": "30.3.0", "jest-watcher": "30.3.0", "jest-worker": "30.3.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" } }, "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw=="], + "jest-runner": ["jest-runner@30.4.2", "", { "dependencies": { "@jest/console": "30.4.1", "@jest/environment": "30.4.1", "@jest/test-result": "30.4.1", "@jest/transform": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-docblock": "30.4.0", "jest-environment-node": "30.4.1", "jest-haste-map": "30.4.1", "jest-leak-detector": "30.4.1", "jest-message-util": "30.4.1", "jest-resolve": "30.4.1", "jest-runtime": "30.4.2", "jest-util": "30.4.1", "jest-watcher": "30.4.1", "jest-worker": "30.4.1", "p-limit": "^3.1.0", "source-map-support": "0.5.13" } }, "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg=="], - "jest-runtime": ["jest-runtime@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/fake-timers": "30.3.0", "@jest/globals": "30.3.0", "@jest/source-map": "30.0.1", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "jest-message-util": "30.3.0", "jest-mock": "30.3.0", "jest-regex-util": "30.0.1", "jest-resolve": "30.3.0", "jest-snapshot": "30.3.0", "jest-util": "30.3.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" } }, "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng=="], + "jest-runtime": ["jest-runtime@30.4.2", "", { "dependencies": { "@jest/environment": "30.4.1", "@jest/fake-timers": "30.4.1", "@jest/globals": "30.4.1", "@jest/source-map": "30.0.1", "@jest/test-result": "30.4.1", "@jest/transform": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-regex-util": "30.4.0", "jest-resolve": "30.4.1", "jest-snapshot": "30.4.1", "jest-util": "30.4.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" } }, "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ=="], "jest-skipped-reporter": ["jest-skipped-reporter@0.0.5", "", { "dependencies": { "jest-util": "^24.9.0" } }, "sha512-cjbwbH4mrPUf0JGqOTzgNzB8j+bw72qLFlj4oinxCSBNMAP/DiVYveTl4ZyTiWQ4oBm0gelcfJMDX7SoIaIgeg=="], - "jest-snapshot": ["jest-snapshot@30.3.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", "@jest/snapshot-utils": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", "expect": "30.3.0", "graceful-fs": "^4.2.11", "jest-diff": "30.3.0", "jest-matcher-utils": "30.3.0", "jest-message-util": "30.3.0", "jest-util": "30.3.0", "pretty-format": "30.3.0", "semver": "^7.7.2", "synckit": "^0.11.8" } }, "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ=="], + "jest-snapshot": ["jest-snapshot@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", "@jest/snapshot-utils": "30.4.1", "@jest/transform": "30.4.1", "@jest/types": "30.4.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", "expect": "30.4.1", "graceful-fs": "^4.2.11", "jest-diff": "30.4.1", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-util": "30.4.1", "pretty-format": "30.4.1", "semver": "^7.7.2", "synckit": "^0.11.8" } }, "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw=="], - "jest-util": ["jest-util@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg=="], + "jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="], - "jest-validate": ["jest-validate@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.3.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", "pretty-format": "30.3.0" } }, "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q=="], + "jest-validate": ["jest-validate@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.4.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", "pretty-format": "30.4.1" } }, "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw=="], - "jest-watcher": ["jest-watcher@30.3.0", "", { "dependencies": { "@jest/test-result": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", "jest-util": "30.3.0", "string-length": "^4.0.2" } }, "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w=="], + "jest-watcher": ["jest-watcher@30.4.1", "", { "dependencies": { "@jest/test-result": "30.4.1", "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", "jest-util": "30.4.1", "string-length": "^4.0.2" } }, "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw=="], - "jest-worker": ["jest-worker@30.3.0", "", { "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", "jest-util": "30.3.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" } }, "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ=="], + "jest-worker": ["jest-worker@30.4.1", "", { "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" } }, "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g=="], "js-sdsl": ["js-sdsl@4.3.0", "", {}, "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ=="], @@ -1720,9 +1720,9 @@ "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], - "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], - "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], @@ -1800,6 +1800,10 @@ "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], + "react-is-18": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-is-19": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], + "react-popper": ["react-popper@2.3.0", "", { "dependencies": { "react-fast-compare": "^3.0.1", "warning": "^4.0.2" }, "peerDependencies": { "@popperjs/core": "^2.0.0", "react": "^16.8.0 || ^17 || ^18", "react-dom": "^16.8.0 || ^17 || ^18" } }, "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q=="], "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" } }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], @@ -2346,32 +2350,46 @@ "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "@jest/console/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "@jest/console/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], + + "@jest/core/@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], - "@jest/core/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "@jest/core/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "@jest/core/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "@jest/core/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], - "@jest/environment/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "@jest/environment/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], - "@jest/environment-jsdom-abstract/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "@jest/environment-jsdom-abstract/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], - "@jest/expect/expect": ["expect@30.3.0", "", { "dependencies": { "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.3.0", "jest-message-util": "30.3.0", "jest-mock": "30.3.0", "jest-util": "30.3.0" } }, "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q=="], + "@jest/expect/expect": ["expect@30.4.1", "", { "dependencies": { "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-util": "30.4.1" } }, "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA=="], - "@jest/fake-timers/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "@jest/fake-timers/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "@jest/fake-timers/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "@jest/fake-timers/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], - "@jest/globals/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "@jest/globals/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], + + "@jest/reporters/@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], "@jest/reporters/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "@jest/reporters/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "@jest/reporters/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], "@jest/source-map/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "@jest/transform/@jest/types": ["@jest/types@30.3.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw=="], + + "@jest/transform/jest-haste-map": ["jest-haste-map@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", "jest-util": "30.3.0", "jest-worker": "30.3.0", "picomatch": "^4.0.3", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.3" } }, "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA=="], + + "@jest/transform/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], + + "@jest/transform/jest-util": ["jest-util@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg=="], + "@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], + "@jest/types/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], + "@jridgewell/remapping/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], @@ -2546,9 +2564,9 @@ "htmlparser2/readable-stream": ["readable-stream@1.1.14", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ=="], - "http-proxy-agent/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "https-proxy-agent/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + "http-proxy-agent/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -2580,17 +2598,19 @@ "istanbul-lib-source-maps/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], - "jest-circus/jest-matcher-utils": ["jest-matcher-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.3.0", "pretty-format": "30.3.0" } }, "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA=="], + "jest-circus/jest-matcher-utils": ["jest-matcher-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A=="], - "jest-circus/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "jest-circus/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "jest-circus/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-circus/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "jest-config/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], + "jest-config/babel-jest": ["babel-jest@30.4.1", "", { "dependencies": { "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw=="], + "jest-config/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "jest-config/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-config/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "jest-config/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -2598,15 +2618,15 @@ "jest-each/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "jest-each/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-each/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], - "jest-environment-node/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "jest-environment-node/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], "jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "jest-leak-detector/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "jest-leak-detector/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-leak-detector/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "jest-matcher-utils/pretty-format": ["pretty-format@30.0.5", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw=="], @@ -2618,44 +2638,52 @@ "jest-mock/jest-util": ["jest-util@30.0.5", "", { "dependencies": { "@jest/types": "30.0.5", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.2" } }, "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g=="], - "jest-runner/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "jest-runner/@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], + + "jest-runner/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], "jest-runtime/@jest/source-map": ["@jest/source-map@30.0.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "callsites": "^3.1.0", "graceful-fs": "^4.2.11" } }, "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg=="], + "jest-runtime/@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], + "jest-runtime/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "jest-runtime/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "jest-runtime/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "jest-runtime/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "jest-runtime/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], "jest-runtime/strip-bom": ["strip-bom@4.0.0", "", {}, "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w=="], "jest-skipped-reporter/jest-util": ["jest-util@24.9.0", "", { "dependencies": { "@jest/console": "^24.9.0", "@jest/fake-timers": "^24.9.0", "@jest/source-map": "^24.9.0", "@jest/test-result": "^24.9.0", "@jest/types": "^24.9.0", "callsites": "^3.0.0", "chalk": "^2.0.1", "graceful-fs": "^4.1.15", "is-ci": "^2.0.0", "mkdirp": "^0.5.1", "slash": "^2.0.0", "source-map": "^0.6.0" } }, "sha512-x+cZU8VRmOJxbA1K5oDBdxQmdq0OIdADarLxk0Mq+3XS4jgvhG/oKGWcIDCtPG0HgjxOYvF+ilPJQsAyXfbNOg=="], - "jest-snapshot/@jest/expect-utils": ["@jest/expect-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA=="], + "jest-snapshot/@jest/expect-utils": ["@jest/expect-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ=="], "jest-snapshot/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "jest-snapshot/expect": ["expect@30.3.0", "", { "dependencies": { "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.3.0", "jest-message-util": "30.3.0", "jest-mock": "30.3.0", "jest-util": "30.3.0" } }, "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q=="], + "jest-snapshot/@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], + + "jest-snapshot/expect": ["expect@30.4.1", "", { "dependencies": { "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-util": "30.4.1" } }, "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA=="], - "jest-snapshot/jest-diff": ["jest-diff@30.3.0", "", { "dependencies": { "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.3.0" } }, "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ=="], + "jest-snapshot/jest-diff": ["jest-diff@30.4.1", "", { "dependencies": { "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.4.1" } }, "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA=="], - "jest-snapshot/jest-matcher-utils": ["jest-matcher-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.3.0", "pretty-format": "30.3.0" } }, "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA=="], + "jest-snapshot/jest-matcher-utils": ["jest-matcher-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A=="], - "jest-snapshot/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "jest-snapshot/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "jest-snapshot/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-snapshot/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], - "jest-snapshot/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "jest-snapshot/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "jest-validate/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "jest-validate/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-validate/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "jsdom/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "jsdom/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "jshint/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], @@ -2864,31 +2892,41 @@ "@jest/console/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@jest/console/jest-message-util/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "@jest/console/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], + + "@jest/core/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], "@jest/core/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@jest/core/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@jest/core/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], - "@jest/expect/expect/@jest/expect-utils": ["@jest/expect-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA=="], + "@jest/expect/expect/@jest/expect-utils": ["@jest/expect-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ=="], "@jest/expect/expect/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "@jest/expect/expect/jest-matcher-utils": ["jest-matcher-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.3.0", "pretty-format": "30.3.0" } }, "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA=="], + "@jest/expect/expect/jest-matcher-utils": ["jest-matcher-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A=="], - "@jest/expect/expect/jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + "@jest/expect/expect/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "@jest/expect/expect/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "@jest/expect/expect/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], "@jest/fake-timers/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@jest/fake-timers/jest-message-util/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "@jest/fake-timers/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], + + "@jest/reporters/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], "@jest/reporters/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@jest/reporters/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@jest/reporters/jest-message-util/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "@jest/reporters/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], + + "@jest/transform/@jest/types/@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + + "@jest/transform/jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "@jest/transform/jest-haste-map/jest-worker": ["jest-worker@30.3.0", "", { "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", "jest-util": "30.3.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" } }, "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ=="], "@react-three/eslint-plugin/eslint/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], @@ -3016,35 +3054,47 @@ "jest-circus/jest-matcher-utils/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "jest-circus/jest-matcher-utils/jest-diff": ["jest-diff@30.3.0", "", { "dependencies": { "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.3.0" } }, "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ=="], + "jest-circus/jest-matcher-utils/jest-diff": ["jest-diff@30.4.1", "", { "dependencies": { "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.4.1" } }, "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA=="], "jest-circus/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "jest-circus/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-circus/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], + + "jest-config/babel-jest/@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], + + "jest-config/babel-jest/babel-preset-jest": ["babel-preset-jest@30.4.0", "", { "dependencies": { "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg=="], "jest-config/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "jest-config/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-config/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "jest-each/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-each/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], - "jest-leak-detector/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-leak-detector/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-message-util/@jest/types/@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + "jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-mock/@jest/types/@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + + "jest-runner/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], + "jest-runner/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "jest-runner/jest-message-util/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-runner/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], + + "jest-runtime/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], "jest-runtime/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "jest-runtime/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "jest-runtime/jest-message-util/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "jest-runtime/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "jest-skipped-reporter/jest-util/@jest/console": ["@jest/console@24.9.0", "", { "dependencies": { "@jest/source-map": "^24.9.0", "chalk": "^2.0.1", "slash": "^2.0.0" } }, "sha512-Zuj6b8TnKXi3q4ymac8EQfc3ea/uhLeCGThFqXeC8H9/raaH8ARPUTdId+XyGd03Z4In0/VjD2OYFcBF09fNLQ=="], @@ -3062,20 +3112,26 @@ "jest-skipped-reporter/jest-util/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "jest-snapshot/expect/jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + "jest-snapshot/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], - "jest-snapshot/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.3.0", "", {}, "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA=="], + "jest-snapshot/expect/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], + + "jest-snapshot/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.4.0", "", {}, "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g=="], "jest-snapshot/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "jest-snapshot/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-snapshot/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], - "jest-validate/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-validate/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "jest-worker/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "jsdom/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "jsdom/https-proxy-agent/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + "jshint/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "node-source-walk/@babel/parser/@babel/types": ["@babel/types@7.26.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg=="], @@ -3158,27 +3214,29 @@ "@jest/console/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@jest/console/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@jest/console/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "@jest/core/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@jest/expect/expect/jest-matcher-utils/jest-diff": ["jest-diff@30.3.0", "", { "dependencies": { "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.3.0" } }, "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ=="], + "@jest/expect/expect/jest-matcher-utils/jest-diff": ["jest-diff@30.4.1", "", { "dependencies": { "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.4.1" } }, "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA=="], - "@jest/expect/expect/jest-matcher-utils/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "@jest/expect/expect/jest-matcher-utils/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "@jest/expect/expect/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@jest/expect/expect/jest-message-util/pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + "@jest/expect/expect/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "@jest/fake-timers/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@jest/fake-timers/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@jest/fake-timers/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "@jest/reporters/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "@jest/reporters/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@jest/reporters/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@jest/reporters/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], + + "@jest/transform/jest-haste-map/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "@react-three/eslint-plugin/eslint/espree/acorn": ["acorn@8.14.0", "", { "bin": "bin/acorn" }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], @@ -3210,6 +3268,8 @@ "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "expect/jest-util/@jest/types/@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + "farmbot/mqtt/worker-timers/tslib": ["tslib@2.6.3", "", {}, "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="], "farmbot/mqtt/worker-timers/worker-timers-broker": ["worker-timers-broker@6.1.8", "", { "dependencies": { "@babel/runtime": "^7.24.5", "fast-unique-numbers": "^8.0.13", "tslib": "^2.6.2", "worker-timers-worker": "^7.0.71" } }, "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ=="], @@ -3240,21 +3300,29 @@ "istanbul-lib-instrument/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], - "jest-circus/jest-matcher-utils/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.3.0", "", {}, "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA=="], + "jest-circus/jest-matcher-utils/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.4.0", "", {}, "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g=="], "jest-circus/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "jest-config/babel-jest/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], + + "jest-config/babel-jest/babel-preset-jest/babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@30.4.0", "", { "dependencies": { "@types/babel__core": "^7.20.5" } }, "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA=="], + "jest-config/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "jest-message-util/@jest/types/@jest/pattern/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], + + "jest-mock/@jest/types/@jest/pattern/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], + "jest-runner/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "jest-runner/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-runner/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "jest-runtime/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "jest-runtime/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "jest-runtime/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "jest-runtime/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "jest-skipped-reporter/jest-util/@jest/fake-timers/jest-message-util": ["jest-message-util@24.9.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "@jest/test-result": "^24.9.0", "@jest/types": "^24.9.0", "@types/stack-utils": "^1.0.1", "chalk": "^2.0.1", "micromatch": "^3.1.10", "slash": "^2.0.0", "stack-utils": "^1.0.1" } }, "sha512-oCj8FiZ3U0hTP4aSui87P4L4jC37BtQwUMqk+zk/b11FR19BJDeZsZAvIHutWnmtw7r85UmR3CEWZ0HWU2mAlw=="], @@ -3326,20 +3394,24 @@ "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - "@jest/expect/expect/jest-matcher-utils/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.3.0", "", {}, "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA=="], + "@jest/expect/expect/jest-matcher-utils/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.4.0", "", {}, "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g=="], - "@jest/expect/expect/jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@jest/expect/expect/jest-matcher-utils/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "@jest/expect/expect/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@jest/expect/expect/jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@jest/expect/expect/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "@jest/reporters/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@jest/transform/jest-haste-map/jest-worker/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "@react-three/eslint-plugin/eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "detective-typescript/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "expect/jest-util/@jest/types/@jest/pattern/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], + "farmbot/mqtt/worker-timers/worker-timers-broker/fast-unique-numbers": ["fast-unique-numbers@8.0.13", "", { "dependencies": { "@babel/runtime": "^7.23.8", "tslib": "^2.6.2" } }, "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g=="], "jest-config/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/package.json b/package.json index 53d67b7c21..df34a97038 100644 --- a/package.json +++ b/package.json @@ -38,14 +38,14 @@ "mqtt": "mqtt/dist/mqtt.esm.js" }, "dependencies": { - "@blueprintjs/core": "6.12.1", - "@blueprintjs/select": "6.1.10", + "@blueprintjs/core": "6.13.0", + "@blueprintjs/select": "6.1.11", "@monaco-editor/react": "4.7.0", "@react-spring/three": "10.0.3", "@react-three/drei": "10.7.7", "@react-three/fiber": "9.6.1", "@rollbar/react": "1.0.0", - "@types/bun": "1.3.13", + "@types/bun": "1.3.14", "@types/lodash": "4.17.24", "@types/markdown-it": "14.1.2", "@types/markdown-it-emoji": "3.0.1", @@ -58,7 +58,7 @@ "@types/three": "0.184.1", "@types/ws": "8.18.1", "@xterm/xterm": "6.0.0", - "axios": "1.16.0", + "axios": "1.16.1", "bowser": "2.14.1", "browser-speech": "1.1.1", "delaunator": "5.1.0", @@ -66,7 +66,7 @@ "farmbot": "15.9.3", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.0.10", + "i18next": "26.1.0", "lodash": "4.18.1", "markdown-it": "14.1.1", "markdown-it-emoji": "3.0.0", @@ -104,8 +104,8 @@ "@types/jest": "30.0.0", "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", - "@typescript-eslint/eslint-plugin": "8.59.2", - "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", "eslint": "10.3.0", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", @@ -115,16 +115,16 @@ "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", "happy-dom": "20.9.0", - "jest": "30.3.0", + "jest": "30.4.2", "jest-canvas-mock": "2.5.2", - "jest-cli": "30.3.0", - "jest-environment-jsdom": "30.3.0", + "jest-cli": "30.4.2", + "jest-environment-jsdom": "30.4.1", "jest-junit": "17.0.0", "jest-skipped-reporter": "0.0.5", "jshint": "2.13.6", "madge": "8.0.0", "path-browserify": "1.0.1", - "playwright": "1.59.1", + "playwright": "1.60.0", "postcss": "8.5.14", "postcss-scss": "4.0.9", "raf": "3.4.1", From 925b2f91cd1587efd3a1279c3b710f8e5bf3f8ce Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 20 May 2026 17:42:14 -0700 Subject: [PATCH 58/83] update error pages --- public/400.html | 40 +++++++-------- public/404.html | 74 +-------------------------- public/405.html | 26 ++++++++++ public/406-unsupported-browser.html | 26 ++++++---- public/406.html | 26 ++++++++++ public/422.html | 68 +++++++------------------ public/429.html | 26 ++++++++++ public/500.html | 79 ++--------------------------- public/501.html | 26 ++++++++++ public/error-pages.css | 49 ++++++++++++++++++ 10 files changed, 211 insertions(+), 229 deletions(-) create mode 100644 public/405.html create mode 100644 public/406.html create mode 100644 public/429.html create mode 100644 public/501.html create mode 100644 public/error-pages.css diff --git a/public/400.html b/public/400.html index 39f442cfa4..fc1e2a5d79 100644 --- a/public/400.html +++ b/public/400.html @@ -1,31 +1,27 @@ - - + - - The server cannot process the request due to a client error (400 Bad Request) - - - - - - - + Bad Request (400) + + + + - -
-
-

- The server cannot process the request due to a client error. - Please check the request and try again. If you're the application owner check the logs for more information. -

-
-
- +
+ + FarmBot + +

+ 400 +

+

+ The server cannot process the request due to a client error.

+ Please check the request and try again. +

+
diff --git a/public/404.html b/public/404.html index e6419c7b81..be2eb73aad 100755 --- a/public/404.html +++ b/public/404.html @@ -4,83 +4,13 @@ - +
- - - - - - - - - - - - - - - - + FarmBot

404 diff --git a/public/405.html b/public/405.html new file mode 100644 index 0000000000..ffd0e72144 --- /dev/null +++ b/public/405.html @@ -0,0 +1,26 @@ + + + + + Method Not Allowed (405) + + + + + + +
+ + FarmBot + +

+ 405 +

+

+ The request method is not allowed.

+ Try going back. +

+
+ + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html index fa1aa430f5..2c14a06030 100644 --- a/public/406-unsupported-browser.html +++ b/public/406-unsupported-browser.html @@ -1,20 +1,26 @@ - + - Your browser is not supported (406) - - + Your browser is not supported (406) + + + -
-
-

Your browser is not supported.

-

Please upgrade your browser to continue.

+
+ + FarmBot + +

+ 406 +

+

+ Your browser is not supported.

+ Please upgrade your browser to continue. +

-
diff --git a/public/406.html b/public/406.html new file mode 100644 index 0000000000..26ef88afe7 --- /dev/null +++ b/public/406.html @@ -0,0 +1,26 @@ + + + + + Not Acceptable (406) + + + + + + +
+ + FarmBot + +

+ 406 +

+

+ The requested format is not acceptable.

+ Try going back. +

+
+ + + diff --git a/public/422.html b/public/422.html index 7b5a47a458..bef7004b40 100755 --- a/public/422.html +++ b/public/422.html @@ -1,58 +1,26 @@ - - The change you wanted was rejected (422) - + + The change you wanted was rejected (422) + + + - -
-

The change you wanted was rejected.

-

Maybe you tried to change something you didn't have access to.

-
-

If you are the application owner check the logs for more information.

+
+ + FarmBot + +

+ 422 +

+

+ The change you wanted was rejected.

+ Maybe you tried to change something you didn't have access to. +

+
+ diff --git a/public/429.html b/public/429.html new file mode 100644 index 0000000000..75f9bae357 --- /dev/null +++ b/public/429.html @@ -0,0 +1,26 @@ + + + + + Too Many Requests (429) + + + + + + +
+ + FarmBot + +

+ 429 +

+

+ Too many requests were sent.

+ Please wait a moment and try again. +

+
+ + + diff --git a/public/500.html b/public/500.html index 90318ed0a9..8a17910654 100755 --- a/public/500.html +++ b/public/500.html @@ -4,91 +4,20 @@ - +
- - - - - - - - - - - - - - - - - + FarmBot

- Sorry, something went wrong. + 500

+ Sorry, something went wrong.

You can try going back, but if the problem persists, please let us know. -

diff --git a/public/501.html b/public/501.html new file mode 100644 index 0000000000..e49121b005 --- /dev/null +++ b/public/501.html @@ -0,0 +1,26 @@ + + + + + Not Implemented (501) + + + + + + +
+ + FarmBot + +

+ 501 +

+

+ The requested feature is not implemented.

+ Try going back. +

+
+ + + diff --git a/public/error-pages.css b/public/error-pages.css new file mode 100644 index 0000000000..0039a8ca34 --- /dev/null +++ b/public/error-pages.css @@ -0,0 +1,49 @@ +body { + min-width: 100vw; + min-height: 100vh; + background: linear-gradient(-135deg, #089460, #159ACC); + background-size: 100% 100%; + overflow: hidden; + text-shadow: 0px 1px 1px #555; +} + +img { + display: block; + margin: 0 auto 2rem; + max-width: 20rem; +} + +h1, +h4, +a { + font-family: "Cabin", "Cabin Fallback", sans-serif; + text-align: center; + color: #fff; +} + +h1 { + font-weight: 600; + font-size: 4rem; + margin-bottom: 1rem; +} + +h4, +a { + font-size: 1.2rem; + font-weight: 400; +} + +a { + text-decoration: underline; + cursor: pointer; +} + +a:active, +a:visited { + color: #fff; +} + +.content { + padding: 0 2rem; + margin: 4rem auto 0; +} From 6cb011056dd30a34ef81470ba788c32d755f7264 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 20 May 2026 17:42:53 -0700 Subject: [PATCH 59/83] update browser versions --- app/controllers/application_controller.rb | 8 ++++++++ frontend/app.tsx | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index da3fff1dbd..c956a72b3d 100755 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,14 @@ class ApplicationController < ActionController::Base # For APIs, you may want to use :null_session instead. protect_from_forgery with: :null_session + # class static initialization block support + allow_browser versions: { + chrome: 94, + firefox: 93, + edge: 94, + ie: false + } + after_action :unset_current_device def unset_current_device diff --git a/frontend/app.tsx b/frontend/app.tsx index bd76aa6af3..6dcddd8428 100644 --- a/frontend/app.tsx +++ b/frontend/app.tsx @@ -98,7 +98,8 @@ export class RawApp extends React.Component { } }, LOAD_TIME_FAILURE_MS); const browser = Bowser.getParser(window.navigator.userAgent); - !browser.satisfies({ chrome: ">85", firefox: ">75", edge: ">85" }) && + /** css :has() support */ + !browser.satisfies({ chrome: ">=105", firefox: ">=121", edge: ">=105" }) && warning(t(Content.UNSUPPORTED_BROWSER)); } From 82ffba1438c12edf8d280bf0850ac55e9458c6a0 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 20 May 2026 17:43:10 -0700 Subject: [PATCH 60/83] add more screenshots --- scripts/ci/run-playwright-render | 32 ++++++++++++++++++++++++++------ scripts/fps.js | 20 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/scripts/ci/run-playwright-render b/scripts/ci/run-playwright-render index 6120606f6f..5f86f2e8da 100755 --- a/scripts/ci/run-playwright-render +++ b/scripts/ci/run-playwright-render @@ -20,30 +20,50 @@ attempts="${ATTEMPTS:-2}" GITHUB_ENV="${GITHUB_ENV:-/tmp/render-github-env}" urls=( - "promo|http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" - "app|http://localhost:3000/try_farmbot?productLine=genesis_1.8" - # "stress|http://localhost:3000/try_farmbot?productLine=genesis_xl_1.8_stress_250" + "promo|fps|http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" + "login|screenshot|http://localhost:3000/" + "os|screenshot|http://localhost:3000/os" + "demo|screenshot|http://localhost:3000/demo" + "password_reset|screenshot|http://localhost:3000/password_reset/abc" + "404|screenshot|http://localhost:3000/404" + "app|fps|http://localhost:3000/try_farmbot?productLine=genesis_1.8" + # "stress|fps|http://localhost:3000/try_farmbot?productLine=genesis_xl_1.8_stress_250" ) for entry in "${urls[@]}"; do name="${entry%%|*}" - url="${entry#*|}" + rest="${entry#*|}" + mode="${rest%%|*}" + url="${rest#*|}" if [ "$name" = "promo" ]; then screenshot_path="/tmp/$PROMO_IMAGE.png" fps_samples_path="/tmp/$FPS_SAMPLES_NAME.csv" scene_metrics_path="/tmp/$SCENE_METRICS_NAME.csv" else - screenshot_path="/tmp/${PROMO_IMAGE}_${name}.png" + screenshot_path="/tmp/screenshot_${name}.png" fps_samples_path="/tmp/${FPS_SAMPLES_NAME}_${name}.csv" scene_metrics_path="/tmp/${SCENE_METRICS_NAME}_${name}.csv" fi for _ in $(seq 1 "$attempts"); do - echo "Attempting FPS check via ${url}" + if [ "$mode" = "screenshot" ]; then + echo "Attempting screenshot via ${url}" + else + echo "Attempting FPS check via ${url}" + fi if ! sudo docker compose exec -T web curl -I "${url}" >/dev/null; then continue fi + if [ "$mode" = "screenshot" ]; then + if ! screenshot_output=$(SCREENSHOT_ONLY=1 bun scripts/fps.js "${url}" "$screenshot_path"); then + echo "${screenshot_output}" + continue + fi + echo "${screenshot_output}" + continue 2 + fi + if ! fps_output=$(bun scripts/fps.js "${url}" "$screenshot_path" "$fps_samples_path"); then echo "${fps_output}" continue diff --git a/scripts/fps.js b/scripts/fps.js index 4bde083565..cf65fd5ebe 100644 --- a/scripts/fps.js +++ b/scripts/fps.js @@ -11,6 +11,8 @@ const postLoadSamples = 10; const sampleIntervalMs = 1000; const ci = Boolean(process.env.CI); const openWindow = Boolean(process.env.DISPLAY && process.env.OPEN_WINDOW); +const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; +const screenshotOnly = Boolean(process.env.SCREENSHOT_ONLY); const chromiumArgs = [ '--no-sandbox', '--disable-setuid-sandbox', @@ -52,6 +54,10 @@ function printUsage() { ' url Page URL that exposes window.__fps and window.__scene_metrics.', ' screenshot_path Optional full-page screenshot PNG path. Default: /tmp/fps.png', ' fps_samples_csv_path Optional FPS samples CSV path. Default: /tmp/fps_samples.csv', + '', + 'Environment:', + ' PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH Path to a Chrome/Chromium binary.', + ' SCREENSHOT_ONLY=1 Take a screenshot without FPS metrics.', ].join('\n')); } @@ -80,8 +86,12 @@ function saveFpsSamplesCsv(samples, destination) { async function main() { console.log(`Launching Chromium with args: ${chromiumArgs.join(' ')}`); + if (executablePath) { + console.log(`CHROMIUM_EXECUTABLE_PATH=${executablePath}`); + } const browser = await chromium.launch({ headless: !openWindow, + executablePath, args: chromiumArgs, }); const page = await browser.newPage(); @@ -89,6 +99,16 @@ async function main() { try { await page.goto(url, { waitUntil: 'domcontentloaded' }); await prepareStressResources(page, url); + if (screenshotOnly) { + fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); + await page.screenshot({ + path: screenshotPath, + fullPage: true, + timeout: 60_000, + }); + console.log(`SCREENSHOT=${screenshotPath}`); + return; + } const renderer = await webglRenderer(page); console.log(`WEBGL_STATUS=${renderer.status}`); if (renderer.vendor) { console.log(`WEBGL_VENDOR=${renderer.vendor}`); } From b6805a3f6b54200f0e7a6667b5e51f92be042dc4 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 21 May 2026 11:19:21 -0700 Subject: [PATCH 61/83] improve ci data sourcing --- .ci_env | 1 + .github/actions/setup-ci/action.yml | 7 ++- .github/workflows/render.yml | 62 ++++++++++++++----------- .github/workflows/test.yml | 26 +++++++---- .gitignore | 1 + scripts/ci/previous-fps-value | 21 +++++++++ scripts/ci/render-env | 1 + scripts/ci/restore-artifact-branch-file | 25 ++++++++++ scripts/fps.js | 1 + 9 files changed, 110 insertions(+), 35 deletions(-) create mode 100755 scripts/ci/previous-fps-value create mode 100755 scripts/ci/restore-artifact-branch-file diff --git a/.ci_env b/.ci_env index 30e40fa938..a4608b0e3d 100644 --- a/.ci_env +++ b/.ci_env @@ -11,3 +11,4 @@ CODECLIMATE_REPO_TOKEN=2216bf71b977a85ed8da497942c34a503dbedd77da1b6b97c33551ba0 COVERALLS_REPO_TOKEN=lEX6nkql7y2YFCcIXVq5ORvdvMtYzfZdG CODECOV_TOKEN=c0fe1e65-d284-4d58-a742-4088e88be35d RAILS_ENV=test +MAX_POOL_SIZE=2 diff --git a/.github/actions/setup-ci/action.yml b/.github/actions/setup-ci/action.yml index d1cad902ff..6c481929ec 100644 --- a/.github/actions/setup-ci/action.yml +++ b/.github/actions/setup-ci/action.yml @@ -5,12 +5,17 @@ inputs: description: Save Docker build layers to the GitHub Actions cache. required: false default: "false" + rails-env: + description: Rails environment to write to .env. + required: true runs: using: composite steps: - name: Set up environment file shell: bash - run: mv .ci_env .env + run: | + mv .ci_env .env + sed -i "s/^RAILS_ENV=.*/RAILS_ENV=${{ inputs.rails-env }}/" .env - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml index fc9ed02a72..682e372620 100644 --- a/.github/workflows/render.yml +++ b/.github/workflows/render.yml @@ -8,6 +8,7 @@ on: env: NOTIFY: ${{ github.ref_name == 'staging' }} + STAGING: ${{ github.ref_name == 'staging' }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} SCENE_METRICS_NAME: scene_metrics FE_COVERAGE_NAME: fe_coverage @@ -25,6 +26,8 @@ jobs: - name: Set up CI uses: ./.github/actions/setup-ci + with: + rails-env: production - name: Set render service hosts run: | @@ -82,41 +85,49 @@ jobs: echo "mqtt auth did not become ready" exit 1 - - name: Run playwright - run: scripts/ci/run-playwright-render + - name: Restore metric history + id: restore_metric_history + run: | + . scripts/ci/render-env + scripts/ci/restore-artifact-branch-file "$SCENE_METRICS_NAME*.csv" + scripts/ci/restore-artifact-branch-file "$FE_COVERAGE_NAME.csv" + if compgen -G "/tmp/$SCENE_METRICS_NAME*.csv" >/dev/null; then + echo "scene_metrics_found=true" >> "$GITHUB_OUTPUT" + fi + if [ -f "/tmp/$FE_COVERAGE_NAME.csv" ]; then + echo "fe_coverage_found=true" >> "$GITHUB_OUTPUT" + fi - name: Restore FPS metrics + if: steps.restore_metric_history.outputs.scene_metrics_found != 'true' uses: actions/cache/restore@v5 with: path: | - fps_value.txt /tmp/${{ env.SCENE_METRICS_NAME }}*.csv key: ${{ env.FPS_METRICS_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} restore-keys: | ${{ env.FPS_METRICS_CACHE_KEY }}- - - name: Track FPS value - run: | - if [ -f fps_value.txt ]; then - PREV=$(cat fps_value.txt) - else - PREV=100 - fi - echo "Previous value: $PREV fps" - percent_change=$(CURRENT_VALUE="$FPS_VALUE" PREVIOUS_VALUE="$PREV" scripts/ci/percent-change) - echo "$FPS_VALUE fps ($percent_change% change)" - echo "PERCENT_CHANGE=$percent_change" >> "$GITHUB_ENV" - echo "$FPS_VALUE" > fps_value.txt - - name: Restore frontend coverage value - if: always() + if: ${{ always() && steps.restore_metric_history.outputs.fe_coverage_found != 'true' }} uses: actions/cache/restore@v5 with: - path: ${{ env.FE_COVERAGE_NAME }}.csv + path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv key: ${{ env.FE_COVERAGE_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} restore-keys: | ${{ env.FE_COVERAGE_CACHE_KEY }}- + - name: Run playwright + run: scripts/ci/run-playwright-render + + - name: Track FPS value + run: | + PREV=$(scripts/ci/previous-fps-value) + echo "Previous value: $PREV fps" + percent_change=$(CURRENT_VALUE="$FPS_VALUE" PREVIOUS_VALUE="$PREV" scripts/ci/percent-change) + echo "$FPS_VALUE fps ($percent_change% change)" + echo "PERCENT_CHANGE=$percent_change" >> "$GITHUB_ENV" + - name: Plot metric CSVs if: always() run: | @@ -124,16 +135,11 @@ jobs: [ -f "$csv" ] || continue bun scripts/metric_plot.js "$csv" done - if [ -f "$FE_COVERAGE_NAME.csv" ]; then - bun scripts/metric_plot.js "$FE_COVERAGE_NAME.csv" - fi - name: Save FPS metrics - if: ${{ hashFiles('fps_value.txt') != '' }} uses: actions/cache/save@v5 with: path: | - fps_value.txt /tmp/${{ env.SCENE_METRICS_NAME }}*.csv key: ${{ env.FPS_METRICS_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} @@ -173,9 +179,7 @@ jobs: sudo docker compose logs --no-color --tail=500 || true - name: Publish image links - if: success() && env.NOTIFY == 'true' - env: - ARTIFACT_BRANCH: ci-artifacts + if: success() && env.STAGING == 'true' run: | . scripts/ci/render-env worktree=$(mktemp -d) @@ -196,7 +200,13 @@ jobs: done cp "/tmp/$FPS_SAMPLES_NAME.csv" "$worktree/build/latest/$FPS_SAMPLES_NAME.csv" cp "/tmp/$FPS_SAMPLES_NAME.csv" "$worktree/build/${GITHUB_RUN_ID}/$FPS_SAMPLES_NAME.csv" + for metric in "/tmp/$SCENE_METRICS_NAME"*.csv "/tmp/$FE_COVERAGE_NAME.csv"; do + [ -f "$metric" ] && cp "$metric" "$worktree/$(basename "$metric")" + done git -C "$worktree" add build + for artifact in "$SCENE_METRICS_NAME"*.csv "$FE_COVERAGE_NAME.csv"; do + [ -f "$worktree/$artifact" ] && git -C "$worktree" add "$artifact" + done git -C "$worktree" commit -m "Update CI image artifacts for ${GITHUB_RUN_ID}" || true git -C "$worktree" push origin "HEAD:$ARTIFACT_BRANCH" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44ade5acae..f8d18173e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,7 @@ on: env: NOTIFY: ${{ github.ref_name == 'staging' }} + STAGING: ${{ github.ref_name == 'staging' }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} FE_COVERAGE_NAME: fe_coverage CACHE_KEY_SUFFIX: ${{ github.run_id }}-${{ github.run_attempt }} @@ -60,6 +61,7 @@ jobs: - name: Set up CI uses: ./.github/actions/setup-ci with: + rails-env: test save-docker-cache: "true" - name: Run Ruby linters @@ -70,7 +72,7 @@ jobs: - name: Check app coverage status if: ${{ always() && hashFiles('coverage_api/index.html') != '' }} - run: sudo docker compose run --rm web bundle exec rake check_file_coverage:api || [ "$NOTIFY" = "true" ] + run: sudo docker compose run --rm web bundle exec rake check_file_coverage:api || [ "$STAGING" = "true" ] - name: Upload app coverage to Codecov if: ${{ hashFiles('coverage_api/coverage.xml') != '' }} @@ -105,7 +107,7 @@ jobs: if: ${{ always() && (hashFiles('coverage_fe/index.html') != '' || hashFiles('coverage_fe/coverage-final.json') != '' || hashFiles('coverage_fe/lcov.info') != '') }} env: GITHUB_PULL_REQUEST: ${{ github.event.pull_request.html_url || '/0' }} - run: sudo docker compose run --rm -e GITHUB_SHA="$GITHUB_SHA" -e GITHUB_REF_NAME="$GITHUB_REF_NAME" -e GITHUB_PULL_REQUEST="$GITHUB_PULL_REQUEST" web bundle exec rake coverage:run || [ "$NOTIFY" = "true" ] + run: sudo docker compose run --rm -e GITHUB_SHA="$GITHUB_SHA" -e GITHUB_REF_NAME="$GITHUB_REF_NAME" -e GITHUB_PULL_REQUEST="$GITHUB_PULL_REQUEST" web bundle exec rake coverage:run || [ "$STAGING" = "true" ] - name: Check frontend file coverage status if: always() @@ -126,11 +128,19 @@ jobs: if: always() uses: actions/cache/restore@v5 with: - path: ${{ env.FE_COVERAGE_NAME }}.csv + path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv key: ${{ env.FE_COVERAGE_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} restore-keys: | ${{ env.FE_COVERAGE_CACHE_KEY }}- + - name: Restore frontend coverage history fallback + if: always() + run: | + [ -f "/tmp/$FE_COVERAGE_NAME.csv" ] && exit 0 + + . scripts/ci/render-env + scripts/ci/restore-artifact-branch-file "$FE_COVERAGE_NAME.csv" + - name: Track frontend coverage value if: always() run: | @@ -140,13 +150,13 @@ jobs: fi eval "$(awk -F: '/^LH:/ { covered += $2 } /^LF:/ { total += $2 } END { printf "COVERED=%d\nTOTAL=%d\n", covered, total }' coverage_fe/lcov.info)" FE_VALUE=$(awk "BEGIN { printf \"%.2f\", ($COVERED / $TOTAL) * 100 }") - fe_coverage_csv="$FE_COVERAGE_NAME.csv" + fe_coverage_csv="/tmp/$FE_COVERAGE_NAME.csv" if [ -f "$fe_coverage_csv" ]; then PREV_FE=$(python3 - <<'PY' import csv import os - with open(f"{os.environ['FE_COVERAGE_NAME']}.csv", newline="") as f: + with open(f"/tmp/{os.environ['FE_COVERAGE_NAME']}.csv", newline="") as f: reader = csv.reader(f) next(reader, None) rows = [float(row[0]) for row in reader if row and row[0]] @@ -166,10 +176,10 @@ jobs: cat "$fe_coverage_csv" - name: Save frontend coverage value - if: ${{ always() && hashFiles(format('{0}.csv', env.FE_COVERAGE_NAME)) != '' }} + if: ${{ always() && env.COVERAGE_AVAILABLE == 'true' }} uses: actions/cache/save@v5 with: - path: ${{ env.FE_COVERAGE_NAME }}.csv + path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv key: ${{ env.FE_COVERAGE_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} - name: Upload test results @@ -185,7 +195,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: fe-coverage-csv - path: ${{ env.FE_COVERAGE_NAME }}.csv + path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv if-no-files-found: warn - name: On failure diff --git a/.gitignore b/.gitignore index 5513db4f0a..a0f87065dd 100755 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ upgrade_deps.sh storage/* tmp module_graph.* +scripts/ci/__pycache__ diff --git a/scripts/ci/previous-fps-value b/scripts/ci/previous-fps-value new file mode 100755 index 0000000000..993dc1c6bc --- /dev/null +++ b/scripts/ci/previous-fps-value @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +import csv +import os + +path = f"/tmp/{os.environ['SCENE_METRICS_NAME']}.csv" +fallback = os.environ.get("FALLBACK_FPS_VALUE", "100") + +try: + with open(path, newline="") as f: + rows = list(csv.DictReader(f)) +except FileNotFoundError: + rows = [] + +values = [] +for row in rows[:-1]: + try: + values.append(float(row["FPS"])) + except (KeyError, TypeError, ValueError): + pass + +print(f"{values[-1]:.2f}" if values else fallback) diff --git a/scripts/ci/render-env b/scripts/ci/render-env index 922d1ccd26..d5e8c77995 100644 --- a/scripts/ci/render-env +++ b/scripts/ci/render-env @@ -2,3 +2,4 @@ : "${FPS_SAMPLES_NAME:=fps_samples}" : "${SCENE_METRICS_NAME:=scene_metrics}" : "${SCENE_METRICS_HEADER:=epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes}" +: "${ARTIFACT_BRANCH:=ci-artifacts}" diff --git a/scripts/ci/restore-artifact-branch-file b/scripts/ci/restore-artifact-branch-file new file mode 100755 index 0000000000..90d22f9a92 --- /dev/null +++ b/scripts/ci/restore-artifact-branch-file @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -f scripts/ci/render-env ]; then + # shellcheck disable=SC1091 + . scripts/ci/render-env +fi + +source_pattern="${1:?source path is required}" + +if ! git ls-remote --exit-code --heads origin "$ARTIFACT_BRANCH" >/dev/null; then + exit 0 +fi + +git fetch origin "$ARTIFACT_BRANCH" + +git ls-tree -r --name-only "origin/$ARTIFACT_BRANCH" | while IFS= read -r source_path; do + case "$source_path" in + $source_pattern) + destination_path="/tmp/$source_path" + mkdir -p "$(dirname "$destination_path")" + git show "origin/$ARTIFACT_BRANCH:$source_path" > "$destination_path" + ;; + esac +done diff --git a/scripts/fps.js b/scripts/fps.js index cf65fd5ebe..68f1cfa32e 100644 --- a/scripts/fps.js +++ b/scripts/fps.js @@ -100,6 +100,7 @@ async function main() { await page.goto(url, { waitUntil: 'domcontentloaded' }); await prepareStressResources(page, url); if (screenshotOnly) { + await page.waitForTimeout(1000); fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); await page.screenshot({ path: screenshotPath, From e186ddb39f987dfca9e8478050b431cdb57dd4ad Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 21 May 2026 14:32:45 -0700 Subject: [PATCH 62/83] add new urls to ci --- scripts/ci/render-urls.json | 49 ++++++++++++++++ scripts/ci/run-playwright-render | 55 +++++++++++------- scripts/fps.js | 98 +++++++++++++++++++++++++++----- 3 files changed, 167 insertions(+), 35 deletions(-) create mode 100644 scripts/ci/render-urls.json diff --git a/scripts/ci/render-urls.json b/scripts/ci/render-urls.json new file mode 100644 index 0000000000..933f518f71 --- /dev/null +++ b/scripts/ci/render-urls.json @@ -0,0 +1,49 @@ +[ + { + "name": "promo", + "mode": "fps", + "url": "http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" + }, + { + "name": "login", + "mode": "screenshot", + "url": "http://localhost:3000/" + }, + { + "name": "os", + "mode": "screenshot", + "url": "http://localhost:3000/os" + }, + { + "name": "demo", + "mode": "screenshot", + "url": "http://localhost:3000/demo" + }, + { + "name": "password_reset", + "mode": "screenshot", + "url": "http://localhost:3000/password_reset/abc" + }, + { + "name": "404", + "mode": "screenshot", + "url": "http://localhost:3000/404" + }, + { + "name": "app", + "mode": "fps", + "url": "http://localhost:3000/try_farmbot?productLine=genesis_1.8" + }, + { + "name": "stress", + "mode": "fps", + "url": "http://localhost:3000/try_farmbot?productLine=genesis_xl_1.8_stress_1000" + }, + { + "name": "water", + "mode": "fps", + "url": "http://localhost:3000/app/designer/sequences/Water_all", + "click": "Run", + "state": "app" + } +] diff --git a/scripts/ci/run-playwright-render b/scripts/ci/run-playwright-render index 5f86f2e8da..a9f0af0b39 100755 --- a/scripts/ci/run-playwright-render +++ b/scripts/ci/run-playwright-render @@ -18,23 +18,9 @@ attempts="${ATTEMPTS:-2}" : "${SCENE_METRICS_NAME:?SCENE_METRICS_NAME is required}" : "${SCENE_METRICS_HEADER:?SCENE_METRICS_HEADER is required}" GITHUB_ENV="${GITHUB_ENV:-/tmp/render-github-env}" +URLS_JSON="${URLS_JSON:-scripts/ci/render-urls.json}" -urls=( - "promo|fps|http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" - "login|screenshot|http://localhost:3000/" - "os|screenshot|http://localhost:3000/os" - "demo|screenshot|http://localhost:3000/demo" - "password_reset|screenshot|http://localhost:3000/password_reset/abc" - "404|screenshot|http://localhost:3000/404" - "app|fps|http://localhost:3000/try_farmbot?productLine=genesis_1.8" - # "stress|fps|http://localhost:3000/try_farmbot?productLine=genesis_xl_1.8_stress_250" -) - -for entry in "${urls[@]}"; do - name="${entry%%|*}" - rest="${entry#*|}" - mode="${rest%%|*}" - url="${rest#*|}" +while IFS=$'\034' read -r -u 3 name mode url click state; do if [ "$name" = "promo" ]; then screenshot_path="/tmp/$PROMO_IMAGE.png" fps_samples_path="/tmp/$FPS_SAMPLES_NAME.csv" @@ -46,6 +32,16 @@ for entry in "${urls[@]}"; do fi for _ in $(seq 1 "$attempts"); do + fps_args=( + --name "$name" + --url "$url" + --screenshot-path "$screenshot_path" + --fps-samples-path "$fps_samples_path" + ) + [ -n "$click" ] && fps_args+=(--click "$click") + [ -n "$state" ] && fps_args+=(--state "$state") + + echo -e "\n\n\n" if [ "$mode" = "screenshot" ]; then echo "Attempting screenshot via ${url}" else @@ -56,7 +52,7 @@ for entry in "${urls[@]}"; do fi if [ "$mode" = "screenshot" ]; then - if ! screenshot_output=$(SCREENSHOT_ONLY=1 bun scripts/fps.js "${url}" "$screenshot_path"); then + if ! screenshot_output=$(bun scripts/fps.js --screenshot-only "${fps_args[@]}"); then echo "${screenshot_output}" continue fi @@ -64,7 +60,7 @@ for entry in "${urls[@]}"; do continue 2 fi - if ! fps_output=$(bun scripts/fps.js "${url}" "$screenshot_path" "$fps_samples_path"); then + if ! fps_output=$(bun scripts/fps.js "${fps_args[@]}"); then echo "${fps_output}" continue fi @@ -76,7 +72,7 @@ for entry in "${urls[@]}"; do printf '%s\n' "$SCENE_METRICS_HEADER" > "$scene_metrics_path" fi printf '%s\n' "$scene_metrics" >> "$scene_metrics_path" - echo $scene_metrics_path + echo "SCENE_METRICS=${scene_metrics_path}" if [ "$name" = "promo" ]; then echo "FPS_VALUE=${fps_value}" >> "$GITHUB_ENV" @@ -88,11 +84,30 @@ for entry in "${urls[@]}"; do echo "FPS check failed for ${url}" exit 1 -done +done 3< <(python3 - "$URLS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as f: + entries = json.load(f) + +for entry in entries: + print("\034".join([ + entry["name"], + entry["mode"], + entry["url"], + entry.get("click", ""), + entry.get("state", ""), + ])) +PY +) if [ -z "${GITHUB_ACTIONS:-}" ]; then + echo -e "\n\n\n" + echo "Plotting metrics..." for csv in /tmp/*.csv; do [ -f "$csv" ] || continue bun scripts/metric_plot.js "$csv" done + echo -e "\n\n\n" fi diff --git a/scripts/fps.js b/scripts/fps.js index 68f1cfa32e..6ba48751ae 100644 --- a/scripts/fps.js +++ b/scripts/fps.js @@ -3,16 +3,59 @@ const fs = require('fs'); const path = require('path'); const { prepareStressResources } = require('./fps_stress_resources'); -const url = process.argv[2]; -const screenshotPath = process.argv[3] || '/tmp/fps.png'; -const samplesCsvPath = process.argv[4] || '/tmp/fps_samples.csv'; +function parseArgs(argv) { + const options = { + screenshotPath: '/tmp/fps.png', + samplesCsvPath: '/tmp/fps_samples.csv', + screenshotOnly: false, + }; + const valueOptions = { + '--name': 'name', + '--url': 'url', + '--screenshot-path': 'screenshotPath', + '--fps-samples-path': 'samplesCsvPath', + '--click': 'click', + '--state': 'state', + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '-h' || arg === '--help') { + options.help = true; + continue; + } + if (arg === '--screenshot-only') { + options.screenshotOnly = true; + continue; + } + const optionName = valueOptions[arg]; + if (!optionName) { throw new Error(`Unknown argument: ${arg}`); } + const value = argv[i + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${arg}`); + } + options[optionName] = value; + i++; + } + + return options; +} + +const options = parseArgs(process.argv.slice(2)); +const name = options.name; +const url = options.url; +const screenshotPath = options.screenshotPath; +const samplesCsvPath = options.samplesCsvPath; const maxLoadingSamples = 240; const postLoadSamples = 10; const sampleIntervalMs = 1000; const ci = Boolean(process.env.CI); const openWindow = Boolean(process.env.DISPLAY && process.env.OPEN_WINDOW); const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; -const screenshotOnly = Boolean(process.env.SCREENSHOT_ONLY); +const screenshotOnly = options.screenshotOnly; +const click = options.click; +const state = options.state ? path.join('/tmp', `${options.state}.json`) : undefined; +const saveState = path.join('/tmp', `${name}.json`); const chromiumArgs = [ '--no-sandbox', '--disable-setuid-sandbox', @@ -48,16 +91,22 @@ const webglRenderer = page => function printUsage() { console.log([ - 'Usage: bun scripts/fps.js [screenshot_path] [fps_samples_csv_path]', + 'Usage: bun scripts/fps.js --name --url [options]', '', - 'Arguments:', - ' url Page URL that exposes window.__fps and window.__scene_metrics.', - ' screenshot_path Optional full-page screenshot PNG path. Default: /tmp/fps.png', - ' fps_samples_csv_path Optional FPS samples CSV path. Default: /tmp/fps_samples.csv', + 'Options:', + ' --name Scenario name used for storage state output.', + ' --url Page URL that exposes window.__fps and window.__scene_metrics.', + ' --screenshot-path Full-page screenshot PNG path. Default: /tmp/fps.png', + ' --fps-samples-path FPS samples CSV path. Default: /tmp/fps_samples.csv', + ' --screenshot-only Take a screenshot without FPS metrics.', + ' --click Click an element by title after page load.', + ' --state <name> Load cookies and localStorage from /tmp/<name>.json.', '', 'Environment:', - ' PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH Path to a Chrome/Chromium binary.', - ' SCREENSHOT_ONLY=1 Take a screenshot without FPS metrics.', + ' CI Use CI browser launch behavior.', + ' DISPLAY Display server used for headed local runs.', + ' OPEN_WINDOW Open a visible browser window when DISPLAY is set.', + ' PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH Path to a Chrome/Chromium binary.', ].join('\n')); } @@ -84,8 +133,14 @@ function saveFpsSamplesCsv(samples, destination) { .join('\n')}\n`); } +async function saveStorage(page) { + fs.mkdirSync(path.dirname(saveState), { recursive: true }); + await page.context().storageState({ path: saveState }); + console.log(`SAVE_STATE=${saveState}`); +} + async function main() { - console.log(`Launching Chromium with args: ${chromiumArgs.join(' ')}`); + console.log(`Launching Chromium with args:\n ${chromiumArgs.join('\n ')}\n`); if (executablePath) { console.log(`CHROMIUM_EXECUTABLE_PATH=${executablePath}`); } @@ -94,11 +149,20 @@ async function main() { executablePath, args: chromiumArgs, }); - const page = await browser.newPage(); + const contextOptions = state ? { storageState: state } : {}; + if (state) { + console.log(`STATE=${state}`); + } + const context = await browser.newContext(contextOptions); + const page = await context.newPage(); page.setDefaultTimeout(60_000); try { await page.goto(url, { waitUntil: 'domcontentloaded' }); await prepareStressResources(page, url); + if (click) { + await page.getByTitle(click).click(); + console.log(`CLICK=${click}`); + } if (screenshotOnly) { await page.waitForTimeout(1000); fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); @@ -182,6 +246,7 @@ async function main() { console.log(`FPS_SCREENSHOT=${screenshotPath}`); saveFpsSamplesCsv(sampleValues, samplesCsvPath); console.log(`FPS_SAMPLES_CSV=${samplesCsvPath}`); + await saveStorage(page); } catch (err) { console.error('Failed to read window.__fps:', err.message || err); process.exitCode = 1; @@ -190,9 +255,12 @@ async function main() { } } -if (!url || url === '-h' || url === '--help') { +if (options.help) { + printUsage(); + process.exitCode = 0; +} else if (!name || !url) { printUsage(); - process.exitCode = url ? 0 : 1; + process.exitCode = 1; } else { main().catch((err) => { console.error('Unexpected error:', err); From 917c1c0537c13cd974f60b3359eeb86993e8fd3e Mon Sep 17 00:00:00 2001 From: gabrielburnworth <gabrielburnworth@users.noreply.github.com> Date: Thu, 21 May 2026 15:20:03 -0700 Subject: [PATCH 63/83] fix ci data and plots --- .github/workflows/render.yml | 4 ++-- scripts/metric_plot.js | 12 +++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml index 682e372620..51e2f2bbfd 100644 --- a/.github/workflows/render.yml +++ b/.github/workflows/render.yml @@ -204,8 +204,8 @@ jobs: [ -f "$metric" ] && cp "$metric" "$worktree/$(basename "$metric")" done git -C "$worktree" add build - for artifact in "$SCENE_METRICS_NAME"*.csv "$FE_COVERAGE_NAME.csv"; do - [ -f "$worktree/$artifact" ] && git -C "$worktree" add "$artifact" + for artifact in "$worktree/$SCENE_METRICS_NAME"*.csv "$worktree/$FE_COVERAGE_NAME.csv"; do + [ -f "$artifact" ] && git -C "$worktree" add "$(basename "$artifact")" done git -C "$worktree" commit -m "Update CI image artifacts for ${GITHUB_RUN_ID}" || true git -C "$worktree" push origin "HEAD:$ARTIFACT_BRANCH" diff --git a/scripts/metric_plot.js b/scripts/metric_plot.js index f4214a9786..157b6d6952 100644 --- a/scripts/metric_plot.js +++ b/scripts/metric_plot.js @@ -247,6 +247,12 @@ const parseCsv = content => { return { headers, rows }; }; +const title = (basename, prefix) => { + const name = basename.replace(/\.csv$/, '').replace(/_/g, ' '); + const suffix = name.replace(prefix.toLowerCase(), '').trim(); + return suffix ? `${prefix}: ${suffix}` : prefix; +} + const inferCsvPlot = ({ headers, rows }, filename = '') => { const basename = path.basename(filename); const isSceneMetricsCsv = /^scene_metrics(?:_[^/]+)?\.csv$/.test(basename); @@ -284,7 +290,7 @@ const inferCsvPlot = ({ headers, rows }, filename = '') => { if (headers.includes('FPS')) { if (isSceneMetricsCsv) { return { - title: 'Scene metrics', + title: title(basename, 'Scene metrics'), xLabel: 'Runs', hideStats: true, series: plottableHeaders.map(header => ({ @@ -297,7 +303,7 @@ const inferCsvPlot = ({ headers, rows }, filename = '') => { }; } return { - title: 'FPS samples', + title: title(basename, 'FPS samples'), xLabel: 'Runs', samples: rows.map((row, index) => sampleFor(row, index, 'FPS')), }; @@ -313,7 +319,7 @@ const inferCsvPlot = ({ headers, rows }, filename = '') => { : undefined; const highlightIndex = rows.findIndex(row => row.chosen === 'true'); return { - title: 'FPS samples', + title: title(basename, 'FPS samples'), xLabel: xHeader ? 'Seconds' : 'Samples', samples: rows.map((row, index) => sampleFor(row, index, 'fps')), statsAfterLoaded: true, From b0ab13550416eac54cda559c7920031ffdc4c29d Mon Sep 17 00:00:00 2001 From: gabrielburnworth <gabrielburnworth@users.noreply.github.com> Date: Thu, 21 May 2026 15:57:52 -0700 Subject: [PATCH 64/83] fix ci coverage data source --- .github/workflows/render.yml | 14 +++++++++----- .github/workflows/test.yml | 20 +++++++++++--------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml index 51e2f2bbfd..26540935eb 100644 --- a/.github/workflows/render.yml +++ b/.github/workflows/render.yml @@ -90,13 +90,9 @@ jobs: run: | . scripts/ci/render-env scripts/ci/restore-artifact-branch-file "$SCENE_METRICS_NAME*.csv" - scripts/ci/restore-artifact-branch-file "$FE_COVERAGE_NAME.csv" if compgen -G "/tmp/$SCENE_METRICS_NAME*.csv" >/dev/null; then echo "scene_metrics_found=true" >> "$GITHUB_OUTPUT" fi - if [ -f "/tmp/$FE_COVERAGE_NAME.csv" ]; then - echo "fe_coverage_found=true" >> "$GITHUB_OUTPUT" - fi - name: Restore FPS metrics if: steps.restore_metric_history.outputs.scene_metrics_found != 'true' @@ -109,7 +105,7 @@ jobs: ${{ env.FPS_METRICS_CACHE_KEY }}- - name: Restore frontend coverage value - if: ${{ always() && steps.restore_metric_history.outputs.fe_coverage_found != 'true' }} + if: always() uses: actions/cache/restore@v5 with: path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv @@ -117,6 +113,14 @@ jobs: restore-keys: | ${{ env.FE_COVERAGE_CACHE_KEY }}- + - name: Restore frontend coverage history fallback + if: always() + run: | + [ -f "/tmp/$FE_COVERAGE_NAME.csv" ] && exit 0 + + . scripts/ci/render-env + scripts/ci/restore-artifact-branch-file "$FE_COVERAGE_NAME.csv" + - name: Run playwright run: scripts/ci/run-playwright-render diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f8d18173e2..90a6cce55c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -124,8 +124,18 @@ jobs: format: lcov fail-on-error: false - - name: Restore frontend coverage value + - name: Restore frontend coverage history + id: restore_fe_coverage_history if: always() + run: | + . scripts/ci/render-env + scripts/ci/restore-artifact-branch-file "$FE_COVERAGE_NAME.csv" + if [ -f "/tmp/$FE_COVERAGE_NAME.csv" ]; then + echo "fe_coverage_found=true" >> "$GITHUB_OUTPUT" + fi + + - name: Restore frontend coverage cache fallback + if: ${{ always() && steps.restore_fe_coverage_history.outputs.fe_coverage_found != 'true' }} uses: actions/cache/restore@v5 with: path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv @@ -133,14 +143,6 @@ jobs: restore-keys: | ${{ env.FE_COVERAGE_CACHE_KEY }}- - - name: Restore frontend coverage history fallback - if: always() - run: | - [ -f "/tmp/$FE_COVERAGE_NAME.csv" ] && exit 0 - - . scripts/ci/render-env - scripts/ci/restore-artifact-branch-file "$FE_COVERAGE_NAME.csv" - - name: Track frontend coverage value if: always() run: | From 7d20b321b40f4f8e4f3853c649aa6bd7b51c38a6 Mon Sep 17 00:00:00 2001 From: gabrielburnworth <gabrielburnworth@users.noreply.github.com> Date: Fri, 22 May 2026 15:38:56 -0700 Subject: [PATCH 65/83] clean up ci and render summary --- .github/workflows/render.yml | 50 +---- .github/workflows/test.yml | 52 +---- scripts/ci/combine-render-images | 151 ++++++++++++++ scripts/ci/create-compare-link | 19 ++ scripts/ci/export-render-env | 20 ++ scripts/ci/git-publish-render-artifacts | 46 +++++ ...-file => git-restore-artifact-branch-file} | 6 +- scripts/ci/percent-change | 10 +- scripts/ci/previous-fps-value | 13 +- scripts/ci/render-env | 10 +- scripts/ci/render-url-records | 15 ++ scripts/ci/render-urls.json | 2 +- scripts/ci/run-playwright-render | 40 ++-- scripts/ci/send-notification | 33 ++- scripts/ci/test_python_scripts.py | 188 ++++++++++++++++++ scripts/ci/track-fe-coverage | 50 +++++ 16 files changed, 569 insertions(+), 136 deletions(-) create mode 100755 scripts/ci/combine-render-images create mode 100755 scripts/ci/create-compare-link create mode 100755 scripts/ci/export-render-env create mode 100755 scripts/ci/git-publish-render-artifacts rename scripts/ci/{restore-artifact-branch-file => git-restore-artifact-branch-file} (84%) create mode 100755 scripts/ci/render-url-records create mode 100644 scripts/ci/test_python_scripts.py create mode 100755 scripts/ci/track-fe-coverage diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml index 26540935eb..6e8e19fd8a 100644 --- a/.github/workflows/render.yml +++ b/.github/workflows/render.yml @@ -10,8 +10,6 @@ env: NOTIFY: ${{ github.ref_name == 'staging' }} STAGING: ${{ github.ref_name == 'staging' }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - SCENE_METRICS_NAME: scene_metrics - FE_COVERAGE_NAME: fe_coverage CACHE_KEY_SUFFIX: ${{ github.run_id }}-${{ github.run_attempt }} FPS_METRICS_CACHE_KEY: fps-metrics-${{ github.ref_name }} FE_COVERAGE_CACHE_KEY: fe-coverage-p-${{ github.ref_name }} @@ -24,6 +22,9 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Set render env + run: scripts/ci/export-render-env + - name: Set up CI uses: ./.github/actions/setup-ci with: @@ -88,8 +89,7 @@ jobs: - name: Restore metric history id: restore_metric_history run: | - . scripts/ci/render-env - scripts/ci/restore-artifact-branch-file "$SCENE_METRICS_NAME*.csv" + scripts/ci/git-restore-artifact-branch-file "$SCENE_METRICS_NAME*.csv" if compgen -G "/tmp/$SCENE_METRICS_NAME*.csv" >/dev/null; then echo "scene_metrics_found=true" >> "$GITHUB_OUTPUT" fi @@ -118,8 +118,7 @@ jobs: run: | [ -f "/tmp/$FE_COVERAGE_NAME.csv" ] && exit 0 - . scripts/ci/render-env - scripts/ci/restore-artifact-branch-file "$FE_COVERAGE_NAME.csv" + scripts/ci/git-restore-artifact-branch-file "$FE_COVERAGE_NAME.csv" - name: Run playwright run: scripts/ci/run-playwright-render @@ -128,7 +127,7 @@ jobs: run: | PREV=$(scripts/ci/previous-fps-value) echo "Previous value: $PREV fps" - percent_change=$(CURRENT_VALUE="$FPS_VALUE" PREVIOUS_VALUE="$PREV" scripts/ci/percent-change) + percent_change=$(scripts/ci/percent-change --new "$FPS_VALUE" --old "$PREV") echo "$FPS_VALUE fps ($percent_change% change)" echo "PERCENT_CHANGE=$percent_change" >> "$GITHUB_ENV" @@ -184,47 +183,14 @@ jobs: - name: Publish image links if: success() && env.STAGING == 'true' - run: | - . scripts/ci/render-env - worktree=$(mktemp -d) - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - if git ls-remote --exit-code --heads origin "$ARTIFACT_BRANCH" >/dev/null; then - git fetch origin "$ARTIFACT_BRANCH" - git worktree add "$worktree" "origin/$ARTIFACT_BRANCH" - else - git worktree add --detach "$worktree" - git -C "$worktree" switch --orphan "$ARTIFACT_BRANCH" - fi - - mkdir -p "$worktree/build/latest" "$worktree/build/${GITHUB_RUN_ID}" - for image in "$PROMO_IMAGE" "$FPS_SAMPLES_NAME"; do - cp "/tmp/$image.png" "$worktree/build/latest/$image.png" - cp "/tmp/$image.png" "$worktree/build/${GITHUB_RUN_ID}/$image.png" - done - cp "/tmp/$FPS_SAMPLES_NAME.csv" "$worktree/build/latest/$FPS_SAMPLES_NAME.csv" - cp "/tmp/$FPS_SAMPLES_NAME.csv" "$worktree/build/${GITHUB_RUN_ID}/$FPS_SAMPLES_NAME.csv" - for metric in "/tmp/$SCENE_METRICS_NAME"*.csv "/tmp/$FE_COVERAGE_NAME.csv"; do - [ -f "$metric" ] && cp "$metric" "$worktree/$(basename "$metric")" - done - git -C "$worktree" add build - for artifact in "$worktree/$SCENE_METRICS_NAME"*.csv "$worktree/$FE_COVERAGE_NAME.csv"; do - [ -f "$artifact" ] && git -C "$worktree" add "$(basename "$artifact")" - done - git -C "$worktree" commit -m "Update CI image artifacts for ${GITHUB_RUN_ID}" || true - git -C "$worktree" push origin "HEAD:$ARTIFACT_BRANCH" - - raw_base="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${ARTIFACT_BRANCH}/build/${GITHUB_RUN_ID}" - echo "SCREENSHOT_URL=${raw_base}/${PROMO_IMAGE}.png" >> "$GITHUB_ENV" - echo "FPS_PLOT_URL=${raw_base}/${FPS_SAMPLES_NAME}.png" >> "$GITHUB_ENV" + run: scripts/ci/git-publish-render-artifacts - name: Send notification if: success() && env.NOTIFY == 'true' run: | RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" for MESSAGE in \ - "<${SCREENSHOT_URL:-$RUN_URL}|screenshot>" \ - "<${FPS_PLOT_URL:-$RUN_URL}|fps>" + "<${RENDER_SUMMARY_URL:-$RUN_URL}|render summary>" do scripts/ci/send-notification "$MESSAGE" done diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90a6cce55c..d993e6b54f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,6 @@ env: NOTIFY: ${{ github.ref_name == 'staging' }} STAGING: ${{ github.ref_name == 'staging' }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - FE_COVERAGE_NAME: fe_coverage CACHE_KEY_SUFFIX: ${{ github.run_id }}-${{ github.run_attempt }} FE_COVERAGE_CACHE_KEY: fe-coverage-p-${{ github.ref_name }} @@ -26,6 +25,12 @@ jobs: with: fetch-depth: 100 + - name: Set render env + run: scripts/ci/export-render-env + + - name: Run CI script tests + run: python3 -m unittest scripts/ci/test_python_scripts.py + - name: Send notification if: env.NOTIFY == 'true' run: scripts/ci/send-notification "CI run started" @@ -37,20 +42,7 @@ jobs: - name: Create compare link if: env.NOTIFY == 'true' run: | - DEPLOYS_URL_API="https://api.github.com/repos/Farmbot/Farmbot-Web-App/deployments" - COMPARE_URL_WEB="https://github.com/Farmbot/Farmbot-Web-App/compare/" - data=$(curl -fsS "$DEPLOYS_URL_API") || data="[]" - last_sha=$(DEPLOYS_DATA="$data" python3 - <<'PY' - import json - import os - - data = json.loads(os.environ.get("DEPLOYS_DATA") or "[]") - deploy_index = 0 - sha = (data[deploy_index] or {}).get("sha") if data else "" - print(sha or "") - PY - ) - compare_url="$COMPARE_URL_WEB$last_sha...${GITHUB_SHA}" + compare_url=$(scripts/ci/create-compare-link) echo "$compare_url" echo "COMPARE_URL=$compare_url" >> "$GITHUB_ENV" @@ -128,8 +120,7 @@ jobs: id: restore_fe_coverage_history if: always() run: | - . scripts/ci/render-env - scripts/ci/restore-artifact-branch-file "$FE_COVERAGE_NAME.csv" + scripts/ci/git-restore-artifact-branch-file "$FE_COVERAGE_NAME.csv" if [ -f "/tmp/$FE_COVERAGE_NAME.csv" ]; then echo "fe_coverage_found=true" >> "$GITHUB_OUTPUT" fi @@ -150,32 +141,7 @@ jobs: echo "skipping" exit 0 fi - eval "$(awk -F: '/^LH:/ { covered += $2 } /^LF:/ { total += $2 } END { printf "COVERED=%d\nTOTAL=%d\n", covered, total }' coverage_fe/lcov.info)" - FE_VALUE=$(awk "BEGIN { printf \"%.2f\", ($COVERED / $TOTAL) * 100 }") - fe_coverage_csv="/tmp/$FE_COVERAGE_NAME.csv" - if [ -f "$fe_coverage_csv" ]; then - PREV_FE=$(python3 - <<'PY' - import csv - import os - - with open(f"/tmp/{os.environ['FE_COVERAGE_NAME']}.csv", newline="") as f: - reader = csv.reader(f) - next(reader, None) - rows = [float(row[0]) for row in reader if row and row[0]] - print(f"{rows[-1]:.2f}" if rows else "99.4") - PY - ) - else - PREV_FE=99.4 - fi - percent_change=$(CURRENT_VALUE="$FE_VALUE" PREVIOUS_VALUE="$PREV_FE" scripts/ci/percent-change) - echo "Covered lines: $COVERED, Total lines: $TOTAL, Coverage: $FE_VALUE%" - echo "$FE_VALUE% ($percent_change% change)" - if [ ! -f "$fe_coverage_csv" ]; then - printf '%s\n' "percent,covered lines,total lines,percent change" > "$fe_coverage_csv" - fi - printf '%s\n' "$FE_VALUE,$COVERED,$TOTAL,$percent_change" >> "$fe_coverage_csv" - cat "$fe_coverage_csv" + scripts/ci/track-fe-coverage - name: Save frontend coverage value if: ${{ always() && env.COVERAGE_AVAILABLE == 'true' }} diff --git a/scripts/ci/combine-render-images b/scripts/ci/combine-render-images new file mode 100755 index 0000000000..be6820d6f9 --- /dev/null +++ b/scripts/ci/combine-render-images @@ -0,0 +1,151 @@ +#!/usr/bin/env bun +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('playwright'); + +const requiredEnv = name => { + const value = process.env[name]; + if (!value) { + console.error(`${name} is required`); + process.exit(1); + } + return value; +}; + +const output = `/tmp/${requiredEnv('COMBINED_RENDER_IMAGE')}.png`; +const screenshotName = requiredEnv('SCREENSHOT_NAME'); +const fpsSamplesName = requiredEnv('FPS_SAMPLES_NAME'); +const sceneMetricsName = requiredEnv('SCENE_METRICS_NAME'); +const feCoverageName = requiredEnv('FE_COVERAGE_NAME'); +const imagePath = name => `/tmp/${name}.png`; +const imageData = filePath => { + const data = fs.readFileSync(filePath).toString('base64'); + return `data:image/png;base64,${data}`; +}; + +const metricRow = name => [ + [`${name} screenshot`, imagePath(`${screenshotName}_${name}`)], + [`${name} fps`, imagePath(`${fpsSamplesName}_${name}`)], + [`${name} metrics`, imagePath(`${sceneMetricsName}_${name}`)], +]; +const screenshotCell = name => [ + name.replace(/_/g, ' '), + imagePath(`${screenshotName}_${name}`), +]; +const rows = [ + metricRow('promo_xl'), + metricRow('app'), + metricRow('stress'), + metricRow('water'), + [ + screenshotCell('login'), + screenshotCell('os'), + screenshotCell('demo'), + ], + [ + screenshotCell('password_reset'), + screenshotCell('404'), + ['fe coverage', imagePath(feCoverageName)], + ], +]; + +const escapeHtml = value => String(value) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); +const cell = ([title, filePath]) => { + if (!fs.existsSync(filePath)) { + return ` + <figure class="missing"> + <figcaption>${escapeHtml(title)}</figcaption> + <div>Missing ${escapeHtml(path.basename(filePath))}</div> + </figure>`; + } + return ` + <figure> + <figcaption>${escapeHtml(title)}</figcaption> + <img src="${imageData(filePath)}" /> + </figure>`; +}; +const rowHtml = rows + .map(row => `<section class="row">${row.map(cell).join('')}</section>`) + .join(''); +const html = `<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <style> + * { box-sizing: border-box; } + body { + margin: 0; + padding: 24px; + background: #f6f8fa; + color: #24292f; + font-family: Arial, sans-serif; + } + .row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 16px; + } + .row:last-child { margin-bottom: 0; } + figure { + margin: 0; + padding: 12px; + background: #ffffff; + border: 1px solid #d0d7de; + border-radius: 6px; + } + figcaption { + margin-bottom: 8px; + color: #57606a; + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + } + img { + display: block; + width: 100%; + height: auto; + background: #ffffff; + } + .missing div { + min-height: 160px; + display: grid; + place-items: center; + color: #8c959f; + border: 1px dashed #d0d7de; + background: #f6f8fa; + font-size: 13px; + } + </style> +</head> +<body> + ${rowHtml} +</body> +</html>`; + +async function main() { + fs.mkdirSync(path.dirname(output), { recursive: true }); + const browser = await chromium.launch({ + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + ], + }); + try { + const page = await browser.newPage({ viewport: { width: 1800, height: 1200 } }); + await page.setContent(html, { waitUntil: 'load' }); + await page.screenshot({ path: output, fullPage: true }); + console.log(`COMBINED_RENDER_IMAGE=${output}`); + } finally { + await browser.close(); + } +} + +main().catch(error => { + console.error('Failed to combine render images:', error.message || error); + process.exitCode = 1; +}); diff --git a/scripts/ci/create-compare-link b/scripts/ci/create-compare-link new file mode 100755 index 0000000000..499d8e0364 --- /dev/null +++ b/scripts/ci/create-compare-link @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +import json +import os +import sys +import urllib.request + +deploys_url = "https://api.github.com/repos/Farmbot/Farmbot-Web-App/deployments" +compare_url_base = "https://github.com/Farmbot/Farmbot-Web-App/compare/" +github_sha = os.environ["GITHUB_SHA"] + +try: + with urllib.request.urlopen(deploys_url, timeout=30) as response: + deployments = json.load(response) +except Exception as error: + print(f"Failed to read deployments: {error}", file=sys.stderr) + deployments = [] + +last_sha = deployments[0].get("sha", "") if deployments else "" +print(f"{compare_url_base}{last_sha}...{github_sha}") diff --git a/scripts/ci/export-render-env b/scripts/ci/export-render-env new file mode 100755 index 0000000000..d466e8fa0e --- /dev/null +++ b/scripts/ci/export-render-env @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +render_env="scripts/ci/render-env" +github_env="${GITHUB_ENV:?GITHUB_ENV is required}" + +set -a +# shellcheck disable=SC1091 +. "$render_env" +set +a + +while IFS= read -r line; do + case "$line" in + ': "${'*':='*'}"') + name="${line#*: \"\$\{}" + name="${name%%:=*}" + echo "$name=${!name}" >> "$github_env" + ;; + esac +done < "$render_env" diff --git a/scripts/ci/git-publish-render-artifacts b/scripts/ci/git-publish-render-artifacts new file mode 100755 index 0000000000..a62b5d7ebd --- /dev/null +++ b/scripts/ci/git-publish-render-artifacts @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${ARTIFACT_BRANCH:?ARTIFACT_BRANCH is required}" +: "${COMBINED_RENDER_IMAGE:?COMBINED_RENDER_IMAGE is required}" +: "${CHOSEN_FPS:?CHOSEN_FPS is required}" +: "${SCENE_METRICS_NAME:?SCENE_METRICS_NAME is required}" +: "${FE_COVERAGE_NAME:?FE_COVERAGE_NAME is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" +: "${GITHUB_ENV:?GITHUB_ENV is required}" + +worktree=$(mktemp -d) +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +if git ls-remote --exit-code --heads origin "$ARTIFACT_BRANCH" >/dev/null; then + git fetch origin "$ARTIFACT_BRANCH" + git worktree add "$worktree" "origin/$ARTIFACT_BRANCH" +else + git worktree add --detach "$worktree" + git -C "$worktree" switch --orphan "$ARTIFACT_BRANCH" +fi + +bun scripts/ci/combine-render-images + +mkdir -p "$worktree/build/latest" "$worktree/build/${GITHUB_RUN_ID}" +for image in "$COMBINED_RENDER_IMAGE"; do + cp "/tmp/$image.png" "$worktree/build/latest/$image.png" + cp "/tmp/$image.png" "$worktree/build/${GITHUB_RUN_ID}/$image.png" +done +cp "/tmp/$CHOSEN_FPS.csv" "$worktree/build/latest/$CHOSEN_FPS.csv" +cp "/tmp/$CHOSEN_FPS.csv" "$worktree/build/${GITHUB_RUN_ID}/$CHOSEN_FPS.csv" +for metric in "/tmp/$SCENE_METRICS_NAME"*.csv "/tmp/$FE_COVERAGE_NAME.csv"; do + [ -f "$metric" ] && cp "$metric" "$worktree/$(basename "$metric")" +done +git -C "$worktree" add build +for artifact in "$worktree/$SCENE_METRICS_NAME"*.csv "$worktree/$FE_COVERAGE_NAME.csv"; do + [ -f "$artifact" ] && git -C "$worktree" add "$(basename "$artifact")" +done +git -C "$worktree" commit -m "Update CI image artifacts for ${GITHUB_RUN_ID}" || true +git -C "$worktree" push origin "HEAD:$ARTIFACT_BRANCH" + +raw_base="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${ARTIFACT_BRANCH}/build/${GITHUB_RUN_ID}" +render_summary_url="${raw_base}/${COMBINED_RENDER_IMAGE}.png" +echo "RENDER_SUMMARY_URL=${render_summary_url}" +echo "RENDER_SUMMARY_URL=${render_summary_url}" >> "$GITHUB_ENV" diff --git a/scripts/ci/restore-artifact-branch-file b/scripts/ci/git-restore-artifact-branch-file similarity index 84% rename from scripts/ci/restore-artifact-branch-file rename to scripts/ci/git-restore-artifact-branch-file index 90d22f9a92..13de65686b 100755 --- a/scripts/ci/restore-artifact-branch-file +++ b/scripts/ci/git-restore-artifact-branch-file @@ -1,12 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -if [ -f scripts/ci/render-env ]; then - # shellcheck disable=SC1091 - . scripts/ci/render-env -fi - source_pattern="${1:?source path is required}" +: "${ARTIFACT_BRANCH:?ARTIFACT_BRANCH is required}" if ! git ls-remote --exit-code --heads origin "$ARTIFACT_BRANCH" >/dev/null; then exit 0 diff --git a/scripts/ci/percent-change b/scripts/ci/percent-change index 53f761c391..55647cdb52 100755 --- a/scripts/ci/percent-change +++ b/scripts/ci/percent-change @@ -1,7 +1,9 @@ #!/usr/bin/env python3 -import os +import argparse -current = float(os.environ.get("CURRENT_VALUE", "0") or 0) -previous = float(os.environ.get("PREVIOUS_VALUE", "0") or 0) +parser = argparse.ArgumentParser() +parser.add_argument("--new", required=True, type=float) +parser.add_argument("--old", required=True, type=float) +args = parser.parse_args() -print("n/a" if previous == 0 else f"{((current - previous) / previous) * 100:.2f}") +print("n/a" if args.old == 0 else f"{((args.new - args.old) / args.old) * 100:.2f}") diff --git a/scripts/ci/previous-fps-value b/scripts/ci/previous-fps-value index 993dc1c6bc..21a6f88026 100755 --- a/scripts/ci/previous-fps-value +++ b/scripts/ci/previous-fps-value @@ -1,9 +1,18 @@ #!/usr/bin/env python3 import csv import os +import sys -path = f"/tmp/{os.environ['SCENE_METRICS_NAME']}.csv" -fallback = os.environ.get("FALLBACK_FPS_VALUE", "100") +def required_env(name): + value = os.environ.get(name) + if value is None: + print(f"{name} is required", file=sys.stderr) + raise SystemExit(1) + return value + +chosen_metrics = required_env("CHOSEN_METRICS") +fallback = required_env("FALLBACK_FPS_VALUE") +path = f"/tmp/{chosen_metrics}.csv" try: with open(path, newline="") as f: diff --git a/scripts/ci/render-env b/scripts/ci/render-env index d5e8c77995..e915a0078f 100644 --- a/scripts/ci/render-env +++ b/scripts/ci/render-env @@ -1,5 +1,13 @@ -: "${PROMO_IMAGE:=promo}" +: "${CHOSEN_NAME:=promo_xl}" +: "${SCREENSHOT_NAME:=screenshot}" +: "${CHOSEN_IMAGE:=${SCREENSHOT_NAME}_${CHOSEN_NAME}}" : "${FPS_SAMPLES_NAME:=fps_samples}" +: "${CHOSEN_FPS:=${FPS_SAMPLES_NAME}_${CHOSEN_NAME}}" : "${SCENE_METRICS_NAME:=scene_metrics}" +: "${CHOSEN_METRICS:=${SCENE_METRICS_NAME}_${CHOSEN_NAME}}" +: "${COMBINED_RENDER_IMAGE:=render_summary}" +: "${FE_COVERAGE_NAME:=fe_coverage}" : "${SCENE_METRICS_HEADER:=epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes}" : "${ARTIFACT_BRANCH:=ci-artifacts}" +: "${FALLBACK_FPS_VALUE:=100}" +: "${FALLBACK_FE_COVERAGE_VALUE:=99.4}" diff --git a/scripts/ci/render-url-records b/scripts/ci/render-url-records new file mode 100755 index 0000000000..0b5f4d9992 --- /dev/null +++ b/scripts/ci/render-url-records @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as f: + entries = json.load(f) + +for entry in entries: + print("\034".join([ + entry["name"], + entry["mode"], + entry["url"], + entry.get("click", ""), + entry.get("state", ""), + ])) diff --git a/scripts/ci/render-urls.json b/scripts/ci/render-urls.json index 933f518f71..aa0924e64b 100644 --- a/scripts/ci/render-urls.json +++ b/scripts/ci/render-urls.json @@ -1,6 +1,6 @@ [ { - "name": "promo", + "name": "promo_xl", "mode": "fps", "url": "http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true" }, diff --git a/scripts/ci/run-playwright-render b/scripts/ci/run-playwright-render index a9f0af0b39..1270244039 100755 --- a/scripts/ci/run-playwright-render +++ b/scripts/ci/run-playwright-render @@ -13,23 +13,19 @@ if [ -z "${GITHUB_ACTIONS:-}" ]; then fi attempts="${ATTEMPTS:-2}" -: "${PROMO_IMAGE:?PROMO_IMAGE is required}" +: "${CHOSEN_NAME:?CHOSEN_NAME is required}" +: "${SCREENSHOT_NAME:?SCREENSHOT_NAME is required}" : "${FPS_SAMPLES_NAME:?FPS_SAMPLES_NAME is required}" : "${SCENE_METRICS_NAME:?SCENE_METRICS_NAME is required}" : "${SCENE_METRICS_HEADER:?SCENE_METRICS_HEADER is required}" GITHUB_ENV="${GITHUB_ENV:-/tmp/render-github-env}" URLS_JSON="${URLS_JSON:-scripts/ci/render-urls.json}" +chosen_seen=false while IFS=$'\034' read -r -u 3 name mode url click state; do - if [ "$name" = "promo" ]; then - screenshot_path="/tmp/$PROMO_IMAGE.png" - fps_samples_path="/tmp/$FPS_SAMPLES_NAME.csv" - scene_metrics_path="/tmp/$SCENE_METRICS_NAME.csv" - else - screenshot_path="/tmp/screenshot_${name}.png" - fps_samples_path="/tmp/${FPS_SAMPLES_NAME}_${name}.csv" - scene_metrics_path="/tmp/${SCENE_METRICS_NAME}_${name}.csv" - fi + screenshot_path="/tmp/${SCREENSHOT_NAME}_${name}.png" + fps_samples_path="/tmp/${FPS_SAMPLES_NAME}_${name}.csv" + scene_metrics_path="/tmp/${SCENE_METRICS_NAME}_${name}.csv" for _ in $(seq 1 "$attempts"); do fps_args=( @@ -74,9 +70,10 @@ while IFS=$'\034' read -r -u 3 name mode url click state; do printf '%s\n' "$scene_metrics" >> "$scene_metrics_path" echo "SCENE_METRICS=${scene_metrics_path}" - if [ "$name" = "promo" ]; then + if [ "$name" = "${CHOSEN_NAME}" ]; then echo "FPS_VALUE=${fps_value}" >> "$GITHUB_ENV" echo "SCENE_METRICS=${scene_metrics}" >> "$GITHUB_ENV" + chosen_seen=true fi continue 2 @@ -84,23 +81,12 @@ while IFS=$'\034' read -r -u 3 name mode url click state; do echo "FPS check failed for ${url}" exit 1 -done 3< <(python3 - "$URLS_JSON" <<'PY' -import json -import sys +done 3< <(scripts/ci/render-url-records "$URLS_JSON") -with open(sys.argv[1], encoding="utf-8") as f: - entries = json.load(f) - -for entry in entries: - print("\034".join([ - entry["name"], - entry["mode"], - entry["url"], - entry.get("click", ""), - entry.get("state", ""), - ])) -PY -) +if [ "$chosen_seen" != "true" ]; then + echo "Chosen FPS scenario '${CHOSEN_NAME}' was not found in ${URLS_JSON}" + exit 1 +fi if [ -z "${GITHUB_ACTIONS:-}" ]; then echo -e "\n\n\n" diff --git a/scripts/ci/send-notification b/scripts/ci/send-notification index 26a8e719f7..faaee307e1 100755 --- a/scripts/ci/send-notification +++ b/scripts/ci/send-notification @@ -1,14 +1,25 @@ -#!/usr/bin/env bash -set -euo pipefail - -message="$*" -if [ -z "${SLACK_WEBHOOK_URL:-}" ]; then - exit 0 -fi - -MESSAGE="$message" python3 - <<'PY' | curl -fsS -X POST -H "Content-Type: application/json" --data @- "$SLACK_WEBHOOK_URL" || true +#!/usr/bin/env python3 import json import os +import sys +import urllib.request + +webhook_url = os.environ.get("SLACK_WEBHOOK_URL") +if not webhook_url: + raise SystemExit(0) + +payload = json.dumps({ + "text": " ".join(sys.argv[1:]), + "channel": "#software", +}).encode() +request = urllib.request.Request( + webhook_url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", +) -print(json.dumps({"text": os.environ["MESSAGE"], "channel": "#software"})) -PY +try: + urllib.request.urlopen(request, timeout=30) +except Exception as error: + print(f"Failed to send notification: {error}", file=sys.stderr) diff --git a/scripts/ci/test_python_scripts.py b/scripts/ci/test_python_scripts.py new file mode 100644 index 0000000000..c62c7f035d --- /dev/null +++ b/scripts/ci/test_python_scripts.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +import contextlib +import io +import json +import os +from pathlib import Path +import runpy +import sys +import tempfile +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[2] +CI = ROOT / "scripts" / "ci" + + +class FakeResponse: + def __init__(self, body): + self.body = body + + def __enter__(self): + return self + + def __exit__(self, _exc_type, _exc_value, _traceback): + return False + + def read(self): + return self.body.encode() + + +def run_script(name, argv=None, env=None): + stdout = io.StringIO() + stderr = io.StringIO() + script = CI / name + with patch.object(sys, "argv", [str(script), *(argv or [])]): + with patch.dict(os.environ, env or {}, clear=True): + with contextlib.redirect_stdout(stdout): + with contextlib.redirect_stderr(stderr): + try: + runpy.run_path(str(script), run_name="__main__") + except SystemExit as error: + code = error.code if isinstance(error.code, int) else 1 + else: + code = 0 + return code, stdout.getvalue(), stderr.getvalue() + + +class CiPythonScriptTest(unittest.TestCase): + def test_percent_change_reports_percent(self): + code, stdout, stderr = run_script("percent-change", [ + "--new", "110", + "--old", "100", + ]) + self.assertEqual(code, 0) + self.assertEqual(stdout, "10.00\n") + self.assertEqual(stderr, "") + + def test_percent_change_handles_zero_old_value(self): + code, stdout, _stderr = run_script("percent-change", [ + "--new", "110", + "--old", "0", + ]) + self.assertEqual(code, 0) + self.assertEqual(stdout, "n/a\n") + + def test_previous_fps_value_reads_previous_row(self): + metrics_name = f"scene_metrics_test_{os.getpid()}" + path = Path("/tmp") / f"{metrics_name}.csv" + path.write_text( + "epoch,FPS\n" + "1,80\n" + "2,90\n" + "3,100\n", + ) + try: + code, stdout, stderr = run_script("previous-fps-value", env={ + "CHOSEN_METRICS": metrics_name, + "FALLBACK_FPS_VALUE": "100", + }) + finally: + path.unlink(missing_ok=True) + + self.assertEqual(code, 0) + self.assertEqual(stdout, "90.00\n") + self.assertEqual(stderr, "") + + def test_previous_fps_value_uses_fallback_without_history(self): + code, stdout, _stderr = run_script("previous-fps-value", env={ + "CHOSEN_METRICS": f"missing_metrics_{os.getpid()}", + "FALLBACK_FPS_VALUE": "100", + }) + self.assertEqual(code, 0) + self.assertEqual(stdout, "100\n") + + def test_render_url_records_outputs_field_separated_records(self): + with tempfile.NamedTemporaryFile("w", delete=False) as file: + json.dump([{ + "name": "promo", + "mode": "fps", + "url": "http://localhost:3000/promo", + "click": "Run", + "state": "app", + }], file) + file_path = file.name + try: + code, stdout, stderr = run_script( + "render-url-records", [file_path]) + finally: + Path(file_path).unlink(missing_ok=True) + + self.assertEqual(code, 0) + self.assertEqual( + stdout, "promo\034fps\034http://localhost:3000/promo\034Run\034app\n") + self.assertEqual(stderr, "") + + def test_create_compare_link_uses_latest_deployment_sha(self): + deployments = json.dumps([{"sha": "old123"}]) + with patch("urllib.request.urlopen", return_value=FakeResponse(deployments)): + code, stdout, stderr = run_script("create-compare-link", env={ + "GITHUB_SHA": "new456", + }) + + self.assertEqual(code, 0) + self.assertEqual( + stdout, "https://github.com/Farmbot/Farmbot-Web-App/compare/old123...new456\n") + self.assertEqual(stderr, "") + + def test_send_notification_skips_without_webhook(self): + code, stdout, stderr = run_script("send-notification", ["hello"]) + self.assertEqual(code, 0) + self.assertEqual(stdout, "") + self.assertEqual(stderr, "") + + def test_send_notification_posts_payload(self): + calls = [] + + def fake_urlopen(request, timeout): + calls.append((request, timeout)) + return object() + + with patch("urllib.request.urlopen", side_effect=fake_urlopen): + code, stdout, stderr = run_script("send-notification", ["hello", "world"], env={ + "SLACK_WEBHOOK_URL": "https://example.test/webhook", + }) + + self.assertEqual(code, 0) + self.assertEqual(stdout, "") + self.assertEqual(stderr, "") + self.assertEqual(len(calls), 1) + request, timeout = calls[0] + self.assertEqual(timeout, 30) + self.assertEqual(request.full_url, "https://example.test/webhook") + self.assertEqual(request.get_method(), "POST") + self.assertEqual(json.loads(request.data.decode()), { + "text": "hello world", + "channel": "#software", + }) + + def test_track_fe_coverage_appends_csv(self): + coverage_name = f"fe_coverage_test_{os.getpid()}" + csv_path = Path("/tmp") / f"{coverage_name}.csv" + with tempfile.NamedTemporaryFile("w", delete=False) as lcov: + lcov.write("LF:10\nLH:8\nLF:5\nLH:4\n") + lcov_path = lcov.name + + try: + code, stdout, stderr = run_script("track-fe-coverage", env={ + "FE_COVERAGE_NAME": coverage_name, + "FALLBACK_FE_COVERAGE_VALUE": "75", + "FE_LCOV_PATH": lcov_path, + "PATH": os.environ["PATH"], + }) + finally: + Path(lcov_path).unlink(missing_ok=True) + csv_path.unlink(missing_ok=True) + + self.assertEqual(code, 0) + self.assertIn( + "Covered lines: 12, Total lines: 15, Coverage: 80.00%", stdout) + self.assertIn("80.00% (6.67% change)", stdout) + self.assertIn( + "percent,covered lines,total lines,percent change\n80.00,12,15,6.67\n", stdout) + self.assertEqual(stderr, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/track-fe-coverage b/scripts/ci/track-fe-coverage new file mode 100755 index 0000000000..4c4600511c --- /dev/null +++ b/scripts/ci/track-fe-coverage @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +import csv +import os +from pathlib import Path +import subprocess + +fe_coverage_name = os.environ["FE_COVERAGE_NAME"] +fallback = os.environ["FALLBACK_FE_COVERAGE_VALUE"] +lcov_path = Path(os.environ.get("FE_LCOV_PATH", "coverage_fe/lcov.info")) +csv_path = Path("/tmp") / f"{fe_coverage_name}.csv" + +covered = 0 +total = 0 +with lcov_path.open() as lcov: + for line in lcov: + if line.startswith("LH:"): + covered += int(line.split(":", 1)[1]) + elif line.startswith("LF:"): + total += int(line.split(":", 1)[1]) + +value = f"{(covered / total) * 100:.2f}" + +previous = fallback +if csv_path.exists(): + with csv_path.open(newline="") as csv_file: + reader = csv.reader(csv_file) + next(reader, None) + values = [float(row[0]) for row in reader if row and row[0]] + if values: + previous = f"{values[-1]:.2f}" + +percent_change = subprocess.check_output([ + "scripts/ci/percent-change", + "--new", + value, + "--old", + previous, +], text=True).strip() + +print(f"Covered lines: {covered}, Total lines: {total}, Coverage: {value}%") +print(f"{value}% ({percent_change}% change)") + +if not csv_path.exists(): + csv_path.write_text("percent,covered lines,total lines,percent change\n") + +with csv_path.open("a", newline="") as csv_file: + writer = csv.writer(csv_file) + writer.writerow([value, covered, total, percent_change]) + +print(csv_path.read_text(), end="") From e6ba003951d14af668015d8a29f8df134d231d77 Mon Sep 17 00:00:00 2001 From: gabrielburnworth <gabrielburnworth@users.noreply.github.com> Date: Mon, 25 May 2026 11:44:02 -0700 Subject: [PATCH 66/83] add point deletion guards for farm events and regimens --- app/mutations/points/destroy.rb | 83 ++++++++-- frontend/api/__tests__/delete_points_test.ts | 41 ++++- frontend/api/delete_points.ts | 23 ++- .../points/destroy_edge_cases_spec.rb | 4 +- spec/mutations/points/destroy_spec.rb | 149 +++++++++++++++++- spec/mutations/sequences/mark_as_spec.rb | 4 +- 6 files changed, 273 insertions(+), 31 deletions(-) diff --git a/app/mutations/points/destroy.rb b/app/mutations/points/destroy.rb index 8a2c413a1b..e3ec3c5699 100644 --- a/app/mutations/points/destroy.rb +++ b/app/mutations/points/destroy.rb @@ -1,9 +1,7 @@ module Points class Destroy < Mutations::Command - STILL_IN_USE = "Could not delete the following item(s): %s. Item(s) are " \ - "in use by the following sequence(s): %s." - JUST_ONE = "Could not delete %s. Item is in use by the following " \ - "sequence(s): %s." + STILL_IN_USE = "Could not delete %s. Items are in use by %s." + JUST_ONE = "Could not delete %s. Item is in use by %s." required do model :device, class: Device @@ -19,21 +17,18 @@ class Destroy < Mutations::Command def validate maybe_wrap_ids - # Collect names of sequences that still use this point. - problems = (tool_seq + point_seq + resource_update_seq) - .group_by(&:sequence_name) - .to_a - .each_with_object({ S => [], P => [] }) do |(seq_name, data), total| - total[S].push(seq_name) - total[P].push(*(data || []).map(&:fancy_name)) + problems = point_usage.each_with_object({ S => [], P => [] }) do |usage, total| + owner, point = usage + total[S].push(owner_name(owner)) + total[P].push(point.fancy_name) end p = problems[P].sort.uniq.join(", ") if p.present? - sequences = problems[S].sort.uniq.join(", ") + owners = problems[S].sort.uniq.join(", ") message = point_ids.many? ? STILL_IN_USE : JUST_ONE - problems = format(message, p, sequences) + problems = format(message, p, owners) add_error :whoops, :in_use, problems end @@ -109,6 +104,68 @@ def tool_seq .to_a end + def point_usage + @point_usage ||= sequence_point_usage + fragment_point_usage + end + + def sequence_point_usage + @sequence_point_usage ||= sequence_usage + .group_by(&:sequence_id) + .to_a + .flat_map do |(sequence_id, data)| + sequence = sequence_owners[sequence_id] + next [] unless sequence + + (data || []).map { |point| [sequence, point] } + end + end + + def sequence_usage + @sequence_usage ||= tool_seq + point_seq + resource_update_seq + end + + def sequence_owners + @sequence_owners ||= Sequence + .where(id: sequence_usage.map(&:sequence_id).uniq) + .index_by(&:id) + end + + def fragment_point_usage + @fragment_point_usage ||= begin + primitives = point_ids.flat_map do |point_id| + Primitive + .where(fragment_id: fragment_owner_ids) + .where(value: point_id) + end + pairs = PrimitivePair + .includes(:primitive) + .where(arg_name_id: ArgName.find_or_create_by!(value: "pointer_id").id) + .where(primitive_id: primitives.map(&:id)) + owners = Fragment + .includes(:owner) + .where(id: pairs.map(&:fragment_id).uniq) + .index_by(&:id) + points_by_id = points.index_by(&:id) + + pairs.each_with_object([]) do |pair, result| + owner = owners[pair.fragment_id]&.owner + point = points_by_id[pair.primitive.value] + result.push([owner, point]) if owner && point + end + end + end + + def fragment_owner_ids + @fragment_owner_ids ||= Fragment + .where(device_id: device.id, + owner_type: [FarmEvent.name, Regimen.name]) + .pluck(:id) + end + + def owner_name(owner) + "#{owner.class.name} '#{owner.fancy_name}'" + end + def maybe_wrap_ids raise "NO" unless (point || point_ids) diff --git a/frontend/api/__tests__/delete_points_test.ts b/frontend/api/__tests__/delete_points_test.ts index 84f5202ffb..65c8564726 100644 --- a/frontend/api/__tests__/delete_points_test.ts +++ b/frontend/api/__tests__/delete_points_test.ts @@ -64,7 +64,28 @@ describe("deletePoints()", () => { expect(error).toHaveBeenCalledWith(expect.stringContaining( "Some weeds failed to delete.")); expect(error).toHaveBeenCalledWith(expect.stringContaining( - "Are they in use by sequences?")); + "Are they in use by sequences, regimens, or events?")); + }); + + it("shows response errors when points can't be deleted", async () => { + mockDelete = Promise.reject({ + response: { + data: { + whoops: "Could not delete point. Item is in use by the following: X.", + }, + }, + }); + mockData = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const dispatch = jest.fn(); + const progressCb = jest.fn(); + const query = { meta: { created_by: "plant-detection" } }; + await actualDeletePoints().deletePoints("weeds", query, progressCb)( + dispatch, jest.fn()); + await Promise.resolve(); + expect(error).toHaveBeenCalledWith(expect.stringContaining( + "Some weeds failed to delete.")); + expect(error).toHaveBeenCalledWith( + "Whoops: Could not delete point. Item is in use by the following: X."); }); it("chunks points", async () => { @@ -114,7 +135,23 @@ describe("deletePointsByIds()", () => { expect(error).toHaveBeenCalledWith(expect.stringContaining( "Some points failed to delete.")); expect(error).toHaveBeenCalledWith(expect.stringContaining( - "Are they in use by sequences?")); + "Are they in use by sequences, regimens, or events?")); + expect(success).not.toHaveBeenCalled(); + }); + + it("shows response errors when points can't be deleted", async () => { + mockDelete = Promise.reject({ + response: { + data: { + whoops: "Could not delete point. Item is in use by the following: X.", + }, + }, + }); + await actualDeletePoints().deletePointsByIds("points", [1, 2, 3]); + expect(error).toHaveBeenCalledWith(expect.stringContaining( + "Some points failed to delete.")); + expect(error).toHaveBeenCalledWith( + "Whoops: Could not delete point. Item is in use by the following: X."); expect(success).not.toHaveBeenCalled(); }); }); diff --git a/frontend/api/delete_points.ts b/frontend/api/delete_points.ts index 988ded7970..164eee9ef2 100644 --- a/frontend/api/delete_points.ts +++ b/frontend/api/delete_points.ts @@ -7,6 +7,16 @@ import { noop, chunk } from "lodash"; import { Point } from "farmbot/dist/resources/api_resources"; import { Actions } from "../constants"; import { t } from "../i18next_wrapper"; +import { + AxiosErrorResponse, + prettyPrintApiErrors, +} from "../util/errors"; + +const toastResponseErrors = (err: AxiosErrorResponse) => { + if (err.response?.data) { + error(prettyPrintApiErrors(err)); + } +}; export function deletePoints( pointName: string, @@ -30,7 +40,7 @@ export function deletePoints( return x; }); }); - Promise + return Promise .all(promises) .then(function () { dispatch({ @@ -42,10 +52,11 @@ export function deletePoints( })); prog.finish(); }) - .catch(function () { + .catch(function (err: AxiosErrorResponse) { error(trim(`${t("Some {{points}} failed to delete.", { points: pointName })} - ${t("Are they in use by sequences?")}`)); + ${t("Are they in use by sequences, regimens, or events?")}`)); + toastResponseErrors(err); prog.finish(); }); }; @@ -59,6 +70,8 @@ export const deletePointsByIds = (pointName: string, ids: number[]) => success(t("Deleted {{num}} {{points}}", { num: ids.length, points: pointName, }))) - .catch(() => + .catch((err: AxiosErrorResponse) => { error(trim(`${t("Some {{points}} failed to delete.", { points: pointName })} - ${t("Are they in use by sequences?")}`))); + ${t("Are they in use by sequences, regimens, or events?")}`)); + toastResponseErrors(err); + }); diff --git a/spec/mutations/points/destroy_edge_cases_spec.rb b/spec/mutations/points/destroy_edge_cases_spec.rb index 273f38f632..5f4a3c537b 100644 --- a/spec/mutations/points/destroy_edge_cases_spec.rb +++ b/spec/mutations/points/destroy_edge_cases_spec.rb @@ -26,8 +26,8 @@ }]) result = Points::Destroy.run(point_ids: [tool_slot.id], device: device) errors = result.errors.message_list - expected = "Could not delete foo tool. Item is in use by the following "\ - "sequence(s): sequence." + expected = "Could not delete foo tool. Item is in use by " \ + "Sequence 'sequence'." expect(errors).to include(expected) end end diff --git a/spec/mutations/points/destroy_spec.rb b/spec/mutations/points/destroy_spec.rb index edce38ac07..ead324dc3b 100644 --- a/spec/mutations/points/destroy_spec.rb +++ b/spec/mutations/points/destroy_spec.rb @@ -63,8 +63,8 @@ expect(result.errors.message_list.count).to eq(1) expect(result.errors.message_list.first).to include(params[:name]) coords = [:x, :y, :z].map { |c| points.first[c] }.join(", ") - expected = "Could not delete the following item(s): point at (#{coords})." \ - " Item(s) are in use by the following sequence(s): Test Case I." + expected = "Could not delete point at (#{coords}). Items are in use " \ + "by Sequence 'Test Case I'." expect(result.errors.message_list.first).to include(expected) end @@ -73,11 +73,147 @@ point_ids = [s.tool_slot.id] result = Points::Destroy.run(point_ids: point_ids, device: s.device) expect(result.success?).to be(false) - expected = "Could not delete Scenario Tool. Item is in use by the " \ - "following sequence(s): Scenario Sequence." + expected = "Could not delete Scenario Tool. Item is in use by " \ + "Sequence 'Scenario Sequence'." expect(result.errors.message_list).to include(expected) end + it "prevents deletion of points used by farm event fragments" do + point = FactoryBot.create(:generic_pointer, + device: device, + x: 4, + y: 5, + z: 6) + farm_event = FactoryBot.create(:farm_event, + device: device, + start_time: Time.now + 1.day) + Fragment.from_celery(device: device, + owner: farm_event, + kind: "internal_farm_event", + args: {}, + body: [ + { + kind: "parameter_application", + args: { + label: "location", + data_value: { + kind: "point", + args: { + pointer_type: "GenericPointer", + pointer_id: point.id, + }, + }, + }, + }, + ]) + + result = Points::Destroy.run(point_ids: [point.id], device: device) + + expect(result.success?).to be(false) + expected = "Could not delete point at (4.0, 5.0, 6.0). Item is in use " \ + "by FarmEvent '#{farm_event.fancy_name}'." + expect(result.errors.message[:whoops]).to eq(expected) + expect(Point.exists?(point.id)).to be(true) + end + + it "prevents deletion of points used by regimen fragments" do + point = FactoryBot.create(:generic_pointer, + device: device, + x: 4, + y: 5, + z: 6) + regimen = FactoryBot.create(:regimen, + device: device, + name: "Point user") + Fragment.from_celery(device: device, + owner: regimen, + kind: "internal_regimen", + args: {}, + body: [ + { + kind: "parameter_application", + args: { + label: "location", + data_value: { + kind: "point", + args: { + pointer_type: "GenericPointer", + pointer_id: point.id, + }, + }, + }, + }, + ]) + + result = Points::Destroy.run(point_ids: [point.id], device: device) + + expect(result.success?).to be(false) + expected = "Could not delete point at (4.0, 5.0, 6.0). Item is in use " \ + "by Regimen 'Point user'." + expect(result.errors.message[:whoops]).to eq(expected) + expect(Point.exists?(point.id)).to be(true) + end + + it "reports sequence and fragment point usage in one error" do + point = FactoryBot.create(:generic_pointer, + device: device, + x: 4, + y: 5, + z: 6) + Sequences::Create.run!(device: device, + name: "Sequence user", + body: [ + { + kind: "move_absolute", + args: { + location: { + kind: "point", + args: { + pointer_type: "GenericPointer", + pointer_id: point.id, + }, + }, + speed: 100, + offset: { + kind: "coordinate", + args: { x: 0, y: 0, z: 0 }, + }, + }, + }, + ]) + farm_event = FactoryBot.create(:farm_event, + device: device, + start_time: Time.now + 1.day) + Fragment.from_celery(device: device, + owner: farm_event, + kind: "internal_farm_event", + args: {}, + body: [ + { + kind: "parameter_application", + args: { + label: "location", + data_value: { + kind: "point", + args: { + pointer_type: "GenericPointer", + pointer_id: point.id, + }, + }, + }, + }, + ]) + + result = Points::Destroy.run(point_ids: [point.id], device: device) + + expect(result.success?).to be(false) + expect(result.errors.message_list.count).to eq(1) + expected = "Could not delete point at (4.0, 5.0, 6.0). Item is in use " \ + "by FarmEvent '#{farm_event.fancy_name}', " \ + "Sequence 'Sequence user'." + expect(result.errors.message[:whoops]).to eq(expected) + end + it "handles multiple sequence dep tracking issues at deletion time" do point = FactoryBot.create(:generic_pointer, device: device, x: 4, y: 5, z: 6) plant = FactoryBot.create(:plant, device: device, x: 0, y: 1, z: 0) @@ -152,9 +288,8 @@ .run(point_ids: [point.id, plant.id], device: device) .errors .message - expected = "Could not delete the following item(s): plant at (0.0, 1.0," \ - " 0.0). Item(s) are in use by the following sequence(s): " \ - "Sequence A, Sequence B." + expected = "Could not delete plant at (0.0, 1.0, 0.0). Items are " \ + "in use by Sequence 'Sequence A', Sequence 'Sequence B'." expect(result[:whoops]).to eq(expected) end diff --git a/spec/mutations/sequences/mark_as_spec.rb b/spec/mutations/sequences/mark_as_spec.rb index e280174f8d..908bd928ec 100644 --- a/spec/mutations/sequences/mark_as_spec.rb +++ b/spec/mutations/sequences/mark_as_spec.rb @@ -63,7 +63,7 @@ def sequence(body, locals = nil) expect(weed_result.success?).to be false error = weed_result.errors.message_list.first expect(error).to include("Could not delete weed") - expect(error).to include("in use by the following sequence(s): #{s.name}") + expect(error).to include("in use by Sequence '#{s.name}'") end end @@ -94,7 +94,7 @@ def sequence(body, locals = nil) expect(plant_result.success?).to be false error = plant_result.errors.message_list.first expect(error).to include("Could not delete plant") - expect(error).to include("in use by the following sequence(s): #{s.name}") + expect(error).to include("in use by Sequence '#{s.name}'") end end From 2be9ed58cc6eb3ef4652cd9c5264356676db00f7 Mon Sep 17 00:00:00 2001 From: gabrielburnworth <gabrielburnworth@users.noreply.github.com> Date: Mon, 25 May 2026 13:14:49 -0700 Subject: [PATCH 67/83] upgrade deps --- Gemfile.lock | 18 +- bun.lock | 160 +++++++----------- frontend/AGENTS.md | 1 + .../__test_support__/react_element_search.tsx | 34 ++++ .../toggle_highlight_modified_test.tsx | 30 +--- .../__tests__/scheduler_test.tsx | 33 +--- .../list/__tests__/regimen_list_item_test.tsx | 34 +--- .../__tests__/tile_execute_test.tsx | 46 ++--- .../__tests__/tile_move_absolute_test.tsx | 49 +++--- .../pin_bindings/__tests__/model_test.tsx | 5 + .../pin_binding_input_group_test.tsx | 107 +++++++----- .../garden/__tests__/plant_instances_test.tsx | 2 +- .../garden/__tests__/point_test.tsx | 3 +- .../garden/__tests__/solar_test.tsx | 7 +- .../garden/__tests__/weed_test.tsx | 3 +- .../tools/__tests__/edit_tool_slot_test.tsx | 15 +- frontend/tools/__tests__/index_test.tsx | 67 ++------ .../tool_slot_edit_components_test.tsx | 5 + frontend/ui/__tests__/filter_search_test.tsx | 76 +++------ frontend/wizard/__tests__/checks_test.tsx | 30 +--- package.json | 30 ++-- 21 files changed, 314 insertions(+), 441 deletions(-) create mode 100644 frontend/__test_support__/react_element_search.tsx diff --git a/Gemfile.lock b/Gemfile.lock index 28d9b27b99..371ebbd0eb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -142,13 +142,13 @@ GEM railties (>= 6.1.0) faker (3.8.0) i18n (>= 1.8.11, < 2) - faraday (2.14.1) + faraday (2.14.2) faraday-net_http (>= 2.0, < 3.5) json logger faraday-follow_redirects (0.5.0) faraday (>= 1, < 3) - faraday-net_http (3.4.2) + faraday-net_http (3.4.3) net-http (~> 0.5) globalid (1.3.0) activesupport (>= 6.1) @@ -202,7 +202,7 @@ GEM reline (>= 0.4.2) json (2.19.5) jsonapi-renderer (0.2.2) - jwt (3.1.2) + jwt (3.2.0) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -233,7 +233,7 @@ GEM net-imap net-pop net-smtp - marcel (1.1.0) + marcel (1.2.1) method_source (1.1.0) mini_mime (1.1.5) minitest (6.0.6) @@ -266,7 +266,7 @@ GEM parser (3.3.11.1) ast (~> 2.4.1) racc - passenger (6.1.2) + passenger (6.1.3) logger (>= 1.7.0) rack (>= 1.6.13) rackup (>= 1.0.1) @@ -357,7 +357,7 @@ GEM responders (3.2.0) actionpack (>= 7.0) railties (>= 7.0) - retriable (3.4.1) + retriable (3.5.0) rexml (3.4.4) rollbar (3.8.0) rspec (3.13.2) @@ -383,7 +383,7 @@ GEM rspec-support (3.13.7) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.86.1) + rubocop (1.86.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -397,7 +397,7 @@ GEM rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) - rubocop-rails (2.35.0) + rubocop-rails (2.35.2) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -453,7 +453,7 @@ GEM base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.7.5) + zeitwerk (2.8.2) PLATFORMS aarch64-linux diff --git a/bun.lock b/bun.lock index 0f1d5edb75..c25d3eeb78 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,10 @@ "": { "name": "farmbot-web-frontend", "dependencies": { - "@blueprintjs/core": "6.13.0", - "@blueprintjs/select": "6.1.11", + "@blueprintjs/core": "6.15.0", + "@blueprintjs/select": "6.2.1", "@monaco-editor/react": "4.7.0", - "@react-spring/three": "10.0.3", + "@react-spring/three": "10.1.0", "@react-three/drei": "10.7.7", "@react-three/fiber": "9.6.1", "@rollbar/react": "1.0.0", @@ -17,7 +17,7 @@ "@types/markdown-it": "14.1.2", "@types/markdown-it-emoji": "3.0.1", "@types/promise-timeout": "1.3.3", - "@types/react": "19.2.14", + "@types/react": "19.2.15", "@types/react-color": "3.0.13", "@types/react-dom": "19.2.3", "@types/react-test-renderer": "19.1.0", @@ -33,9 +33,9 @@ "farmbot": "15.9.3", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.1.0", + "i18next": "26.2.0", "lodash": "4.18.1", - "markdown-it": "14.1.1", + "markdown-it": "14.2.0", "markdown-it-emoji": "3.0.0", "moment": "2.30.1", "monaco-editor": "0.55.1", @@ -47,8 +47,8 @@ "react": "19.2.6", "react-color": "2.19.3", "react-dom": "19.2.6", - "react-redux": "9.2.0", - "react-router": "7.15.0", + "react-redux": "9.3.0", + "react-router": "7.15.1", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", @@ -71,9 +71,9 @@ "@types/jest": "30.0.0", "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", - "@typescript-eslint/eslint-plugin": "8.59.3", - "@typescript-eslint/parser": "8.59.3", - "eslint": "10.3.0", + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "eslint": "10.4.0", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", "eslint-plugin-jest": "29.15.2", @@ -92,15 +92,15 @@ "madge": "8.0.0", "path-browserify": "1.0.1", "playwright": "1.60.0", - "postcss": "8.5.14", + "postcss": "8.5.15", "postcss-scss": "4.0.9", "raf": "3.4.1", "react-test-renderer": "19.2.6", - "sass": "1.99.0", + "sass": "1.100.0", "sass-lint": "1.13.1", - "stylelint": "17.11.0", + "stylelint": "17.12.0", "stylelint-config-standard-scss": "17.0.0", - "ts-jest": "29.4.9", + "ts-jest": "29.4.11", "tslint": "6.1.3", }, }, @@ -186,11 +186,11 @@ "@blueprintjs/colors": ["@blueprintjs/colors@5.1.16", "", { "dependencies": { "tslib": "~2.6.2" } }, "sha512-P9uX0Aj2TP9+6aUcori1iPl4snxM/Vgq0LZbhUl1l5bHTgNxxwm/0+IoS/SlQg93HBRl8KTAM1evEqtPbwV10A=="], - "@blueprintjs/core": ["@blueprintjs/core@6.13.0", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/icons": "^6.10.0", "@floating-ui/react": "^0.27.13", "@popperjs/core": "^2.11.8", "classnames": "^2.3.1", "normalize.css": "^8.0.1", "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"], "bin": { "upgrade-blueprint-2.0.0-rename": "scripts/upgrade-blueprint-2.0.0-rename.sh", "upgrade-blueprint-3.0.0-rename": "scripts/upgrade-blueprint-3.0.0-rename.sh" } }, "sha512-2gKXoe7ve0X1zSK0Svqmp6rwrLXvvN1QghByVHksTuTXwBisJahBgzcbeG8h9eg7VAXbUXYE1vvlUlQdn8LWvw=="], + "@blueprintjs/core": ["@blueprintjs/core@6.15.0", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/icons": "^6.10.0", "@floating-ui/react": "^0.27.13", "@popperjs/core": "^2.11.8", "classnames": "^2.3.1", "normalize.css": "^8.0.1", "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"], "bin": { "upgrade-blueprint-2.0.0-rename": "scripts/upgrade-blueprint-2.0.0-rename.sh", "upgrade-blueprint-3.0.0-rename": "scripts/upgrade-blueprint-3.0.0-rename.sh" } }, "sha512-g0CcgoZltOHd142EJfLkLpsgQ5/JjFaJmMkTclef1Z5bZUrkJl8pLS8GZfjdhLp/ZGWqFUZRXvKV2jEnbd/W1Q=="], "@blueprintjs/icons": ["@blueprintjs/icons@6.10.0", "", { "dependencies": { "change-case": "^4.1.2", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-KjNviCpxjJOcIVgNUOlw2FCGp8T5o0SZW56alL6veDHyHnMP48jaW8cVCRvKvZqGmUL+d9faLis1CJzI20E+kg=="], - "@blueprintjs/select": ["@blueprintjs/select@6.1.11", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/core": "^6.13.0", "@blueprintjs/icons": "^6.10.0", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-Zf32IiagGYFHzfp2N3qIqF+6dV4K8Lk0XzVDSnGGLhYdOChpzOp0K9aisfXHbqtDjDtH25d9UvqlVqp7TmT8wQ=="], + "@blueprintjs/select": ["@blueprintjs/select@6.2.1", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/core": "^6.15.0", "@blueprintjs/icons": "^6.10.0", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-NBMxdAR7PE4Oyn5tN9AfKVzFpCRoQHaIuhNCCGFFjt22efgQscQF+ppHPe1+pUy4YIWB2KBwfBUT5bpIvNFPfA=="], "@cacheable/memory": ["@cacheable/memory@2.0.8", "", { "dependencies": { "@cacheable/utils": "^2.4.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw=="], @@ -230,7 +230,7 @@ "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], @@ -308,7 +308,7 @@ "@jest/test-sequencer": ["@jest/test-sequencer@30.4.1", "", { "dependencies": { "@jest/test-result": "30.4.1", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "slash": "^3.0.0" } }, "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw=="], - "@jest/transform": ["@jest/transform@30.3.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "jest-regex-util": "30.0.1", "jest-util": "30.3.0", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A=="], + "@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], "@jest/types": ["@jest/types@30.4.1", "", { "dependencies": { "@jest/pattern": "30.4.0", "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ=="], @@ -378,17 +378,17 @@ "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], - "@react-spring/animated": ["@react-spring/animated@10.0.3", "", { "dependencies": { "@react-spring/shared": "~10.0.3", "@react-spring/types": "~10.0.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ=="], + "@react-spring/animated": ["@react-spring/animated@10.1.0", "", { "dependencies": { "@react-spring/shared": "~10.1.0", "@react-spring/types": "~10.1.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dvGVSfNYhbkzTAnyB1S1940ZiKp6/Ey+b2vQc70dJ5k6gzH/GdlfeDzCh65V94b6OPqF7Xl4VUICVUQpc07iUg=="], - "@react-spring/core": ["@react-spring/core@10.0.3", "", { "dependencies": { "@react-spring/animated": "~10.0.3", "@react-spring/shared": "~10.0.3", "@react-spring/types": "~10.0.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ=="], + "@react-spring/core": ["@react-spring/core@10.1.0", "", { "dependencies": { "@react-spring/animated": "~10.1.0", "@react-spring/shared": "~10.1.0", "@react-spring/types": "~10.1.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XUG3UCCCxay6lYjBU2KOKzEgC1sx/w44ouB5gRh2Ex6rVJOdPcGVg7JJUIOAQo7uhKGfQdCQU4qUZaQnmInZPQ=="], - "@react-spring/rafz": ["@react-spring/rafz@10.0.3", "", {}, "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg=="], + "@react-spring/rafz": ["@react-spring/rafz@10.1.0", "", {}, "sha512-e/LRnNmvmg8Agl4j3MGcjHuSTSBcCrxr3xeoWdodxpLWeTHY1pHl8PJDOCEJ2Pj6BMdEBaWFRAX7uxRo1FLzCg=="], - "@react-spring/shared": ["@react-spring/shared@10.0.3", "", { "dependencies": { "@react-spring/rafz": "~10.0.3", "@react-spring/types": "~10.0.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q=="], + "@react-spring/shared": ["@react-spring/shared@10.1.0", "", { "dependencies": { "@react-spring/rafz": "~10.1.0", "@react-spring/types": "~10.1.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ogIUujWxdwcsQ2Vp2hZs+KehvH8tKeWGHsFb1eRBYoePzhKS541Qg1JfFlQ7ursBUwwPDBQ5Wpwn+Cwx6WV1PQ=="], - "@react-spring/three": ["@react-spring/three@10.0.3", "", { "dependencies": { "@react-spring/animated": "~10.0.3", "@react-spring/core": "~10.0.3", "@react-spring/shared": "~10.0.3", "@react-spring/types": "~10.0.3" }, "peerDependencies": { "@react-three/fiber": ">=6.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "three": ">=0.126" } }, "sha512-hZP7ChF/EwnWn+H2xuzAsRRfQdhquoBTI1HKgO6X9V8tcVCuR69qJmsA9N00CA4Nzx0bo/zwBtqONmi55Ffm5w=="], + "@react-spring/three": ["@react-spring/three@10.1.0", "", { "dependencies": { "@react-spring/animated": "~10.1.0", "@react-spring/core": "~10.1.0", "@react-spring/shared": "~10.1.0", "@react-spring/types": "~10.1.0" }, "peerDependencies": { "@react-three/fiber": ">=6.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "three": ">=0.126" } }, "sha512-/XhDlpfVfsmIBMzK38M999oexmeyt+wlTLc66XTJKR/XlRpo9dpCGA+6viS+XjwEfD/S9/GlDrsTBy6vKiuxZA=="], - "@react-spring/types": ["@react-spring/types@10.0.3", "", {}, "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ=="], + "@react-spring/types": ["@react-spring/types@10.1.0", "", {}, "sha512-RkJAlkAZ5yZSRBMOSidamioBHLjf8wGKbtr3pZ5iKoHFNQuNM2k6DqJDIEUcNQlWRZXny6Eqys2c9NdugdbdyQ=="], "@react-three/drei": ["@react-three/drei@10.7.7", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mediapipe/tasks-vision": "0.10.17", "@monogrid/gainmap-js": "^3.0.6", "@use-gesture/react": "^10.3.1", "camera-controls": "^3.1.0", "cross-env": "^7.0.3", "detect-gpu": "^5.0.56", "glsl-noise": "^0.0.0", "hls.js": "^1.5.17", "maath": "^0.10.8", "meshline": "^3.3.1", "stats-gl": "^2.2.8", "stats.js": "^0.17.0", "suspend-react": "^0.1.3", "three-mesh-bvh": "^0.8.3", "three-stdlib": "^2.35.6", "troika-three-text": "^0.52.4", "tunnel-rat": "^0.1.2", "use-sync-external-store": "^1.4.0", "utility-types": "^3.11.0", "zustand": "^5.0.1" }, "peerDependencies": { "@react-three/fiber": "^9.0.0", "react": "^19", "react-dom": "^19", "three": ">=0.159" }, "optionalPeers": ["react-dom"] }, "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ=="], @@ -486,7 +486,7 @@ "@types/promise-timeout": ["@types/promise-timeout@1.3.3", "", {}, "sha512-gqmIw/4R1F1bqY5hWWZP0YE66iy6KkIu0tICpOLdXBuyHOAaSy9bNvwWHTJxyYHLozkieHM3Ej9GrYA6nuQPMA=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], "@types/react-color": ["@types/react-color@3.0.13", "", { "dependencies": { "@types/reactcss": "*" }, "peerDependencies": { "@types/react": "*" } }, "sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg=="], @@ -526,25 +526,25 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/type-utils": "8.60.0", "@typescript-eslint/utils": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.3", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.3", "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.60.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.60.0", "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.3", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.60.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.3", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.3", "@typescript-eslint/tsconfig-utils": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.60.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.60.0", "@typescript-eslint/tsconfig-utils": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -678,15 +678,15 @@ "axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], - "babel-jest": ["babel-jest@30.3.0", "", { "dependencies": { "@jest/transform": "30.3.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ=="], + "babel-jest": ["babel-jest@30.4.1", "", { "dependencies": { "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw=="], "babel-plugin-istanbul": ["babel-plugin-istanbul@7.0.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-instrument": "^6.0.2", "test-exclude": "^6.0.0" } }, "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA=="], - "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@30.3.0", "", { "dependencies": { "@types/babel__core": "^7.20.5" } }, "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg=="], + "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@30.4.0", "", { "dependencies": { "@types/babel__core": "^7.20.5" } }, "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA=="], "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.2.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg=="], - "babel-preset-jest": ["babel-preset-jest@30.3.0", "", { "dependencies": { "babel-plugin-jest-hoist": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ=="], + "babel-preset-jest": ["babel-preset-jest@30.4.0", "", { "dependencies": { "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -754,7 +754,7 @@ "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "ci-info": ["ci-info@4.3.0", "", {}, "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ=="], @@ -984,7 +984,7 @@ "escope": ["escope@3.6.0", "", { "dependencies": { "es6-map": "^0.1.3", "es6-weak-map": "^2.0.1", "esrecurse": "^4.1.0", "estraverse": "^4.1.1" } }, "sha512-75IUQsusDdalQEW/G/2esa87J7raqdJF+Ca0/Xm5C3Q58Nr4yVYjZGp/P1+2xiEVgXRrA39dpRb8LcshajbqDQ=="], - "eslint": ["eslint@10.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="], + "eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], @@ -1226,7 +1226,7 @@ "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - "i18next": ["i18next@26.1.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-dIU6td04DvQuIqVst5S9g0GviTmhZ0DYD4b9ociVGJmuCa5vZ2de/t+Enf4olvj87mF8Y2lwjNQBwC9QZsvzKQ=="], + "i18next": ["i18next@26.2.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -1490,7 +1490,7 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "linkify-it": ["linkify-it@5.0.1", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -1534,7 +1534,7 @@ "map-visit": ["map-visit@1.0.0", "", { "dependencies": { "object-visit": "^1.0.0" } }, "sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w=="], - "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], + "markdown-it": ["markdown-it@14.2.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ=="], "markdown-it-emoji": ["markdown-it-emoji@3.0.0", "", {}, "sha512-+rUD93bXHubA4arpEZO3q80so0qgoFJEKRkRbjKX8RTdca89v2kfyF+xR3i2sQTwql9tpPZPOQN5B+PunspXRg=="], @@ -1602,7 +1602,7 @@ "mute-stream": ["mute-stream@0.0.5", "", {}, "sha512-EbrziT4s8cWPmzr47eYVW3wimS4HsvlnV5ri1xw1aR6JQo/OrJX5rkl32K/QQHdxeabJETtfeaROGhd8W7uBgg=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "nanomatch": ["nanomatch@1.2.13", "", { "dependencies": { "arr-diff": "^4.0.0", "array-unique": "^0.3.2", "define-property": "^2.0.2", "extend-shallow": "^3.0.2", "fragment-cache": "^0.2.1", "is-windows": "^1.0.2", "kind-of": "^6.0.2", "object.pick": "^1.3.0", "regex-not": "^1.0.0", "snapdragon": "^0.8.1", "to-regex": "^3.0.1" } }, "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA=="], @@ -1730,7 +1730,7 @@ "possible-typed-array-names": ["possible-typed-array-names@1.0.0", "", {}, "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q=="], - "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss-media-query-parser": ["postcss-media-query-parser@0.2.3", "", {}, "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig=="], @@ -1806,9 +1806,9 @@ "react-popper": ["react-popper@2.3.0", "", { "dependencies": { "react-fast-compare": "^3.0.1", "warning": "^4.0.2" }, "peerDependencies": { "@popperjs/core": "^2.0.0", "react": "^16.8.0 || ^17 || ^18", "react-dom": "^16.8.0 || ^17 || ^18" } }, "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q=="], - "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" } }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + "react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], - "react-router": ["react-router@7.15.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ=="], + "react-router": ["react-router@7.15.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A=="], "react-test-renderer": ["react-test-renderer@19.2.6", "", { "dependencies": { "react-is": "^19.2.6", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw=="], @@ -1820,7 +1820,7 @@ "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "readline-sync": ["readline-sync@1.4.10", "", {}, "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw=="], @@ -1906,7 +1906,7 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sass": ["sass@1.99.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="], + "sass": ["sass@1.100.0", "", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ=="], "sass-lint": ["sass-lint@1.13.1", "", { "dependencies": { "commander": "^2.8.1", "eslint": "^2.7.0", "front-matter": "2.1.2", "fs-extra": "^3.0.1", "glob": "^7.0.0", "globule": "^1.0.0", "gonzales-pe-sl": "^4.2.3", "js-yaml": "^3.5.4", "known-css-properties": "^0.3.0", "lodash.capitalize": "^4.1.0", "lodash.kebabcase": "^4.0.0", "merge": "^1.2.0", "path-is-absolute": "^1.0.0", "util": "^0.10.3" }, "bin": "bin/sass-lint.js" }, "sha512-DSyah8/MyjzW2BWYmQWekYEKir44BpLqrCFsgs9iaWiVTcwZfwXHF586hh3D1n+/9ihUNMfd8iHAyb9KkGgs7Q=="], @@ -2024,7 +2024,7 @@ "strip-json-comments": ["strip-json-comments@1.0.4", "", { "bin": "cli.js" }, "sha512-AOPG8EBc5wAikaG1/7uFCNFJwnKOuQwFTpYBdTW6OvWHeZBQBrAA/amefHGrEiOnCPcLFZK6FUPtWVKpQVIRgg=="], - "stylelint": ["stylelint@17.11.0", "", { "dependencies": { "@csstools/css-calc": "^3.2.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.1", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.2", "global-modules": "^2.0.0", "globby": "^16.2.0", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", "is-plain-object": "^5.0.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.5.13", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.1", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" } }, "sha512-/3czzmbF9XdGWvReDF3Ex4R23Ajolo7j8RB2bFNEqk6Ht356nlpVV+G5bG2Qt8AW1ofJzXztBRDnAtd7cgowWA=="], + "stylelint": ["stylelint@17.12.0", "", { "dependencies": { "@csstools/css-calc": "^3.2.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.1", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.3", "global-modules": "^2.0.0", "globby": "^16.2.0", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.5.14", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.1", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" } }, "sha512-KIlzWXMHUvgfPUR0R7TK3H80yCIi0uoivUwf+6Az4yrHJD1Q3c1qIkh/H5Z0i/K3QXgtq/UMEkWyBUSUwnpnOg=="], "stylelint-config-recommended": ["stylelint-config-recommended@18.0.0", "", { "peerDependencies": { "stylelint": "^17.0.0" } }, "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg=="], @@ -2108,7 +2108,7 @@ "ts-graphviz": ["ts-graphviz@2.1.6", "", { "dependencies": { "@ts-graphviz/adapter": "^2.0.6", "@ts-graphviz/ast": "^2.0.7", "@ts-graphviz/common": "^2.1.5", "@ts-graphviz/core": "^2.0.7" } }, "sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw=="], - "ts-jest": ["ts-jest@29.4.9", "", { "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", "semver": "^7.7.4", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <7" }, "optionalPeers": ["@babel/core", "@jest/transform", "@jest/types", "babel-jest", "jest-util"], "bin": { "ts-jest": "cli.js" } }, "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ=="], + "ts-jest": ["ts-jest@29.4.11", "", { "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", "semver": "^7.8.0", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <7" }, "optionalPeers": ["@babel/core", "@jest/transform", "@jest/types", "babel-jest", "jest-util"], "bin": { "ts-jest": "cli.js" } }, "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g=="], "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], @@ -2352,8 +2352,6 @@ "@jest/console/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], - "@jest/core/@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], - "@jest/core/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], "@jest/core/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], @@ -2370,22 +2368,12 @@ "@jest/globals/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], - "@jest/reporters/@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], - "@jest/reporters/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "@jest/reporters/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], "@jest/source-map/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "@jest/transform/@jest/types": ["@jest/types@30.3.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw=="], - - "@jest/transform/jest-haste-map": ["jest-haste-map@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", "jest-util": "30.3.0", "jest-worker": "30.3.0", "picomatch": "^4.0.3", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.3" } }, "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA=="], - - "@jest/transform/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], - - "@jest/transform/jest-util": ["jest-util@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg=="], - "@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], "@jest/types/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], @@ -2418,9 +2406,11 @@ "@types/jest/pretty-format": ["pretty-format@30.0.5", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw=="], + "@types/react-test-renderer/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/redux-immutable-state-invariant/redux": ["redux@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "@vue/compiler-core/@babel/parser": ["@babel/parser@7.26.7", "", { "dependencies": { "@babel/types": "^7.26.7" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w=="], @@ -2606,8 +2596,6 @@ "jest-config/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "jest-config/babel-jest": ["babel-jest@30.4.1", "", { "dependencies": { "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw=="], - "jest-config/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "jest-config/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], @@ -2638,14 +2626,10 @@ "jest-mock/jest-util": ["jest-util@30.0.5", "", { "dependencies": { "@jest/types": "30.0.5", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.2" } }, "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g=="], - "jest-runner/@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], - "jest-runner/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], "jest-runtime/@jest/source-map": ["@jest/source-map@30.0.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "callsites": "^3.1.0", "graceful-fs": "^4.2.11" } }, "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg=="], - "jest-runtime/@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], - "jest-runtime/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "jest-runtime/jest-message-util": ["jest-message-util@30.4.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-util": "30.4.1", "picomatch": "^4.0.3", "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ=="], @@ -2660,8 +2644,6 @@ "jest-snapshot/@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], - "jest-snapshot/@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], - "jest-snapshot/expect": ["expect@30.4.1", "", { "dependencies": { "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-util": "30.4.1" } }, "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA=="], "jest-snapshot/jest-diff": ["jest-diff@30.4.1", "", { "dependencies": { "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.4.1" } }, "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA=="], @@ -2794,7 +2776,7 @@ "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "stylelint/file-entry-cache": ["file-entry-cache@11.1.2", "", { "dependencies": { "flat-cache": "^6.1.20" } }, "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log=="], + "stylelint/file-entry-cache": ["file-entry-cache@11.1.3", "", { "dependencies": { "flat-cache": "^6.1.22" } }, "sha512-oMbq0PD6VIiIwMF6LIa7MEwd/l9huKwmqRKXqmrkqIZv8CvRbfowL+L0ryAl8h//HfAS0zS+4SbYoRyAoA6BJA=="], "stylelint-scss/known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="], @@ -2814,7 +2796,7 @@ "to-object-path/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], - "ts-jest/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "ts-jest/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": "lib/cli.js" }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], @@ -2894,8 +2876,6 @@ "@jest/console/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], - "@jest/core/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], - "@jest/core/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@jest/core/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], @@ -2914,20 +2894,12 @@ "@jest/fake-timers/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], - "@jest/reporters/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], - "@jest/reporters/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@jest/reporters/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@jest/reporters/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], - "@jest/transform/@jest/types/@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], - - "@jest/transform/jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "@jest/transform/jest-haste-map/jest-worker": ["jest-worker@30.3.0", "", { "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", "jest-util": "30.3.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" } }, "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ=="], - "@react-three/eslint-plugin/eslint/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], "@react-three/eslint-plugin/eslint/@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], @@ -3060,10 +3032,6 @@ "jest-circus/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], - "jest-config/babel-jest/@jest/transform": ["@jest/transform@30.4.1", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.4.1", "jest-regex-util": "30.4.0", "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ=="], - - "jest-config/babel-jest/babel-preset-jest": ["babel-preset-jest@30.4.0", "", { "dependencies": { "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg=="], - "jest-config/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "jest-config/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], @@ -3082,14 +3050,10 @@ "jest-mock/@jest/types/@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], - "jest-runner/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], - "jest-runner/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "jest-runner/jest-message-util/pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], - "jest-runtime/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], - "jest-runtime/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "jest-runtime/jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -3112,8 +3076,6 @@ "jest-skipped-reporter/jest-util/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "jest-snapshot/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], - "jest-snapshot/expect/jest-mock": ["jest-mock@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "jest-util": "30.4.1" } }, "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw=="], "jest-snapshot/jest-diff/@jest/diff-sequences": ["@jest/diff-sequences@30.4.0", "", {}, "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g=="], @@ -3236,8 +3198,6 @@ "@jest/reporters/jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], - "@jest/transform/jest-haste-map/jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "@react-three/eslint-plugin/eslint/espree/acorn": ["acorn@8.14.0", "", { "bin": "bin/acorn" }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "@react-three/eslint-plugin/eslint/file-entry-cache/flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], @@ -3304,10 +3264,6 @@ "jest-circus/jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "jest-config/babel-jest/@jest/transform/write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], - - "jest-config/babel-jest/babel-preset-jest/babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@30.4.0", "", { "dependencies": { "@types/babel__core": "^7.20.5" } }, "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA=="], - "jest-config/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "jest-message-util/@jest/types/@jest/pattern/jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], @@ -3404,8 +3360,6 @@ "@jest/reporters/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "@jest/transform/jest-haste-map/jest-worker/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "@react-three/eslint-plugin/eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "detective-typescript/@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 629461d6b1..33909064ff 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -10,6 +10,7 @@ Follow existing codebase conventions and style, for example: ## Testing Instructions - Jest mocks already exist for many modules in `frontend/__test_support__`. - New tests should be written using the `@testing-library/react` library. +- Use only test-level mocks, avoid module-level mocks. - Do not terminate test commands before they are completed. ### For the files you change diff --git a/frontend/__test_support__/react_element_search.tsx b/frontend/__test_support__/react_element_search.tsx new file mode 100644 index 0000000000..6969dd0a5c --- /dev/null +++ b/frontend/__test_support__/react_element_search.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +// eslint-disable-next-line comma-spacing +export const findElement = <P,>( + node: React.ReactNode, + matcher: (type: React.ElementType) => boolean, +): React.ReactElement<P> | undefined => { + if (!node || typeof node === "string" || typeof node === "number") { + return undefined; + } + if (Array.isArray(node)) { + const children: React.ReactNode[] = node; + for (const child of children) { + const found = findElement<P>(child, matcher); + if (found) { return found; } + } + return undefined; + } + if (React.isValidElement<{ children?: React.ReactNode }>(node)) { + if (matcher(node.type as React.ElementType)) { + return node as React.ReactElement<P>; + } + return findElement<P>(node.props.children, matcher); + } + return undefined; +}; + +export const findElementByType = + // eslint-disable-next-line comma-spacing + <P,>( + node: React.ReactNode, + component: React.ElementType, + ): React.ReactElement<P> | undefined => + findElement<P>(node, type => type === component); diff --git a/frontend/photos/data_management/__tests__/toggle_highlight_modified_test.tsx b/frontend/photos/data_management/__tests__/toggle_highlight_modified_test.tsx index 2711543309..b1c427aa58 100644 --- a/frontend/photos/data_management/__tests__/toggle_highlight_modified_test.tsx +++ b/frontend/photos/data_management/__tests__/toggle_highlight_modified_test.tsx @@ -6,6 +6,9 @@ import * as configStorageActions from "../../../config_storage/actions"; import { BooleanSetting } from "../../../session_keys"; import { ToggleButton } from "../../../ui"; import { ToggleButtonProps } from "../../../ui/toggle_button"; +import { + findElementByType, +} from "../../../__test_support__/react_element_search"; let setWebAppConfigValueSpy: jest.SpyInstance; let getWebAppConfigValueSpy: jest.SpyInstance; @@ -22,27 +25,6 @@ afterEach(() => { getWebAppConfigValueSpy.mockRestore(); }); -const findByType = ( - node: React.ReactNode, - type: unknown, -): React.ReactElement<ToggleButtonProps> | undefined => { - if (!node) { return undefined; } - if (Array.isArray(node)) { - for (const child of React.Children.toArray(node)) { - const found = findByType(child, type); - if (found) { return found; } - } - return undefined; - } - if (React.isValidElement(node)) { - if (node.type === type) { - return node as React.ReactElement<ToggleButtonProps>; - } - return findByType((node.props as { children?: React.ReactNode }).children, type); - } - return undefined; -}; - describe("<ToggleHighlightModified />", () => { const fakeProps = (): ToggleHighlightModifiedProps => ({ dispatch: jest.fn(), @@ -52,7 +34,8 @@ describe("<ToggleHighlightModified />", () => { it("toggles on", () => { const { container } = render(<ToggleHighlightModified {...fakeProps()} />); const element = ToggleHighlightModified(fakeProps()); - const toggleButton = findByType(element, ToggleButton); + const toggleButton = + findElementByType<ToggleButtonProps>(element, ToggleButton); if (!toggleButton) { expect(container.firstChild).toBeTruthy(); return; @@ -67,7 +50,8 @@ describe("<ToggleHighlightModified />", () => { p.getConfigValue = () => true; const { container } = render(<ToggleHighlightModified {...p} />); const element = ToggleHighlightModified(p); - const toggleButton = findByType(element, ToggleButton); + const toggleButton = + findElementByType<ToggleButtonProps>(element, ToggleButton); if (!toggleButton) { expect(container.firstChild).toBeTruthy(); return; diff --git a/frontend/regimens/bulk_scheduler/__tests__/scheduler_test.tsx b/frontend/regimens/bulk_scheduler/__tests__/scheduler_test.tsx index 9e77052a7c..28970827f9 100644 --- a/frontend/regimens/bulk_scheduler/__tests__/scheduler_test.tsx +++ b/frontend/regimens/bulk_scheduler/__tests__/scheduler_test.tsx @@ -14,34 +14,14 @@ import { RegimenSchedulerProps } from "../interfaces"; import { fakeState } from "../../../__test_support__/fake_state"; import { Path } from "../../../internal_urls"; import { DesignerPanelHeader } from "../../../farm_designer/designer_panel"; +import { + findElementByType, +} from "../../../__test_support__/react_element_search"; -const findByType = ( - node: React.ReactNode, - type: unknown, -): React.ReactElement<{ +interface DesignerPanelHeaderTestProps { children?: React.ReactNode; backTo?: string; -}> | undefined => { - if (!node) { return undefined; } - if (Array.isArray(node)) { - for (const child of React.Children.toArray(node)) { - const found = findByType(child, type); - if (found) { return found; } - } - return undefined; - } - if (React.isValidElement(node)) { - if (node.type === type) { - return node as React.ReactElement<{ - children?: React.ReactNode; - backTo?: string; - }>; - } - return findByType( - (node.props as { children?: React.ReactNode }).children, type); - } - return undefined; -}; +} describe("<DesignerRegimenScheduler />", () => { const fakeProps = (): RegimenSchedulerProps => ({ @@ -69,7 +49,8 @@ describe("<DesignerRegimenScheduler />", () => { const p = fakeProps(); p.current = undefined; const element = new DesignerRegimenScheduler(p).render(); - const header = findByType(element, DesignerPanelHeader); + const header = findElementByType<DesignerPanelHeaderTestProps>( + element, DesignerPanelHeader); expect(header?.props.backTo).toEqual(Path.regimens()); }); diff --git a/frontend/regimens/list/__tests__/regimen_list_item_test.tsx b/frontend/regimens/list/__tests__/regimen_list_item_test.tsx index 744914df2d..aff726d884 100644 --- a/frontend/regimens/list/__tests__/regimen_list_item_test.tsx +++ b/frontend/regimens/list/__tests__/regimen_list_item_test.tsx @@ -9,6 +9,9 @@ import * as crud from "../../../api/crud"; import { Path } from "../../../internal_urls"; import * as popover from "../../../ui/popover"; import { ColorPicker } from "../../../ui"; +import { + findElementByType, +} from "../../../__test_support__/react_element_search"; let selectRegimenSpy: jest.SpyInstance; let editSpy: jest.SpyInstance; @@ -29,33 +32,10 @@ afterEach(() => { popoverSpy.mockRestore(); }); -const findByType = ( - node: React.ReactNode, - type: unknown, -): React.ReactElement<{ +interface ColorPickerTestProps { children?: React.ReactNode; onChange?: (color: Color) => void; -}> | undefined => { - if (!node) { return undefined; } - if (Array.isArray(node)) { - for (const child of React.Children.toArray(node)) { - const found = findByType(child, type); - if (found) { return found; } - } - return undefined; - } - if (React.isValidElement(node)) { - if (node.type === type) { - return node as React.ReactElement<{ - children?: React.ReactNode; - onChange?: (color: Color) => void; - }>; - } - return findByType( - (node.props as { children?: React.ReactNode }).children, type); - } - return undefined; -}; +} describe("<RegimenListItem/>", () => { const fakeProps = (): RegimenListItemProps => ({ @@ -118,8 +98,8 @@ describe("<RegimenListItem/>", () => { colorPickerCluster?.props.onChange("red"); if (!colorPickerCluster) { const element = RegimenListItem(p); - const colorPicker = findByType(element, ColorPicker) as - React.ReactElement<{ onChange?: (color: Color) => void }> | undefined; + const colorPicker = findElementByType<ColorPickerTestProps>( + element, ColorPicker); colorPicker?.props.onChange?.("red"); } } diff --git a/frontend/sequences/step_tiles/__tests__/tile_execute_test.tsx b/frontend/sequences/step_tiles/__tests__/tile_execute_test.tsx index 54f2699421..9dc30561e1 100644 --- a/frontend/sequences/step_tiles/__tests__/tile_execute_test.tsx +++ b/frontend/sequences/step_tiles/__tests__/tile_execute_test.tsx @@ -8,12 +8,16 @@ import { TileExecute } from "../tile_execute"; import { render } from "@testing-library/react"; import { Execute, ParameterApplication, Coordinate } from "farmbot"; import { LocalsList } from "../../locals_list/locals_list"; +import { LocalsListProps } from "../../locals_list/locals_list_support"; import { StepParams } from "../../interfaces"; import { fakeStepParams } from "../../../__test_support__/fake_sequence_step_data"; -import { StepWrapper } from "../../step_ui"; +import { StepWrapper, StepWrapperProps } from "../../step_ui"; import { ToolTips } from "../../../constants"; import * as crud from "../../../api/crud"; import * as selectorsById from "../../../resources/selectors_by_id"; +import { + findElementByType, +} from "../../../__test_support__/react_element_search"; const coordinate = (x = 0, y = 0, z = 0): Coordinate => ({ kind: "coordinate", args: { x, y, z } }); @@ -22,31 +26,6 @@ const fakeProps = (): StepParams<Execute> => ({ ...fakeStepParams({ kind: "execute", args: { sequence_id: 0 } }), }); -// eslint-disable-next-line comma-spacing -const findByType = <P,>( - node: React.ReactNode, - type: React.ComponentType<P>, -): React.ReactElement<P> | undefined => { - if (!node) { return undefined; } - if (Array.isArray(node)) { - for (const child of React.Children.toArray(node)) { - const found = findByType(child, type); - if (found) { return found; } - } - return undefined; - } - if (React.isValidElement(node)) { - if (node.type === type) { - return node as React.ReactElement<P>; - } - const elementWithChildren = node as React.ReactElement<{ - children?: React.ReactNode; - }>; - return findByType(elementWithChildren.props.children, type); - } - return undefined; -}; - beforeEach(() => { jest.clearAllMocks(); jest.spyOn(crud, "editStep").mockImplementation(mockEditStep); @@ -96,7 +75,8 @@ describe("<TileExecute />", () => { p.currentStep.args.sequence_id = mockSequence.body.id; mockSequence.body.description = "description"; const element = new TileExecute(p).render(); - const wrapper = findByType(element, StepWrapper); + const wrapper = findElementByType<StepWrapperProps>( + element, StepWrapper); expect(wrapper?.props.helpText).toEqual("description"); }); @@ -104,7 +84,8 @@ describe("<TileExecute />", () => { const p = fakeProps(); mockSequence.body.description = ""; const element = new TileExecute(p).render(); - const wrapper = findByType(element, StepWrapper); + const wrapper = findElementByType<StepWrapperProps>( + element, StepWrapper); expect(wrapper?.props.helpText) .toEqual(ToolTips.EXECUTE_SEQUENCE); }); @@ -155,7 +136,8 @@ describe("<TileExecute />", () => { const p = fakeProps(); p.currentStep.args.sequence_id = 0; const element = new TileExecute(p).render(); - expect(findByType(element, LocalsList)).toBeUndefined(); + expect(findElementByType<LocalsListProps>( + element, LocalsList)).toBeUndefined(); }); it("selects a location", () => { @@ -181,7 +163,8 @@ describe("<TileExecute />", () => { } } }; - const localsList = findByType(element, LocalsList); + const localsList = findElementByType<LocalsListProps>( + element, LocalsList); localsList?.props.onChange(variable, variable.args.label); mockEditStep.mock.calls[0][0].executor(p.currentStep); expect(p.currentStep).toEqual({ @@ -251,7 +234,8 @@ describe("<TileExecute />", () => { } } }; - const localsList = findByType(element, LocalsList); + const localsList = findElementByType<LocalsListProps>( + element, LocalsList); localsList?.props.onChange(variable, variable.args.label); mockEditStep.mock.calls[0][0].executor(p.currentStep); expect(p.currentStep).toEqual({ diff --git a/frontend/sequences/step_tiles/__tests__/tile_move_absolute_test.tsx b/frontend/sequences/step_tiles/__tests__/tile_move_absolute_test.tsx index 252930c782..fa4a36806d 100644 --- a/frontend/sequences/step_tiles/__tests__/tile_move_absolute_test.tsx +++ b/frontend/sequences/step_tiles/__tests__/tile_move_absolute_test.tsx @@ -22,15 +22,26 @@ import { ExpandableHeader } from "../../../ui/expandable_header"; import * as blueprintCore from "@blueprintjs/core"; import { CollapseProps } from "@blueprintjs/core"; import { - actRenderer, - createRenderer, -} from "../../../__test_support__/test_renderer"; + findElementByType, +} from "../../../__test_support__/react_element_search"; let overwriteSpy: jest.SpyInstance; let isDesktopSpy: jest.SpyInstance; let collapseSpy: jest.SpyInstance; let originalInnerWidth = window.innerWidth; +const createBlock = (p: StepParams<MoveAbsolute>) => { + const block = new TileMoveAbsolute(p); + jest.spyOn(block, "setState").mockImplementation(update => { + Object.assign(block.state, update); + }); + return block; +}; + +const getHeader = (block: TileMoveAbsolute) => + findElementByType<React.ComponentProps<typeof ExpandableHeader>>( + block.render(), ExpandableHeader); + const setInnerWidth = (innerWidth: number) => { Object.defineProperty(window, "innerWidth", { configurable: true, @@ -115,17 +126,13 @@ describe("<TileMoveAbsolute />", () => { it("renders options on wide screens", () => { const p = fakeProps(); isDesktopSpy.mockReturnValue(true); - const rendered = createRenderer(<TileMoveAbsolute {...p} />); - const header = rendered.root.findByType(ExpandableHeader); - expect(header.props.title).toEqual("Options"); + expect(getHeader(createBlock(p))?.props.title).toEqual("Options"); }); it("doesn't render options on narrow screens", () => { const p = fakeProps(); isDesktopSpy.mockReturnValue(false); - const rendered = createRenderer(<TileMoveAbsolute {...p} />); - const header = rendered.root.findByType(ExpandableHeader); - expect(header.props.title).toEqual(""); + expect(getHeader(createBlock(p))?.props.title).toEqual(""); }); it("expands form", () => { @@ -133,28 +140,24 @@ describe("<TileMoveAbsolute />", () => { p.expandStepOptions = false; p.currentStep.args.offset.args = { x: 0, y: 0, z: 0 }; p.currentStep.args.speed = 100; - const rendered = createRenderer(<TileMoveAbsolute {...p} />); - const header = rendered.root.findByType(ExpandableHeader); - expect(header.props.expanded).toBeFalsy(); - actRenderer(() => { - header.props.onClick(); - }); - expect(rendered.root.findByType(ExpandableHeader).props.expanded).toBeTruthy(); + const block = createBlock(p); + const header = getHeader(block); + expect(header?.props.expanded).toBeFalsy(); + header?.props.onClick(); + expect(getHeader(block)?.props.expanded).toBeTruthy(); }); it("expands form by default", () => { const p = fakeProps(); p.expandStepOptions = true; - const rendered = createRenderer(<TileMoveAbsolute {...p} />); - expect(rendered.root.findByType(ExpandableHeader).props.expanded).toBeTruthy(); + expect(getHeader(createBlock(p))?.props.expanded).toBeTruthy(); }); it("expands form when offset is present", () => { const p = fakeProps(); p.expandStepOptions = false; p.currentStep.args.offset.args.z = 100; - const rendered = createRenderer(<TileMoveAbsolute {...p} />); - expect(rendered.root.findByType(ExpandableHeader).props.expanded).toBeTruthy(); + expect(getHeader(createBlock(p))?.props.expanded).toBeTruthy(); }); it("not expanding form when speed is 100", () => { @@ -162,8 +165,7 @@ describe("<TileMoveAbsolute />", () => { p.expandStepOptions = false; p.currentStep.args.offset.args = { x: 0, y: 0, z: 0 }; p.currentStep.args.speed = 100; - const rendered = createRenderer(<TileMoveAbsolute {...p} />); - expect(rendered.root.findByType(ExpandableHeader).props.expanded).toBeFalsy(); + expect(getHeader(createBlock(p))?.props.expanded).toBeFalsy(); }); it("expands form when speed is not 100", () => { @@ -171,8 +173,7 @@ describe("<TileMoveAbsolute />", () => { p.expandStepOptions = false; p.currentStep.args.offset.args = { x: 0, y: 0, z: 0 }; p.currentStep.args.speed = 50; - const rendered = createRenderer(<TileMoveAbsolute {...p} />); - expect(rendered.root.findByType(ExpandableHeader).props.expanded).toBeTruthy(); + expect(getHeader(createBlock(p))?.props.expanded).toBeTruthy(); }); it("returns correct node", () => { diff --git a/frontend/settings/pin_bindings/__tests__/model_test.tsx b/frontend/settings/pin_bindings/__tests__/model_test.tsx index 201e62a815..86d399b496 100644 --- a/frontend/settings/pin_bindings/__tests__/model_test.tsx +++ b/frontend/settings/pin_bindings/__tests__/model_test.tsx @@ -32,6 +32,7 @@ import * as deviceActions from "../../../devices/actions"; import { ButtonPin } from "../list_and_label_support"; import { BoxTopBaseProps } from "../interfaces"; import { FirmwareHardware } from "farmbot"; +import * as ui from "../../../ui"; describe("setZForAllInGroup()", () => { it("sets z", () => { @@ -53,6 +54,7 @@ describe("<ElectronicsBoxModel />", () => { let execSequenceSpy: jest.SpyInstance; let useFrameSpy: jest.SpyInstance; let reactUseRefSpy: jest.SpyInstance; + let fbSelectSpy: jest.SpyInstance; beforeEach(() => { jest.useFakeTimers(); @@ -74,6 +76,8 @@ describe("<ElectronicsBoxModel />", () => { })); execSequenceSpy = jest.spyOn(deviceActions, "execSequence") .mockImplementation(jest.fn()); + fbSelectSpy = jest.spyOn(ui, "FBSelect") + .mockImplementation((() => <div />) as never); }); afterEach(() => { @@ -82,6 +86,7 @@ describe("<ElectronicsBoxModel />", () => { document.body.style.cursor = "default"; reactUseRefSpy.mockRestore(); useFrameSpy.mockRestore(); + fbSelectSpy.mockRestore(); }); const fakeProps = (): BoxTopBaseProps => { diff --git a/frontend/settings/pin_bindings/__tests__/pin_binding_input_group_test.tsx b/frontend/settings/pin_bindings/__tests__/pin_binding_input_group_test.tsx index e51f2ac134..412d6bf936 100644 --- a/frontend/settings/pin_bindings/__tests__/pin_binding_input_group_test.tsx +++ b/frontend/settings/pin_bindings/__tests__/pin_binding_input_group_test.tsx @@ -27,7 +27,8 @@ import { PinBindingType, PinBindingSpecialAction, } from "farmbot/dist/resources/api_resources"; import { error, warning } from "../../../toast/toast"; -import { FBSelect } from "../../../ui"; +import * as ui from "../../../ui"; +import { FBSelectProps } from "../../../ui"; import { actRenderer, createRenderer, @@ -36,12 +37,15 @@ import { let getDeviceSpy: jest.SpyInstance; let initSaveSpy: jest.SpyInstance; +let fbSelectMock: jest.SpyInstance; beforeEach(() => { jest.clearAllMocks(); getDeviceSpy = jest.spyOn(deviceModule, "getDevice") .mockImplementation(() => mockDevice as never); initSaveSpy = jest.spyOn(crud, "initSave").mockImplementation(jest.fn()); + fbSelectMock = jest.spyOn(ui, "FBSelect") + .mockImplementation(((_props: FBSelectProps) => <div />) as never); mockDevice.registerGpio = jest.fn(() => Promise.resolve()); mockDevice.unregisterGpio = jest.fn(() => Promise.resolve()); }); @@ -49,6 +53,7 @@ beforeEach(() => { afterEach(() => { getDeviceSpy.mockRestore(); initSaveSpy.mockRestore(); + fbSelectMock.mockRestore(); }); const AVAILABLE_PIN = 18; @@ -72,10 +77,18 @@ describe("<PinBindingInputGroup/>", () => { }; }; + const createInstance = (p = fakeProps()) => { + const instance = new PinBindingInputGroup(p); + jest.spyOn(instance, "setState").mockImplementation(update => { + Object.assign(instance.state, update); + }); + return instance; + }; + it("renders", () => { const wrapper = createRenderer(<PinBindingInputGroup {...fakeProps()} />); - const buttons = wrapper.root.findAllByType("button"); - expect(buttons.length).toBe(3); + const bindButton = wrapper.root.findByProps({ title: "BIND" }); + expect(bindButton).toBeTruthy(); }); it("no pin selected", () => { @@ -152,25 +165,19 @@ describe("<PinBindingInputGroup/>", () => { const key = Object.keys(p.resources.byKind.Sequence)[0]; const s = p.resources.references[key]; const id = s?.body.id; - const wrapper = createRenderer(<PinBindingInputGroup {...p} />); - const instance = - getRendererInstance<PinBindingInputGroup, PinBindingInputGroupProps>( - wrapper, PinBindingInputGroup); + const instance = createInstance(p); expect(instance.state.sequenceIdInput).toEqual(undefined); - actRenderer(() => instance.changeBinding({ + instance.changeBinding({ label: "label", value: "" + id, headingId: PinBindingType.standard - })); + }); expect(instance.state.sequenceIdInput).toEqual(id); }); it("attempts to set pin 99", () => { - const wrapper = createRenderer(<PinBindingInputGroup {...fakeProps()} />); - const instance = - getRendererInstance<PinBindingInputGroup, PinBindingInputGroupProps>( - wrapper, PinBindingInputGroup); + const instance = createInstance(); expect(instance.state.pinNumberInput).toEqual(undefined); - actRenderer(() => instance.setSelectedPin(99)); + instance.setSelectedPin(99); expect(error).toHaveBeenCalledWith( "Invalid Raspberry Pi GPIO pin number."); expect(warning).not.toHaveBeenCalled(); @@ -179,12 +186,9 @@ describe("<PinBindingInputGroup/>", () => { it("attempts to set pin 1", () => { expect(validGpioPins.length).toBeGreaterThan(0); - const wrapper = createRenderer(<PinBindingInputGroup {...fakeProps()} />); - const instance = - getRendererInstance<PinBindingInputGroup, PinBindingInputGroupProps>( - wrapper, PinBindingInputGroup); + const instance = createInstance(); expect(instance.state.pinNumberInput).toEqual(undefined); - actRenderer(() => instance.setSelectedPin(1)); + instance.setSelectedPin(1); expect(error).not.toHaveBeenCalled(); expect(warning).toHaveBeenCalledWith( "Reserved Raspberry Pi pin may not work as expected."); @@ -193,13 +197,10 @@ describe("<PinBindingInputGroup/>", () => { it("rejects pin already in use", () => { const p = fakeProps(); - const wrapper = createRenderer(<PinBindingInputGroup {...p} />); - const instance = - getRendererInstance<PinBindingInputGroup, PinBindingInputGroupProps>( - wrapper, PinBindingInputGroup); + const instance = createInstance(p); expect(instance.state.pinNumberInput).toEqual(undefined); const { pin_number } = p.pinBindings[0]; - actRenderer(() => instance.setSelectedPin(pin_number)); + instance.setSelectedPin(pin_number); expect(error).toHaveBeenCalledWith( "Raspberry Pi GPIO pin already bound or in use."); expect(warning).not.toHaveBeenCalled(); @@ -208,27 +209,21 @@ describe("<PinBindingInputGroup/>", () => { it("changes pin number to available pin", () => { expect(validGpioPins.length).toBeGreaterThan(0); - const wrapper = createRenderer(<PinBindingInputGroup {...fakeProps()} />); - const instance = - getRendererInstance<PinBindingInputGroup, PinBindingInputGroupProps>( - wrapper, PinBindingInputGroup); + const instance = createInstance(); expect(instance.state.pinNumberInput).toEqual(undefined); - actRenderer(() => instance.setSelectedPin(AVAILABLE_PIN)); + instance.setSelectedPin(AVAILABLE_PIN); expect(error).not.toHaveBeenCalled(); expect(warning).not.toHaveBeenCalled(); expect(instance.state.pinNumberInput).toEqual(AVAILABLE_PIN); }); it("changes special action", () => { - const wrapper = createRenderer(<PinBindingInputGroup {...fakeProps()} />); - const instance = - getRendererInstance<PinBindingInputGroup, PinBindingInputGroupProps>( - wrapper, PinBindingInputGroup); - actRenderer(() => instance.changeBinding({ + const instance = createInstance(); + instance.changeBinding({ label: "", value: PinBindingSpecialAction.sync, headingId: PinBindingType.special, - })); + }); expect(instance.state.specialActionInput) .toEqual(PinBindingSpecialAction.sync); }); @@ -244,8 +239,10 @@ describe("<PinNumberInputGroup />", () => { it("sets pin", () => { const p = fakeProps(); - const wrapper = createRenderer(<PinNumberInputGroup {...p} />); - const select = wrapper.root.findByType(FBSelect); + const row = PinNumberInputGroup(p) as React.ReactElement<{ + children: React.ReactNode[]; + }>; + const select = row.props.children[2] as React.ReactElement<FBSelectProps>; select.props.onChange({ label: "", value: AVAILABLE_PIN }); @@ -254,6 +251,20 @@ describe("<PinNumberInputGroup />", () => { }); describe("<BindingTargetDropdown />", () => { + let fbSelectSpy: jest.SpyInstance; + + beforeEach(() => { + fbSelectSpy = jest.spyOn(ui, "FBSelect") + .mockImplementation((() => <div />) as never); + }); + + afterEach(() => { + fbSelectSpy.mockRestore(); + }); + + const fbSelectProps = () => + fbSelectSpy.mock.calls[0][0] as FBSelectProps; + const fakeProps = (): BindingTargetDropdownProps => { const sequence0 = fakeSequence(); sequence0.body.id = undefined; @@ -272,27 +283,33 @@ describe("<BindingTargetDropdown />", () => { it("shows sequence selected", () => { const p = fakeProps(); p.sequenceIdInput = 1; - const wrapper = createRenderer(<BindingTargetDropdown {...p} />); - expect(wrapper.root.findByType(FBSelect).props.selected).toEqual(undefined); + createRenderer(<BindingTargetDropdown {...p} />); + expect(fbSelectProps().selectedItem).toEqual({ + label: "fake", + value: 1, + }); }); it("shows action selected", () => { const p = fakeProps(); p.specialActionInput = PinBindingSpecialAction.sync; - const wrapper = createRenderer(<BindingTargetDropdown {...p} />); - expect(wrapper.root.findByType(FBSelect).props.selected).toEqual(undefined); + createRenderer(<BindingTargetDropdown {...p} />); + expect(fbSelectProps().selectedItem).toEqual({ + label: "Sync", + value: "sync", + }); }); it("shows nothing selected", () => { - const wrapper = createRenderer(<BindingTargetDropdown {...fakeProps()} />); - expect(wrapper.root.findByType(FBSelect).props.selected).toEqual(undefined); + createRenderer(<BindingTargetDropdown {...fakeProps()} />); + expect(fbSelectProps().selectedItem).toEqual(undefined); }); it("shows sequences", () => { const p = fakeProps(); p.sequenceIdInput = 1; - const wrapper = createRenderer(<BindingTargetDropdown {...p} />); - const { list } = wrapper.root.findByType(FBSelect).props; + createRenderer(<BindingTargetDropdown {...p} />); + const { list } = fbSelectProps(); expect(list?.length).toEqual(11); expect(list).toContainEqual({ isNull: true, diff --git a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx index 788279b1db..4de005c393 100644 --- a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx @@ -132,7 +132,7 @@ describe("<PlantInstances />", () => { it("disables frustum culling for billboarded plant icons", () => { const wrapper = createRenderer(<PlantInstances {...fakeProps()} />); const mesh = wrapper.root.findAll(node => - node.type == "instancedMesh")[0]; + (node.type as string) == "instancedMesh")[0]; expect(mesh.props.frustumCulled).toEqual(false); unmountRenderer(wrapper); }); diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx index 0ec36b3a79..c82c50a4e5 100644 --- a/frontend/three_d_garden/garden/__tests__/point_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx @@ -98,7 +98,8 @@ describe("<Point />", () => { it("renders instanced point markers", () => { const wrapper = createRenderer(<PointInstances {...fakeInstanceProps()} />); mountedWrappers.push(wrapper); - const meshes = wrapper.root.findAll(node => node.type == "instancedMesh"); + const meshes = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh"); expect(meshes.length).toEqual(3); expect(meshes[0].props.args[2]).toEqual(2); }); diff --git a/frontend/three_d_garden/garden/__tests__/solar_test.tsx b/frontend/three_d_garden/garden/__tests__/solar_test.tsx index ddc0171d03..5780d9e749 100644 --- a/frontend/three_d_garden/garden/__tests__/solar_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/solar_test.tsx @@ -38,7 +38,7 @@ describe("<Solar />", () => { p.config.solar = true; const wrapper = createRenderer(<Solar {...p} />); const solarCells = wrapper.root.findAll(node => - node.type == "instancedMesh"); + (node.type as string) == "instancedMesh"); expect(solarCells[0].props.frustumCulled).toEqual(false); expect(solarCells[0].props.renderOrder).toEqual(RenderOrder.one + 1); unmountRenderer(wrapper); @@ -51,9 +51,10 @@ describe("<Solar />", () => { const wiring = wrapper.root.findAll(node => node.props.name == "solar-wiring")[0]; const panel = wrapper.root.findAll(node => - node.type == "mesh" && node.props.renderOrder == RenderOrder.one)[0]; + (node.type as string) == "mesh" + && node.props.renderOrder == RenderOrder.one)[0]; const cells = wrapper.root.findAll(node => - node.type == "instancedMesh")[0]; + (node.type as string) == "instancedMesh")[0]; const cellMaterial = wrapper.root.findAll(node => node.props.side == DoubleSide)[0]; expect(wiring.props.renderOrder).toEqual(RenderOrder.default); diff --git a/frontend/three_d_garden/garden/__tests__/weed_test.tsx b/frontend/three_d_garden/garden/__tests__/weed_test.tsx index 5e2dbb7a20..fcec0d76f7 100644 --- a/frontend/three_d_garden/garden/__tests__/weed_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/weed_test.tsx @@ -86,7 +86,8 @@ describe("<Weed />", () => { it("renders instanced weeds", () => { const wrapper = createRenderer(<WeedInstances {...fakeInstanceProps()} />); mountedWrappers.push(wrapper); - const meshes = wrapper.root.findAll(node => node.type == "instancedMesh"); + const meshes = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh"); expect(meshes.length).toEqual(2); expect(meshes[0].props.name).toEqual("weed-icons"); expect(meshes[1].props.name).toEqual("weed-radius"); diff --git a/frontend/tools/__tests__/edit_tool_slot_test.tsx b/frontend/tools/__tests__/edit_tool_slot_test.tsx index 481ba81199..6bd24e4e3e 100644 --- a/frontend/tools/__tests__/edit_tool_slot_test.tsx +++ b/frontend/tools/__tests__/edit_tool_slot_test.tsx @@ -10,7 +10,6 @@ import { } from "../../__test_support__/resource_index_builder"; import * as crud from "../../api/crud"; import { mapStateToPropsEdit } from "../state_to_props"; -import { SlotEditRows } from "../tool_slot_edit_components"; import { fakeToolTransformProps } from "../../__test_support__/fake_tool_info"; import { EditToolSlotProps } from "../interfaces"; import * as toolGraphics from "../../farm_designer/map/layers/tool_slots/tool_graphics"; @@ -157,11 +156,19 @@ describe("<EditToolSlot />", () => { it("finds tool", () => { const p = fakeProps(); const toolSlot = fakeToolSlot(); - p.findToolSlot = () => toolSlot; + toolSlot.body.tool_id = 1; + const tool = fakeTool(); + tool.body.id = 1; + + p.findToolSlot = () => toolSlot; p.findTool = () => tool; - const wrapper = createWrapper(p); - expect(wrapper.root.findAllByType(SlotEditRows)[0]?.props.tool).toEqual(tool); + p.tools = [tool]; + + const { getByText } = render(<EditToolSlot {...p} />); + + expect(getByText("Tool or Seed Container")).toBeTruthy(); + expect(getByText(tool.body.name as string)).toBeTruthy(); }); }); diff --git a/frontend/tools/__tests__/index_test.tsx b/frontend/tools/__tests__/index_test.tsx index 5833c1a01b..5927561f26 100644 --- a/frontend/tools/__tests__/index_test.tsx +++ b/frontend/tools/__tests__/index_test.tsx @@ -2,7 +2,6 @@ const mockDevice = { readPin: jest.fn((_) => Promise.resolve()) }; import React from "react"; import { act, fireEvent, render } from "@testing-library/react"; -import type { ReactTestInstance } from "react-test-renderer"; import { RawTools as Tools, ToolSlotInventoryItem, @@ -17,7 +16,9 @@ import { Content, Actions } from "../../constants"; import * as crud from "../../api/crud"; import { ToolSelection } from "../tool_slot_edit_components"; import { fakeToolTransformProps } from "../../__test_support__/fake_tool_info"; -import { ToolsProps, ToolSlotInventoryItemProps } from "../interfaces"; +import { + ToolsProps, ToolSelectionProps, ToolSlotInventoryItemProps, +} from "../interfaces"; import * as mapActions from "../../farm_designer/map/actions"; import * as mapUtil from "../../farm_designer/map/util"; import { Mode } from "../../farm_designer/map/interfaces"; @@ -26,11 +27,8 @@ import { DEFAULT_CRITERIA } from "../../point_groups/criteria/interfaces"; import { Path } from "../../internal_urls"; import * as deviceModule from "../../device"; import { NavigationContext } from "../../routes_helpers"; -import { FBSelect } from "../../ui/new_fb_select"; -import { - createRenderer, - unmountRenderer, -} from "../../__test_support__/test_renderer"; +import * as toolSlotEditComponents from "../tool_slot_edit_components"; +import { findElement } from "../../__test_support__/react_element_search"; const originalPathname = location.pathname; @@ -41,41 +39,6 @@ const renderWithContext = (element: React.ReactElement) => </NavigationContext.Provider>, ); -const findNodeByType = ( - node: React.ReactNode, - matcher: (type: React.ElementType) => boolean, -): ReactTestInstance | undefined => { - if (!node || typeof node === "string" || typeof node === "number") { - return undefined; - } - if (Array.isArray(node)) { - for (const child of node as React.ReactNode[]) { - const found = findNodeByType(child, matcher); - if (found) { - return found; - } - } - return undefined; - } - if (React.isValidElement(node)) { - const element = node as React.ReactElement<{ children?: React.ReactNode }>; - if (matcher(element.type as React.ElementType)) { - return createRenderer(node as React.ReactElement).root; - } - let found: ReactTestInstance | undefined; - React.Children.forEach(element.props.children, (child: React.ReactNode) => { - if (found) { - return; - } - found = findNodeByType(child, matcher); - }); - if (found) { - return found; - } - } - return undefined; -}; - describe("<Tools />", () => { afterEach(() => { location.pathname = originalPathname; @@ -256,7 +219,7 @@ describe("<Tools />", () => { const ref = React.createRef<Tools>(); renderWithContext(<Tools {...p} ref={ref} />); const mountedTool = ref.current?.MountedToolInfo(); - const toolSelection = findNodeByType( + const toolSelection = findElement<ToolSelectionProps>( mountedTool, type => type === ToolSelection); act(() => { toolSelection?.props.onChange({ tool_id: 123 }); @@ -385,19 +348,17 @@ describe("<ToolSlotInventoryItem />", () => { it("changes tool", () => { const p = fakeProps(); - let wrapper: ReturnType<typeof createRenderer>; - act(() => { - wrapper = createRenderer(<ToolSlotInventoryItem {...p} />); - }); - act(() => { - wrapper.root.findByType(FBSelect).props.onChange({ value: "1" }); - }); + const toolSelectionSpy = jest.spyOn(toolSlotEditComponents, "ToolSelection") + .mockImplementation((props: ToolSelectionProps) => + <button onClick={() => props.onChange({ tool_id: 1 })} />); + const { getByRole } = render(<ToolSlotInventoryItem {...p} />); + fireEvent.click(getByRole("button")); + expect(p.dispatch).toHaveBeenCalledTimes(2); expect(crud.edit).toHaveBeenCalledWith(p.toolSlot, { tool_id: 1 }); expect(crud.save).toHaveBeenCalledWith(p.toolSlot.uuid); - act(() => { - unmountRenderer(wrapper); - }); + + toolSelectionSpy.mockRestore(); }); it("doesn't open tool slot", () => { diff --git a/frontend/tools/__tests__/tool_slot_edit_components_test.tsx b/frontend/tools/__tests__/tool_slot_edit_components_test.tsx index 556b5fcba7..598ca699fe 100644 --- a/frontend/tools/__tests__/tool_slot_edit_components_test.tsx +++ b/frontend/tools/__tests__/tool_slot_edit_components_test.tsx @@ -15,6 +15,7 @@ import { import { fakeTool, fakeToolSlot, } from "../../__test_support__/fake_state/resources"; +import * as ui from "../../ui"; import { BlurableInput, FBSelect, NULL_CHOICE } from "../../ui"; import { fakeToolTransformProps } from "../../__test_support__/fake_tool_info"; import { ToolPulloutDirection } from "farmbot/dist/resources/api_resources"; @@ -41,10 +42,13 @@ const createWrapper = (element: React.ReactElement) => { wrappers.push(wrapper); return wrapper; }; +let fbSelectSpy: jest.SpyInstance; beforeEach(() => { jest.clearAllMocks(); jest.spyOn(deviceActions, "move").mockImplementation(jest.fn()); + fbSelectSpy = jest.spyOn(ui, "FBSelect") + .mockImplementation(((_: ui.FBSelectProps) => <div />) as never); }); afterEach(() => { @@ -53,6 +57,7 @@ afterEach(() => { const wrapper = wrappers.pop(); wrapper && unmountRenderer(wrapper); } + fbSelectSpy.mockRestore(); }); describe("<GantryMountedInput />", () => { diff --git a/frontend/ui/__tests__/filter_search_test.tsx b/frontend/ui/__tests__/filter_search_test.tsx index e99a027104..d226bfdcb1 100644 --- a/frontend/ui/__tests__/filter_search_test.tsx +++ b/frontend/ui/__tests__/filter_search_test.tsx @@ -1,19 +1,10 @@ -import React from "react"; import { allMatchedItems, FilterSearch, FilterSearchProps, } from "../filter_search"; import { DropDownItem } from "../fb_select"; -import { ItemRendererProps } from "@blueprintjs/select"; -import { - actRenderer, - createRenderer, - getRendererInstance, - unmountRenderer, -} from "../../__test_support__/test_renderer"; +import type { ItemRendererProps } from "@blueprintjs/select"; describe("<FilterSearch />", () => { - const wrappers: ReturnType<typeof createRenderer>[] = []; - const fakeItem = (extra?: Partial<DropDownItem>): DropDownItem => Object.assign({ label: "label", value: "value" }, extra); @@ -24,67 +15,46 @@ describe("<FilterSearch />", () => { nullChoice: fakeItem(), }); - const createWrapper = (p = fakeProps()) => { - const wrapper = createRenderer(<FilterSearch {...p} />); - wrappers.push(wrapper); - return wrapper; + const createInstance = (p = fakeProps()) => { + const instance = new FilterSearch(p); + jest.spyOn(instance, "setState").mockImplementation(update => { + Object.assign(instance.state, update); + }); + return instance; }; - const getInstance = (wrapper: ReturnType<typeof createRenderer>) => - getRendererInstance<FilterSearch, React.ComponentProps<typeof FilterSearch>>( - wrapper, FilterSearch); - - afterEach(() => { - while (wrappers.length > 0) { - const wrapper = wrappers.pop(); - wrapper && unmountRenderer(wrapper); - } - }); - it("selects item", () => { const p = fakeProps(); - const wrapper = createWrapper(p); + const instance = createInstance(p); const item = fakeItem(); - actRenderer(() => { - getInstance(wrapper)["handleValueChange"](item); - }); + instance["handleValueChange"](item); expect(p.onChange).toHaveBeenCalledWith(item); }); it("doesn't select item", () => { const p = fakeProps(); - const wrapper = createWrapper(p); - actRenderer(() => { - getInstance(wrapper)["handleValueChange"](undefined); - }); + const instance = createInstance(p); + instance["handleValueChange"](undefined); expect(p.onChange).not.toHaveBeenCalled(); }); it("doesn't select header", () => { const p = fakeProps(); - const wrapper = createWrapper(p); + const instance = createInstance(p); const item = fakeItem({ heading: true }); - actRenderer(() => { - getInstance(wrapper)["handleValueChange"](item); - }); + instance["handleValueChange"](item); expect(p.onChange).not.toHaveBeenCalled(); }); it("handles empty selection", () => { - const p = fakeProps(); - const wrapper = createWrapper(p); - actRenderer(() => { - getInstance(wrapper).setState({ - item: undefined, - }); - }); - expect(JSON.stringify(wrapper.toJSON()).toLowerCase()) + const instance = createInstance(); + instance.state.item = undefined; + expect(JSON.stringify(instance.render()).toLowerCase()) .toContain("no selection"); }); it("handles empty item", () => { - const wrapper = createWrapper(); - const renderItem = getInstance(wrapper)["default"]; + const renderItem = createInstance()["default"]; const item = { label: "label", value: "" }; const renderProps = { handleClick: jest.fn(), @@ -107,22 +77,22 @@ describe("<FilterSearch />", () => { ]; const itemListFilter = jest.fn((items, query) => query.toLowerCase().includes("stress") ? items : items.slice(0, 1)); - const wrapper = createWrapper({ ...p, itemListFilter }); + const instance = createInstance({ ...p, itemListFilter }); + instance.render(); expect(itemListFilter).toHaveBeenCalledWith(p.items, ""); - actRenderer(() => { - getInstance(wrapper)["handleQueryChange"]("stress"); - }); + instance.state.query = "stress"; + instance.render(); expect(itemListFilter).toHaveBeenLastCalledWith(p.items, "stress"); }); it("shows section headings only when a child item matches", () => { - const wrapper = createWrapper(); + const instance = createInstance(); const items = [ fakeItem({ label: "--- Plants", heading: true, headingId: "Plant" }), fakeItem({ label: "Mint", headingId: "Plant" }), ]; - const filter = getInstance(wrapper)["filter"](items); + const filter = instance["filter"](items); expect(filter("plants", items[0])).toBeTruthy(); expect(filter("tools", items[0])).toBeFalsy(); diff --git a/frontend/wizard/__tests__/checks_test.tsx b/frontend/wizard/__tests__/checks_test.tsx index f1a851b6a8..2fcfe5c5f0 100644 --- a/frontend/wizard/__tests__/checks_test.tsx +++ b/frontend/wizard/__tests__/checks_test.tsx @@ -13,7 +13,6 @@ const mockDevice = { import React from "react"; import { fireEvent, render } from "@testing-library/react"; -import type { ReactTestInstance } from "react-test-renderer"; import { bot } from "../../__test_support__/fake_state/bot"; import { buildResourceIndex, fakeDevice, @@ -90,7 +89,6 @@ import * as messageCards from "../../messages/cards"; import * as bootSequenceSelector from "../../settings/fbos_settings/boot_sequence_selector"; import * as messageActions from "../../messages/actions"; import * as deviceActions from "../../devices/actions"; -import { DropdownConfig } from "../../photos/camera_calibration/config"; import { PLACEHOLDER_FARMBOT } from "../../photos/images/image_flipper"; import { createRenderer } from "../../__test_support__/test_renderer"; @@ -114,22 +112,6 @@ let emergencyUnlockSpy: jest.SpyInstance; let findHomeSpy: jest.SpyInstance; let findAxisLengthSpy: jest.SpyInstance; -const findNodeByType = ( - node: React.ReactNode, - matcher: (type: unknown) => boolean, -): ReactTestInstance | undefined => { - if (!node || !React.isValidElement(node)) { - return undefined; - } - try { - return createRenderer(node).root - .findAll(element => matcher(element.type)) - .filter(item => matcher(item.type))[0]; - } catch { - return undefined; - } -}; - beforeEach(() => { jest.clearAllMocks(); jest.useRealTimers(); @@ -951,10 +933,14 @@ describe("<CameraImageOrigin />", () => { it("changes origin", () => { const p = fakeProps(); - const origin = findNodeByType(<CameraImageOrigin {...p} />, - type => type === DropdownConfig); - origin?.props.onChange( - "CAMERA_CALIBRATION_image_bot_origin_location", 2); + const fbSelectProps: ui.FBSelectProps[] = []; + fbSelectSpy = jest.spyOn(ui, "FBSelect") + .mockImplementation(((props: ui.FBSelectProps) => { + fbSelectProps.push(props); + return <div />; + }) as never); + render(<CameraImageOrigin {...p} />); + fbSelectProps[0].onChange({ label: "Top left", value: 2 }); expect(initSave).toHaveBeenCalledWith("FarmwareEnv", { key: "CAMERA_CALIBRATION_image_bot_origin_location", value: "\"TOP_LEFT\"", diff --git a/package.json b/package.json index df34a97038..7d68d6835c 100644 --- a/package.json +++ b/package.json @@ -38,10 +38,10 @@ "mqtt": "mqtt/dist/mqtt.esm.js" }, "dependencies": { - "@blueprintjs/core": "6.13.0", - "@blueprintjs/select": "6.1.11", + "@blueprintjs/core": "6.15.0", + "@blueprintjs/select": "6.2.1", "@monaco-editor/react": "4.7.0", - "@react-spring/three": "10.0.3", + "@react-spring/three": "10.1.0", "@react-three/drei": "10.7.7", "@react-three/fiber": "9.6.1", "@rollbar/react": "1.0.0", @@ -50,7 +50,7 @@ "@types/markdown-it": "14.1.2", "@types/markdown-it-emoji": "3.0.1", "@types/promise-timeout": "1.3.3", - "@types/react": "19.2.14", + "@types/react": "19.2.15", "@types/react-color": "3.0.13", "@types/react-dom": "19.2.3", "@types/react-test-renderer": "19.1.0", @@ -66,9 +66,9 @@ "farmbot": "15.9.3", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.1.0", + "i18next": "26.2.0", "lodash": "4.18.1", - "markdown-it": "14.1.1", + "markdown-it": "14.2.0", "markdown-it-emoji": "3.0.0", "moment": "2.30.1", "monaco-editor": "0.55.1", @@ -80,8 +80,8 @@ "react": "19.2.6", "react-color": "2.19.3", "react-dom": "19.2.6", - "react-redux": "9.2.0", - "react-router": "7.15.0", + "react-redux": "9.3.0", + "react-router": "7.15.1", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", @@ -104,9 +104,9 @@ "@types/jest": "30.0.0", "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", - "@typescript-eslint/eslint-plugin": "8.59.3", - "@typescript-eslint/parser": "8.59.3", - "eslint": "10.3.0", + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "eslint": "10.4.0", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", "eslint-plugin-jest": "29.15.2", @@ -125,15 +125,15 @@ "madge": "8.0.0", "path-browserify": "1.0.1", "playwright": "1.60.0", - "postcss": "8.5.14", + "postcss": "8.5.15", "postcss-scss": "4.0.9", "raf": "3.4.1", "react-test-renderer": "19.2.6", - "sass": "1.99.0", + "sass": "1.100.0", "sass-lint": "1.13.1", - "stylelint": "17.11.0", + "stylelint": "17.12.0", "stylelint-config-standard-scss": "17.0.0", - "ts-jest": "29.4.9", + "ts-jest": "29.4.11", "tslint": "6.1.3" } } From fcfaefc8f96fd8fc4e4fc81b5146888a7b264d95 Mon Sep 17 00:00:00 2001 From: gabrielburnworth <gabrielburnworth@users.noreply.github.com> Date: Tue, 26 May 2026 09:30:17 -0700 Subject: [PATCH 68/83] fix map legend position --- frontend/css/farm_designer/farm_designer.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/css/farm_designer/farm_designer.scss b/frontend/css/farm_designer/farm_designer.scss index 537e7a44cb..1582593e8f 100644 --- a/frontend/css/farm_designer/farm_designer.scss +++ b/frontend/css/farm_designer/farm_designer.scss @@ -537,7 +537,7 @@ } .garden-map-legend { - position: absolute; + position: fixed; top: 7.5rem; right: -155px; z-index: 3; From 80ca9b566631c42d3c33e6bf2469946231e3ad61 Mon Sep 17 00:00:00 2001 From: gabrielburnworth <gabrielburnworth@users.noreply.github.com> Date: Tue, 26 May 2026 10:24:22 -0700 Subject: [PATCH 69/83] add lua runner read_status path support and fix table order --- .../demo/lua_runner/__tests__/index_test.ts | 24 +++++++++++++++++-- frontend/demo/lua_runner/run.ts | 13 +++++++--- frontend/demo/lua_runner/util.ts | 11 ++++++++- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/frontend/demo/lua_runner/__tests__/index_test.ts b/frontend/demo/lua_runner/__tests__/index_test.ts index 962cff7bda..cf48771855 100644 --- a/frontend/demo/lua_runner/__tests__/index_test.ts +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -986,7 +986,7 @@ describe("runDemoLuaCode()", () => { expect(error).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledTimes(1); - expect(console.log).toHaveBeenCalledWith("[3,2,1]"); + expect(console.log).toHaveBeenCalledWith("[1,2,3]"); }); it("runs json.encode", () => { @@ -997,7 +997,7 @@ describe("runDemoLuaCode()", () => { expect(error).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledTimes(1); - expect(console.log).toHaveBeenCalledWith("[3,2,1]"); + expect(console.log).toHaveBeenCalledWith("[1,2,3]"); }); it("runs json.decode", () => { @@ -1268,6 +1268,26 @@ describe("runDemoLuaCode()", () => { expect(console.log).toHaveBeenCalledWith("false"); }); + it("runs read_status with a path", () => { + runDemoLuaCode(` + print(read_status("informational_settings", "locked")) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("false"); + }); + + it("runs read_status with a table path", () => { + runDemoLuaCode(` + print(read_status({"informational_settings", "locked"})) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("false"); + }); + it("runs find_home: all", () => { setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("find_home(\"all\")"); diff --git a/frontend/demo/lua_runner/run.ts b/frontend/demo/lua_runner/run.ts index 8d6e7d39fb..70bbe58116 100644 --- a/frontend/demo/lua_runner/run.ts +++ b/frontend/demo/lua_runner/run.ts @@ -14,7 +14,8 @@ import { store } from "../../redux/store"; import { sortGroupBy } from "../../point_groups/point_group_sort"; import { LUA_HELPERS } from "./lua"; import { - clean, createRecursiveNotImplemented, csToLua, filterPoint, jsToLua, luaToJs, + clean, createRecursiveNotImplemented, csToLua, filterPoint, flattenLuaPath, + jsToLua, luaToJs, } from "./util"; import { Action, XyzNumber } from "./interfaces"; import { @@ -27,7 +28,7 @@ import { } from "./stubs"; import { error } from "../../toast/toast"; import { collectDemoSequenceActions } from "./index"; -import { last } from "lodash"; +import { get, last } from "lodash"; import { XYZ } from "../../devices/constants"; export const runLua = @@ -569,7 +570,13 @@ export const runLua = lua.lua_pushjsfunction(L, () => { const status = getDeviceStatus(); - jsToLua(L, status); + const n = lua.lua_gettop(L); + const path: unknown[] = []; + for (let i = 1; i <= n; i++) { + path.push(luaToJs(L, i)); + } + const flatPath = flattenLuaPath(path); + jsToLua(L, flatPath.length > 0 ? get(status, flatPath) : status); return 1; }); lua.lua_setfield(L, envIndex, to_luastring("read_status")); diff --git a/frontend/demo/lua_runner/util.ts b/frontend/demo/lua_runner/util.ts index 1fe720aeb5..ffb3e0c8f2 100644 --- a/frontend/demo/lua_runner/util.ts +++ b/frontend/demo/lua_runner/util.ts @@ -57,6 +57,12 @@ export const luaToJs = (L: unknown, idx: number): unknown => { } }; +export const flattenLuaPath = (path: unknown[]): string[] => + path.flatMap(item => + Array.isArray(item) + ? flattenLuaPath(item) + : ["" + item]); + const luaTableToJs = (L: unknown, idx: number): unknown => { const absIndex = lua.lua_absindex(L, idx); const keyVals: [string | number, unknown][] = []; @@ -71,7 +77,10 @@ const luaTableToJs = (L: unknown, idx: number): unknown => { const isArrayLike = keyVals.every(([k]) => typeof k === "number"); if (isArrayLike) { - return keyVals.map(([, v]) => v).filter(v => v !== undefined); + return keyVals + .sort(([a], [b]) => (a as number) - (b as number)) + .map(([, v]) => v) + .filter(v => v !== undefined); } else { const result: Record<string, unknown> = {}; for (const [key, value] of keyVals) { From 6ad29caa9e0d8bef986b202d92f3e575e513206f Mon Sep 17 00:00:00 2001 From: gabrielburnworth <gabrielburnworth@users.noreply.github.com> Date: Tue, 26 May 2026 10:44:56 -0700 Subject: [PATCH 70/83] fix ci cache issue --- .github/actions/setup-ci/action.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/actions/setup-ci/action.yml b/.github/actions/setup-ci/action.yml index 6c481929ec..59166c5c23 100644 --- a/.github/actions/setup-ci/action.yml +++ b/.github/actions/setup-ci/action.yml @@ -46,16 +46,12 @@ runs: with: path: docker_volumes/bundle_cache key: bundle-${{ runner.os }}-${{ hashFiles('Gemfile.lock') }} - restore-keys: | - bundle-${{ runner.os }}- - name: Cache node modules uses: actions/cache@v5 with: path: node_modules key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} - restore-keys: | - bun-${{ runner.os }}- - name: Install deps shell: bash From 9fc014c39d41555d8dcfa5ea68243a18dc29e894 Mon Sep 17 00:00:00 2001 From: gabrielburnworth <gabrielburnworth@users.noreply.github.com> Date: Tue, 26 May 2026 10:45:13 -0700 Subject: [PATCH 71/83] add skip render option [skip render] --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5fca287e4..53b2b086a4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,6 +18,7 @@ jobs: render: needs: test + if: github.ref_name == 'staging' || github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip render]') uses: ./.github/workflows/render.yml permissions: contents: write From e39799db6a58f9298cea2e66eabdfc711ca18479 Mon Sep 17 00:00:00 2001 From: Rory Aronson <rory@roryaronson.com> Date: Tue, 26 May 2026 17:34:30 -0700 Subject: [PATCH 72/83] Refine overlay Escape handling --- frontend/promo/__tests__/promo_test.tsx | 53 +++++++++++++------ frontend/promo/promo.tsx | 28 +++++----- .../__tests__/config_overlays_test.tsx | 33 +++++------- frontend/three_d_garden/config_overlays.tsx | 15 ++---- 4 files changed, 70 insertions(+), 59 deletions(-) diff --git a/frontend/promo/__tests__/promo_test.tsx b/frontend/promo/__tests__/promo_test.tsx index 4da989e1ed..252b41f913 100644 --- a/frontend/promo/__tests__/promo_test.tsx +++ b/frontend/promo/__tests__/promo_test.tsx @@ -9,6 +9,11 @@ import * as zoomBeaconConstants from "../../three_d_garden/zoom_beacons_constants"; import { INITIAL, PRESETS } from "../../three_d_garden/config"; import { calculatePlantPositions } from "../plants"; +import * as screenSize from "../../screen_size"; + +type CanvasComponentProps = React.ComponentProps<typeof reactThreeFiber.Canvas>; +type CanvasCreatedState = + Parameters<NonNullable<CanvasComponentProps["onCreated"]>>[0]; describe("<Promo />", () => { const originalSearch = window.location.search; @@ -17,11 +22,20 @@ describe("<Promo />", () => { let gardenModelSpy: jest.SpyInstance; let pushStateSpy: jest.SpyInstance; let focusFromUrlParamsSpy: jest.SpyInstance; + let isMobileSpy: jest.SpyInstance; beforeEach(() => { canvasSpy = jest.spyOn(reactThreeFiber, "Canvas") - .mockImplementation(({ children }: { children?: React.ReactNode }) => - <div>{children}</div>); + .mockImplementation(({ + children, + onCreated, + }: CanvasComponentProps) => { + const state = { + gl: { localClippingEnabled: false }, + } as CanvasCreatedState; + onCreated?.(state); + return <div>{children}</div>; + }); gardenModelSpy = jest.spyOn(gardenModelModule, "GardenModel") .mockImplementation(({ config }: { config: { promoSpread?: boolean } }) => <div>{config.promoSpread ? "spread" : "garden-model"}</div>); @@ -30,6 +44,7 @@ describe("<Promo />", () => { focusFromUrlParamsSpy = jest .spyOn(zoomBeaconConstants, "getFocusFromUrlParams") .mockReturnValue(""); + isMobileSpy = jest.spyOn(screenSize, "isMobile").mockReturnValue(false); }); afterEach(() => { @@ -40,6 +55,7 @@ describe("<Promo />", () => { gardenModelSpy.mockRestore(); pushStateSpy.mockRestore(); focusFromUrlParamsSpy.mockRestore(); + isMobileSpy.mockRestore(); }); it("renders", () => { @@ -91,29 +107,32 @@ describe("<Promo />", () => { }); it("renders spread", () => { - window.location.search = "?promoSpread=true"; + window.location.search = "?promoSpread=true&bedLengthOuter=1234"; const { container, unmount } = render(<Promo />); expect(container).toContainHTML("spread"); unmount(); }); + it("adjusts the initial mobile heading", () => { + isMobileSpy.mockReturnValue(true); + const { unmount } = render(<Promo />); + expect(gardenModelSpy.mock.calls[0][0].config) + .toEqual(expect.objectContaining({ viewpointHeading: 80 })); + unmount(); + }); + it("clears active focus on Escape", async () => { focusFromUrlParamsSpy.mockReturnValue("What you can grow"); - const addEventListenerSpy = jest.spyOn(window, "addEventListener"); const { unmount } = render(<Promo />); - try { - await waitFor(() => expect(addEventListenerSpy) - .toHaveBeenCalledWith("keydown", expect.any(Function))); - const onKeyDown = addEventListenerSpy.mock.calls - .find(([eventName]) => eventName == "keydown")?.[1] as EventListener; - act(() => onKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))); - expect(pushStateSpy).toHaveBeenCalled(); - const nextUrl = pushStateSpy.mock.calls[0][2] as string; - expect(nextUrl).not.toContain("focus="); - } finally { - unmount(); - addEventListenerSpy.mockRestore(); - } + await waitFor(() => expect(gardenModelSpy.mock.calls[0][0]) + .toEqual(expect.objectContaining({ activeFocus: "What you can grow" }))); + fireEvent.keyDown(window, { key: "Enter" }); + fireEvent.keyDown(screen.getByText("garden-model") + .closest(".three-d-garden") as HTMLElement, { key: "Escape" }); + await waitFor(() => expect(pushStateSpy).toHaveBeenCalled()); + const nextUrl = pushStateSpy.mock.calls[0][2] as string; + expect(nextUrl).not.toContain("focus="); + unmount(); }); }); diff --git a/frontend/promo/promo.tsx b/frontend/promo/promo.tsx index e8fae71a0d..bac261173f 100644 --- a/frontend/promo/promo.tsx +++ b/frontend/promo/promo.tsx @@ -42,17 +42,13 @@ interface PromoPlantCapacities { const calcCacheKey = (config: Config): string => `${config.bedLengthOuter}x${config.bedWidthOuter}: ${config.plants}`; -const calcPlantsCache = ( +const addPlantsToCache = ( cache: ThreeDPlantsCache, config: Config, ): ThreeDPlantsCache => { - const cacheKey = calcCacheKey(config); - if (cache[cacheKey]) { - return cache; - } return { ...cache, - [cacheKey]: calculatePlantPositions(config), + [calcCacheKey(config)]: calculatePlantPositions(config), }; }; @@ -60,7 +56,7 @@ const prewarmPlantsCache = () => { let next = PLANTS_CACHE; PROMO_BED_SIZES.map(({ length, width }) => { SEASONS.map(season => { - next = calcPlantsCache(next, { + next = addPlantsToCache(next, { ...INITIAL, bedLengthOuter: length, bedWidthOuter: width, @@ -76,8 +72,9 @@ const getCachedPlants = (config: Config) => { const cachedPlants = PLANTS_CACHE[cacheKey]; if (cachedPlants) { return cachedPlants; } - Object.assign(PLANTS_CACHE, calcPlantsCache(PLANTS_CACHE, config)); - return PLANTS_CACHE[cacheKey] || []; + const plants = calculatePlantPositions(config); + Object.assign(PLANTS_CACHE, { [cacheKey]: plants }); + return plants; }; export const getPromoPlantCapacities = (config: Config): PromoPlantCapacities => { @@ -166,16 +163,20 @@ export const Promo = () => { startTimeRef.current = performance.now() / 1000; }, []); + const clearActiveFocus = React.useCallback(() => { + setActiveFocus(""); + setUrlParam("focus", ""); + }, []); + React.useEffect(() => { if (!activeFocus) { return; } const onKeyDown = (e: KeyboardEvent) => { if (e.key != "Escape") { return; } - setActiveFocus(""); - setUrlParam("focus", ""); + clearActiveFocus(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [activeFocus]); + }, [activeFocus, clearActiveFocus]); const plants = React.useMemo(() => { return getCachedPlants(config); @@ -195,7 +196,8 @@ export const Promo = () => { const plantCapacities = React.useMemo(() => getPromoPlantCapacities(plantCapacityConfig), [plantCapacityConfig]); - return <div className={"three-d-garden promo"}> + return <div className={"three-d-garden promo"} + onKeyDown={e => activeFocus && e.key == "Escape" && clearActiveFocus()}> <div className={"garden-bed-3d-model"}> <FocusTransitionProvider enabled={config.animate}> <MemoryRouter> diff --git a/frontend/three_d_garden/__tests__/config_overlays_test.tsx b/frontend/three_d_garden/__tests__/config_overlays_test.tsx index f64d1d92bb..c46ed4cf8d 100644 --- a/frontend/three_d_garden/__tests__/config_overlays_test.tsx +++ b/frontend/three_d_garden/__tests__/config_overlays_test.tsx @@ -1,7 +1,5 @@ import React from "react"; -import { - act, render, screen, fireEvent, waitFor, -} from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { PublicOverlay, OverlayProps, PrivateOverlay, maybeAddParam, } from "../config_overlays"; @@ -19,6 +17,7 @@ beforeEach(() => { afterEach(() => { setUrlParamSpy.mockRestore(); + jest.useRealTimers(); }); describe("<PublicOverlay />", () => { @@ -196,22 +195,18 @@ describe("<PrivateOverlay />", () => { it("closes the config menu with Escape", async () => { const p = fakeProps(); - const addEventListenerSpy = jest.spyOn(window, "addEventListener"); - const { unmount } = render(<PrivateOverlay {...p} />); - try { - await waitFor(() => expect(addEventListenerSpy) - .toHaveBeenCalledWith("keydown", expect.any(Function))); - const onKeyDown = addEventListenerSpy.mock.calls - .find(([eventName]) => eventName == "keydown")?.[1] as EventListener; - act(() => onKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))); - expect(p.setConfig).toHaveBeenCalledWith({ - ...p.config, - config: false, - }); - } finally { - unmount(); - addEventListenerSpy.mockRestore(); - } + render(<PrivateOverlay {...p} />); + await waitFor(() => + expect(document.activeElement).toEqual(screen.getByPlaceholderText( + "Search configs", + ))); + fireEvent.keyDown(screen.getByPlaceholderText("Search configs"), { + key: "Escape", + }); + expect(p.setConfig).toHaveBeenCalledWith({ + ...p.config, + config: false, + }); }); it("removes url param", () => { diff --git a/frontend/three_d_garden/config_overlays.tsx b/frontend/three_d_garden/config_overlays.tsx index 046f158c1f..6d373ed908 100644 --- a/frontend/three_d_garden/config_overlays.tsx +++ b/frontend/three_d_garden/config_overlays.tsx @@ -325,22 +325,17 @@ export const PrivateOverlay = (props: OverlayProps) => { const [search, setSearch] = React.useState(""); // eslint-disable-next-line no-null/no-null const searchInputRef = React.useRef<HTMLInputElement>(null); + const closeConfig = React.useCallback(() => + setConfig(modifyConfig(config, { config: false })), [config, setConfig]); React.useEffect(() => { searchInputRef.current?.focus(); }, []); - React.useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - if (e.key != "Escape") { return; } - setConfig(modifyConfig(config, { config: false })); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [config, setConfig]); - return <div className={"all-configs"}> + return <div className={"all-configs"} + onKeyDown={e => e.key == "Escape" && closeConfig()}> <div className={"config-title"}> {"Configs"} <p className={"close"} - onClick={() => setConfig(modifyConfig(config, { config: false }))}> + onClick={closeConfig}> X </p> </div> From 0bd785c6b79d359ffb9e7466748c1146124c75cc Mon Sep 17 00:00:00 2001 From: Rory Aronson <rory@roryaronson.com> Date: Tue, 26 May 2026 18:25:37 -0700 Subject: [PATCH 73/83] Speed up progressive loading --- frontend/promo/__tests__/promo_test.tsx | 2 +- frontend/promo/promo.tsx | 2 +- .../__tests__/garden_model_test.tsx | 26 +++++++ .../__tests__/progressive_load_test.tsx | 46 ++++++++++++ frontend/three_d_garden/garden_model.tsx | 71 +++++++++++++++---- frontend/three_d_garden/progressive_load.tsx | 41 +++++++---- frontend/three_d_garden/scenes/greenhouse.tsx | 2 + frontend/three_d_garden/scenes/lab.tsx | 2 + 8 files changed, 162 insertions(+), 30 deletions(-) diff --git a/frontend/promo/__tests__/promo_test.tsx b/frontend/promo/__tests__/promo_test.tsx index 252b41f913..2aa19e4012 100644 --- a/frontend/promo/__tests__/promo_test.tsx +++ b/frontend/promo/__tests__/promo_test.tsx @@ -76,7 +76,7 @@ describe("<Promo />", () => { const { container, unmount } = render(<Promo />); expect(container.querySelector(".settings-bar-loaded")).toBeFalsy(); act(() => { - gardenModelSpy.mock.calls[0][0].onLoadComplete(); + gardenModelSpy.mock.calls[0][0].onDetailsRevealStart(); }); expect(container.querySelector(".settings-bar-loaded")).toBeTruthy(); unmount(); diff --git a/frontend/promo/promo.tsx b/frontend/promo/promo.tsx index bac261173f..4b84a57740 100644 --- a/frontend/promo/promo.tsx +++ b/frontend/promo/promo.tsx @@ -213,7 +213,7 @@ export const Promo = () => { mapPoints={mapPoints} plantIconCapacities={plantCapacities.iconCapacities} plantInstanceCapacity={plantCapacities.plantInstanceCapacity} - onLoadComplete={handleThreeDLoadComplete} + onDetailsRevealStart={handleThreeDLoadComplete} smoothFocusTransitions={true} /> </Canvas> </MemoryRouter> diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 0142abd6fa..bc98faff5d 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -93,6 +93,23 @@ describe("<GardenModel />", () => { expect(container.innerHTML).toContain("points-load-in"); expect(container.innerHTML).toContain("weeds-load-in"); expect(container.innerHTML).toContain("zoom-beacons-load-in"); + expect(container.innerHTML).toContain("farmbot-scene-boundary"); + expect(container.innerHTML).toContain("details-scene-boundary"); + }); + + it("notifies when the progressive reveal completes", async () => { + const p = fakeProps(); + p.onLoadComplete = jest.fn(); + render(<GardenModel {...p} />); + await waitFor(() => expect(p.onLoadComplete).toHaveBeenCalled()); + }); + + it("notifies when scene details begin revealing", async () => { + const p = fakeProps(); + p.onDetailsRevealStart = jest.fn(); + render(<GardenModel {...p} />); + await waitFor(() => + expect(p.onDetailsRevealStart).toHaveBeenCalled()); }); it("renders top down view", () => { @@ -310,6 +327,15 @@ describe("<GardenModel />", () => { expect(container.innerHTML).not.toContain('name="bot"'); }); + it("completes the progressive reveal without FarmBot", async () => { + const p = fakeProps(); + p.addPlantProps = fakeAddPlantProps(); + p.addPlantProps.getConfigValue = () => false; + p.onLoadComplete = jest.fn(); + render(<GardenModel {...p} />); + await waitFor(() => expect(p.onLoadComplete).toHaveBeenCalled()); + }); + it("renders other options", async () => { mockIsDesktop = false; const p = fakeProps(); diff --git a/frontend/three_d_garden/__tests__/progressive_load_test.tsx b/frontend/three_d_garden/__tests__/progressive_load_test.tsx index ad2c6747f8..38d9a0eb16 100644 --- a/frontend/three_d_garden/__tests__/progressive_load_test.tsx +++ b/frontend/three_d_garden/__tests__/progressive_load_test.tsx @@ -15,6 +15,28 @@ describe("<PopInGroup />", () => { expect(container.innerHTML).toContain("bed-load-in"); expect(screen.getByText("content")).toBeTruthy(); }); + + it("keeps children mounted without resting before reveal", () => { + const onRest = jest.fn(); + const { rerender } = render(<PopInGroup + name={"bed-load-in"} + reveal={false} + onRest={onRest}> + <span>content</span> + </PopInGroup>); + + expect(screen.getByText("content")).toBeTruthy(); + expect(onRest).not.toHaveBeenCalled(); + + rerender(<PopInGroup + name={"bed-load-in"} + reveal={true} + onRest={onRest}> + <span>content</span> + </PopInGroup>); + + expect(onRest).toHaveBeenCalled(); + }); }); describe("<FallInGroup />", () => { @@ -158,4 +180,28 @@ describe("3D load progress", () => { render(<LoadStepReady step={"plants"} markStep={markStep} />); expect(markStep).toHaveBeenCalledWith("plants"); }); + + it("can hide before all progress steps are ready", () => { + jest.useFakeTimers(); + const CompleteHarness = ({ complete }: { complete: boolean }) => { + const progress = useThreeDLoadProgress(); + return <ThreeDLoadProgressOverlay + progress={progress} + complete={complete} />; + }; + + const { rerender } = render(<CompleteHarness complete={false} />); + expect(document.querySelector(".three-d-load-progress")).toBeTruthy(); + + rerender(<CompleteHarness complete={true} />); + + expect(screen.getByText("Enjoy!")).toBeTruthy(); + expect(document.querySelector(".three-d-load-progress-complete")) + .toBeTruthy(); + act(() => { + jest.advanceTimersByTime(THREE_D_LOAD_PROGRESS_FADE_MS); + }); + expect(document.querySelector(".three-d-load-progress")).toBeFalsy(); + jest.useRealTimers(); + }); }); diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 6063f33085..bf21e7f393 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -70,15 +70,21 @@ export const SMOOTH_XL_CAMERA_BED_SCALE = 1.9; export const SMOOTH_XL_CAMERA_HEIGHT_SCALE = 1.45; interface ZoomBeaconsLoadInProps extends ZoomBeaconsProps { + reveal?: boolean; onRest?: () => void; } const ZoomBeaconsLoadIn = (props: ZoomBeaconsLoadInProps) => { - const { onRest, ...zoomBeaconProps } = props; + const { onRest, reveal: revealProp, ...zoomBeaconProps } = props; + const reveal = revealProp !== false; const { scale, opacity } = useSpring({ from: { scale: 0.35, opacity: 0 }, - to: { scale: 1, opacity: 1 }, - onRest, + to: { + scale: reveal ? 1 : 0.35, + opacity: reveal ? 1 : 0, + }, + immediate: !reveal, + onRest: () => reveal && onRest?.(), config: { tension: 220, friction: 26, @@ -97,24 +103,24 @@ interface SceneBoundaryProps { markName?: string; loadProgress?: ThreeDLoadProgress; loadStep?: ThreeDLoadStepId; + reveal?: boolean; markReadyOnMount?: boolean; children: React.ReactNode; } const SceneBoundary = (props: SceneBoundaryProps) => { - if (props.loadStep - && props.loadProgress - && !props.loadProgress.isStepAllowed(props.loadStep)) { - return undefined; - } + const reveal = props.reveal !== false; const markReadyOnMount = props.markReadyOnMount !== false; return <React.Suspense fallback={undefined}> - {props.children} - {markReadyOnMount && props.loadStep && props.loadProgress && + <Group name={props.loadStep && `${props.loadStep}-scene-boundary`} + visible={reveal}> + {props.children} + </Group> + {reveal && markReadyOnMount && props.loadStep && props.loadProgress && <LoadStepReady step={props.loadStep} markStep={props.loadProgress.markStep} />} - {props.markName && <PerfMark name={props.markName} />} + {reveal && props.markName && <PerfMark name={props.markName} />} </React.Suspense>; }; @@ -138,13 +144,16 @@ export interface GardenModelProps { smoothFocusTransitions?: boolean; plantIconCapacities?: Record<string, number>; plantInstanceCapacity?: number; + onDetailsRevealStart?(): void; onLoadComplete?(): void; } // eslint-disable-next-line complexity export const GardenModel = (props: GardenModelProps) => { usePerfRenderCount("GardenModel"); - const { config, addPlantProps, onLoadComplete, threeDPlants } = props; + const { + config, addPlantProps, onDetailsRevealStart, onLoadComplete, threeDPlants, + } = props; const dispatch = addPlantProps?.dispatch; const Camera = config.perspective ? PerspectiveCamera : OrthographicCamera; @@ -239,6 +248,15 @@ export const GardenModel = (props: GardenModelProps) => { // eslint-disable-next-line no-null/no-null React.useState<SmoothCameraControls | null>(null); const loadProgress = useThreeDLoadProgress(); + const environmentReveal = loadProgress.isStepAllowed("environment"); + const bedReveal = loadProgress.isStepAllowed("bed"); + const gridReveal = loadProgress.isStepAllowed("grid"); + const plantsReveal = loadProgress.isStepAllowed("plants"); + const weedsReveal = loadProgress.isStepAllowed("weeds"); + const pointsReveal = loadProgress.isStepAllowed("points"); + const farmbotReveal = loadProgress.isStepAllowed("farmbot"); + const detailsReveal = loadProgress.isStepAllowed("details"); + const detailsRevealNotified = React.useRef(false); const loadCompleteNotified = React.useRef(false); const markLoadStep = loadProgress.markStep; const markDetailsLoaded = React.useCallback(() => { @@ -249,6 +267,12 @@ export const GardenModel = (props: GardenModelProps) => { perfMark("garden_model_mounted"); }, []); + React.useEffect(() => { + if (!detailsReveal || detailsRevealNotified.current) { return; } + detailsRevealNotified.current = true; + onDetailsRevealStart?.(); + }, [detailsReveal, onDetailsRevealStart]); + React.useEffect(() => { if (!loadProgress.complete || loadCompleteNotified.current) { return; } loadCompleteNotified.current = true; @@ -377,10 +401,13 @@ export const GardenModel = (props: GardenModelProps) => { maxZoom={10} minDistance={config.lightsDebug ? 50 : 500} maxDistance={config.lightsDebug ? BigDistance.devZoom : BigDistance.zoom} />} - <ThreeDLoadProgressOverlay progress={loadProgress} /> + <ThreeDLoadProgressOverlay + progress={loadProgress} + complete={detailsReveal} /> <SceneBoundary loadStep={"environment"} loadProgress={loadProgress} + reveal={environmentReveal} markName={"three_d_ground_ready"}> <Sky sunPosition={sunPosition(0, 0, 0)} /> <Sphere args={[BigDistance.sky, 8, 16]}> @@ -399,11 +426,13 @@ export const GardenModel = (props: GardenModelProps) => { <SceneBoundary loadStep={"bed"} loadProgress={loadProgress} + reveal={bedReveal} markReadyOnMount={false} markName={"three_d_bed_ready"}> <NorthArrow config={config} /> <PopInGroup name={"bed-load-in"} + reveal={bedReveal} onRest={() => loadProgress.markStep("bed")} distance={config.bedHeight + config.bedZOffset}> <Bed @@ -424,10 +453,12 @@ export const GardenModel = (props: GardenModelProps) => { <SceneBoundary loadStep={"grid"} loadProgress={loadProgress} + reveal={gridReveal} markReadyOnMount={false} markName={"three_d_grid_ready"}> <GridRevealGroup name={"grid-load-in"} + reveal={gridReveal} onRest={() => loadProgress.markStep("grid")}> <Grid config={config} @@ -438,10 +469,12 @@ export const GardenModel = (props: GardenModelProps) => { <SceneBoundary loadStep={"plants"} loadProgress={loadProgress} + reveal={plantsReveal} markReadyOnMount={false} markName={"three_d_core_ready"}> <PopInGroup name={"plants-load-in"} + reveal={plantsReveal} onRest={() => loadProgress.markStep("plants")} distance={200}> <FocusVisibilityGroup @@ -478,10 +511,12 @@ export const GardenModel = (props: GardenModelProps) => { <SceneBoundary loadStep={"weeds"} loadProgress={loadProgress} + reveal={weedsReveal} markReadyOnMount={false} markName={"three_d_weeds_ready"}> <PopInGroup name={"weeds-load-in"} + reveal={weedsReveal} onRest={() => loadProgress.markStep("weeds")} distance={200}> <Group name={"weeds"} @@ -499,10 +534,12 @@ export const GardenModel = (props: GardenModelProps) => { <SceneBoundary loadStep={"points"} loadProgress={loadProgress} + reveal={pointsReveal} markReadyOnMount={false} markName={"three_d_points_ready"}> <FallInGroup name={"points-load-in"} + reveal={pointsReveal} onRest={() => loadProgress.markStep("points")} distance={config.columnLength + 1000}> <Group name={"points"} @@ -520,11 +557,13 @@ export const GardenModel = (props: GardenModelProps) => { <SceneBoundary loadStep={"farmbot"} loadProgress={loadProgress} + reveal={farmbotReveal} markReadyOnMount={!showFarmbot} markName={"three_d_bot_ready"}> {showFarmbot && <FallInGroup name={"bot-load-in"} + reveal={farmbotReveal} onRest={() => loadProgress.markStep("farmbot")} config={botLoadInConfig} distance={config.columnLength + 1500} @@ -543,6 +582,7 @@ export const GardenModel = (props: GardenModelProps) => { <SceneBoundary loadStep={"details"} loadProgress={loadProgress} + reveal={detailsReveal} markReadyOnMount={false} markName={"three_d_details_ready"}> {config.stats && <StatsGl className={"stats-gl"} />} @@ -553,6 +593,7 @@ export const GardenModel = (props: GardenModelProps) => { configPosition={props.configPosition} activeFocus={props.activeFocus} setActiveFocus={props.setActiveFocus} + reveal={detailsReveal} onRest={!sceneDetailsLoadIn ? markDetailsLoaded : undefined} />} <AxesHelper args={[5000]} visible={config.threeAxes} /> {config.viewCube && <GizmoHelper><GizmoViewcube /></GizmoHelper>} @@ -579,12 +620,14 @@ export const GardenModel = (props: GardenModelProps) => { <Lab config={config} activeFocus={props.activeFocus} + reveal={detailsReveal} onDetailsLoadInRest={config.scene == "Lab" ? markDetailsLoaded : undefined} /> <Greenhouse config={config} activeFocus={props.activeFocus} + reveal={detailsReveal} onDetailsLoadInRest={config.scene == "Greenhouse" ? markDetailsLoaded : undefined} /> @@ -593,7 +636,7 @@ export const GardenModel = (props: GardenModelProps) => { config={config} dispatch={dispatch} topDownAtStart={topDownAtStart} />} - {!animatedDetailsLoadIn && + {detailsReveal && !animatedDetailsLoadIn && <LoadStepReady step={"details"} markStep={loadProgress.markStep} />} diff --git a/frontend/three_d_garden/progressive_load.tsx b/frontend/three_d_garden/progressive_load.tsx index dc675a698b..e36d3f0695 100644 --- a/frontend/three_d_garden/progressive_load.tsx +++ b/frontend/three_d_garden/progressive_load.tsx @@ -127,14 +127,16 @@ export const LoadStepReady = (props: LoadStepReadyProps) => { interface ThreeDLoadProgressOverlayProps { progress: ThreeDLoadProgress; + complete?: boolean; } export const ThreeDLoadProgressOverlay = (props: ThreeDLoadProgressOverlayProps) => { - const [mounted, setMounted] = React.useState(!props.progress.complete); + const complete = props.complete || props.progress.complete; + const [mounted, setMounted] = React.useState(!complete); React.useEffect(() => { - if (!props.progress.complete) { + if (!complete) { // eslint-disable-next-line react-hooks/set-state-in-effect setMounted(true); return; @@ -142,32 +144,32 @@ export const ThreeDLoadProgressOverlay = const timeout = window.setTimeout(() => setMounted(false), THREE_D_LOAD_PROGRESS_FADE_MS); return () => window.clearTimeout(timeout); - }, [props.progress.complete]); + }, [complete]); if (!mounted) { return undefined; } const className = [ "three-d-load-progress", - props.progress.complete ? "three-d-load-progress-complete" : "", + complete ? "three-d-load-progress-complete" : "", ].join(" "); return <Html fullscreen={true}> <div className={className}> <div className={"three-d-load-progress-bar"} aria-hidden={true}> <div className={"three-d-load-progress-fill"} - style={{ width: `${props.progress.progress}%` }} /> + style={{ width: `${complete ? 100 : props.progress.progress}%` }} /> </div> - <p>{props.progress.complete ? "Enjoy!" : props.progress.currentStep?.label}</p> + <p>{complete ? "Enjoy!" : props.progress.currentStep?.label}</p> </div> </Html>; }; const loadInConfig = { - tension: 220, - friction: 26, + tension: 240, + friction: 30, }; export const botLoadInConfig = { - tension: 220, + tension: 240, friction: 30, clamp: true, }; @@ -184,6 +186,7 @@ const canTraverse = (value: unknown): value is Object3D => interface LoadInGroupProps { name: string; children: React.ReactNode; + reveal?: boolean; onRest?: () => void; config?: LoadInSpringConfig; fromPosition?: [number, number, number]; @@ -195,12 +198,14 @@ interface LoadInGroupProps { } export const LoadInGroup = (props: LoadInGroupProps) => { + const reveal = props.reveal !== false; const fromPosition = props.fromPosition || [0, 0, 0]; const toPosition = props.toPosition || [0, 0, 0]; const fromScale = props.fromScale || 1; const toScale = props.toScale || 1; + const fromOpacity = props.fadeIn ? 0 : 1; const groupRef = React.useRef<Object3D | undefined>(undefined); - const opacityRef = React.useRef(props.fadeIn ? 0 : 1); + const opacityRef = React.useRef(fromOpacity); const materialBinding = React.useRef<ReturnType< typeof createFocusMaterialBinding > | undefined>(undefined); @@ -224,18 +229,20 @@ export const LoadInGroup = (props: LoadInGroupProps) => { from: { position: fromPosition, scale: fromScale, - opacity: props.fadeIn ? 0 : 1, + opacity: fromOpacity, }, to: { - position: toPosition, - scale: toScale, - opacity: 1, + position: reveal ? toPosition : fromPosition, + scale: reveal ? toScale : fromScale, + opacity: reveal ? 1 : fromOpacity, }, + immediate: !reveal, onChange: result => { const value = result.value as { opacity?: number }; applyOpacity(value.opacity ?? 1); }, onRest: () => { + if (!reveal) { return; } applyOpacity(1); restoreMaterialBinding(); props.onRest?.(); @@ -262,6 +269,7 @@ export const LoadInGroup = (props: LoadInGroupProps) => { interface PopInGroupProps { name: string; children: React.ReactNode; + reveal?: boolean; onRest?: () => void; distance?: number; } @@ -269,6 +277,7 @@ interface PopInGroupProps { export const PopInGroup = (props: PopInGroupProps) => <LoadInGroup name={props.name} + reveal={props.reveal} onRest={props.onRest} fromPosition={[0, 0, -(props.distance || 300)]} fromScale={[0.96, 0.96, 0.05]} @@ -279,6 +288,7 @@ export const PopInGroup = (props: PopInGroupProps) => interface FallInGroupProps { name: string; children: React.ReactNode; + reveal?: boolean; onRest?: () => void; config?: LoadInSpringConfig; distance?: number; @@ -289,6 +299,7 @@ interface FallInGroupProps { export const FallInGroup = (props: FallInGroupProps) => <LoadInGroup name={props.name} + reveal={props.reveal} onRest={props.onRest} config={props.config} fromPosition={[0, 0, props.distance || 3000]} @@ -302,12 +313,14 @@ export const FallInGroup = (props: FallInGroupProps) => interface GridRevealGroupProps { name: string; children: React.ReactNode; + reveal?: boolean; onRest?: () => void; } export const GridRevealGroup = (props: GridRevealGroupProps) => <LoadInGroup name={props.name} + reveal={props.reveal} onRest={props.onRest} fromScale={[0.001, 0.001, 1]} toScale={[1, 1, 1]}> diff --git a/frontend/three_d_garden/scenes/greenhouse.tsx b/frontend/three_d_garden/scenes/greenhouse.tsx index 32e342eca5..f823f86634 100644 --- a/frontend/three_d_garden/scenes/greenhouse.tsx +++ b/frontend/three_d_garden/scenes/greenhouse.tsx @@ -13,6 +13,7 @@ import { useTextureVariant } from "../texture_variants"; export interface GreenhouseProps { config: Config; activeFocus: string; + reveal?: boolean; onDetailsLoadInRest?(): void; } @@ -37,6 +38,7 @@ export const Greenhouse = (props: GreenhouseProps) => { visible={config.scene == "Greenhouse"}> <PopInGroup name={"greenhouse-scene-details-load-in"} + reveal={props.reveal} onRest={props.onDetailsLoadInRest} distance={300}> diff --git a/frontend/three_d_garden/scenes/lab.tsx b/frontend/three_d_garden/scenes/lab.tsx index e6c0e86ae9..ed4ae7449c 100644 --- a/frontend/three_d_garden/scenes/lab.tsx +++ b/frontend/three_d_garden/scenes/lab.tsx @@ -12,6 +12,7 @@ import { useTextureVariant } from "../texture_variants"; export interface LabProps { config: Config; activeFocus: string; + reveal?: boolean; onDetailsLoadInRest?(): void; } @@ -49,6 +50,7 @@ export const Lab = (props: LabProps) => { return <Group name={"lab-environment"} visible={config.scene == "Lab"}> <PopInGroup name={"lab-scene-details-load-in"} + reveal={props.reveal} onRest={props.onDetailsLoadInRest} distance={300}> <Group From 1e138182a7fab7bcd899b941ed7914e93f564806 Mon Sep 17 00:00:00 2001 From: Rory Aronson <rory@roryaronson.com> Date: Tue, 26 May 2026 18:35:32 -0700 Subject: [PATCH 74/83] Fix mobile panel interactivity during progressive load --- frontend/__test_support__/three_d_mocks.tsx | 7 +++++-- .../three_d_garden/__tests__/progressive_load_test.tsx | 7 +++++++ frontend/three_d_garden/progressive_load.tsx | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index 60609f69b5..f33f4d7b6e 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -785,8 +785,11 @@ jest.mock("@react-three/drei", () => { <div className={"text"}>{children}</div>, Detailed: ({ children }: { children: ReactNode }) => <div className={"detailed"}>{children}</div>, - Html: ({ children }: { children: ReactNode }) => - <div className={"html"}>{children}</div>, + Html: ({ children, style }: { + children: ReactNode, + style?: React.CSSProperties, + }) => + <div className={"html"} style={style}>{children}</div>, PerspectiveCamera: ({ name }: { name: string }) => <div className={"perspective-camera"}>{name}</div>, useCursor: jest.fn(), diff --git a/frontend/three_d_garden/__tests__/progressive_load_test.tsx b/frontend/three_d_garden/__tests__/progressive_load_test.tsx index 38d9a0eb16..4ebb52e849 100644 --- a/frontend/three_d_garden/__tests__/progressive_load_test.tsx +++ b/frontend/three_d_garden/__tests__/progressive_load_test.tsx @@ -204,4 +204,11 @@ describe("3D load progress", () => { expect(document.querySelector(".three-d-load-progress")).toBeFalsy(); jest.useRealTimers(); }); + + it("doesn't block clicks outside the progress bar", () => { + render(<ProgressHarness />); + + const html = document.querySelector(".html") as HTMLElement; + expect(html.style.pointerEvents).toEqual("none"); + }); }); diff --git a/frontend/three_d_garden/progressive_load.tsx b/frontend/three_d_garden/progressive_load.tsx index e36d3f0695..2890819cbb 100644 --- a/frontend/three_d_garden/progressive_load.tsx +++ b/frontend/three_d_garden/progressive_load.tsx @@ -151,7 +151,7 @@ export const ThreeDLoadProgressOverlay = "three-d-load-progress", complete ? "three-d-load-progress-complete" : "", ].join(" "); - return <Html fullscreen={true}> + return <Html fullscreen={true} style={{ pointerEvents: "none" }}> <div className={className}> <div className={"three-d-load-progress-bar"} aria-hidden={true}> From 25cc691ea4895fd391dc38c48991f7984624fe0c Mon Sep 17 00:00:00 2001 From: Rory Aronson <rory@roryaronson.com> Date: Tue, 26 May 2026 18:46:44 -0700 Subject: [PATCH 75/83] Improve panel closed URL state --- frontend/app.tsx | 18 ++++++++++++++++++ frontend/farm_designer/panel_header.tsx | 12 +++++------- frontend/hotkeys.tsx | 1 + frontend/nav/nav_links.tsx | 11 ++++++----- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/frontend/app.tsx b/frontend/app.tsx index 6dcddd8428..6e367f3780 100644 --- a/frontend/app.tsx +++ b/frontend/app.tsx @@ -29,6 +29,7 @@ import { AppState } from "./reducer"; import { Navigate, Outlet } from "react-router"; import { ErrorBoundary } from "./error_boundary"; import { DesignerState } from "./farm_designer/interfaces"; +import { setPanelOpen } from "./farm_designer/panel_header"; export interface AppProps { dispatch: Function; @@ -81,17 +82,30 @@ const MUST_LOAD: ResourceName[] = [ export class RawApp extends React.Component<AppProps, {}> { private _isMounted = false; + private mapLandingPanelClosed = false; private get isLoaded() { return (MUST_LOAD.length === intersection(this.props.loaded, MUST_LOAD).length); } + closePanelForMapPage = () => { + const landingPage = this.props.getConfigValue(StringSetting.landing_page); + if (!this.mapLandingPanelClosed && + (Path.equals(Path.designer()) || + ((Path.equals("") || Path.equals(Path.app())) && + landingPage == "map"))) { + this.props.dispatch(setPanelOpen(false)); + this.mapLandingPanelClosed = true; + } + }; + /** * If the sync object takes more than 10s to load, the user will be granted * access into the app, but still warned. */ componentDidMount() { this._isMounted = true; + this.closePanelForMapPage(); setTimeout(() => { if (this._isMounted && !this.isLoaded) { error(t(Content.APP_LOAD_TIMEOUT_MESSAGE), { title: t("Warning") }); @@ -103,6 +117,10 @@ export class RawApp extends React.Component<AppProps, {}> { warning(t(Content.UNSUPPORTED_BROWSER)); } + componentDidUpdate() { + this.closePanelForMapPage(); + } + componentWillUnmount() { this._isMounted = false; } diff --git a/frontend/farm_designer/panel_header.tsx b/frontend/farm_designer/panel_header.tsx index d8d85e8870..c19b121399 100644 --- a/frontend/farm_designer/panel_header.tsx +++ b/frontend/farm_designer/panel_header.tsx @@ -200,7 +200,7 @@ const NavTab = (props: NavTabProps) => { const { panel, dispatch, designer } = props; const isActive = getCurrentPanel(designer) === panel; return <Link id={PANEL_SLUG[panel]} - to={getPanelPath(panel)} + to={isActive ? Path.designer() : getPanelPath(panel)} style={{ flex: 0.3 }} onClick={() => dispatch(setPanelOpen(!isActive))} className={isActive ? "active" : ""}> @@ -264,19 +264,17 @@ export class DesignerNavTabs return <div className={`panel-nav ${hidden}`}> {!this.state.atEnd && <div className={"scroll-indicator"} />} <div className={"panel-tabs"} onScroll={this.updateScroll}> - <a id={Panel.Map} + <Link id={Panel.Map} + to={Path.designer()} style={{ flex: 0.3 }} className={getCurrentPanel(this.props.designer) === Panel.Map ? "active" : ""} - onClick={(e: React.MouseEvent<HTMLAnchorElement>) => { - e.preventDefault(); - this.props.dispatch(setPanelOpen(false)); - }}> + onClick={() => this.props.dispatch(setPanelOpen(false))}> <img width={35} height={30} src={FilePath.icon(Icon.map)} title={PANEL_TITLE()[Panel.Map]} /> - </a> + </Link> <NavTab {...common} panel={Panel.Plants} /> <NavTab {...common} panel={Panel.Weeds} /> <NavTab {...common} panel={Panel.Points} /> diff --git a/frontend/hotkeys.tsx b/frontend/hotkeys.tsx index 3534b33a80..82fcdb533f 100644 --- a/frontend/hotkeys.tsx +++ b/frontend/hotkeys.tsx @@ -119,6 +119,7 @@ export const hotkeysWithActions = (props: HotkeysWithActionsProps) => { if (!isUndefined(designer.drawnPoint?.cx)) { dispatch(resetDrawnPointDataAction()); } else { + navigate(Path.designer()); dispatch(setPanelOpen(false)); } }, diff --git a/frontend/nav/nav_links.tsx b/frontend/nav/nav_links.tsx index 9706029c06..03fd86700a 100644 --- a/frontend/nav/nav_links.tsx +++ b/frontend/nav/nav_links.tsx @@ -8,6 +8,7 @@ import { } from "../farm_designer/panel_header"; import { ExternalUrl } from "../external_urls"; import { maybeBeacon } from "../help/tours"; +import { Path } from "../internal_urls"; export const getLinks = (): Panel[] => [ Panel.Plants, @@ -29,22 +30,22 @@ export const getLinks = (): Panel[] => [ export const NavLinks = (props: NavLinksProps) => <div className={"links"}> <div className={"nav-links"}> - <a id={"map"} + <Link id={"map"} + to={Path.designer()} draggable={false} className={getCurrentPanel(props.designer) === Panel.Map ? "active" : ""} - onClick={(e: React.MouseEvent<HTMLAnchorElement>) => { - e.preventDefault(); + onClick={() => { props.close(); props.dispatch(setPanelOpen(false)); }}> <NavIconAndText panel={Panel.Map} alertCount={props.alertCount} /> - </a> + </Link> {getLinks().map(panel => { const isActive = getCurrentPanel(props.designer) === panel; return <Link - to={getPanelPath(panel)} + to={isActive ? Path.designer() : getPanelPath(panel)} className={[ isActive ? "active" : "", maybeBeacon(PANEL_SLUG[panel], "soft", props.helpState), From f21039d31d9d0e6ace977dad05ae5e87f3aafa66 Mon Sep 17 00:00:00 2001 From: Rory Aronson <rory@roryaronson.com> Date: Tue, 26 May 2026 18:50:12 -0700 Subject: [PATCH 76/83] Mount bot Trail after progressive load animation --- frontend/three_d_garden/bot/bot.tsx | 51 +++++++++++++----------- frontend/three_d_garden/garden_model.tsx | 1 + 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index b06770c7f0..b3d7b68482 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -100,6 +100,7 @@ export interface FarmbotModelProps { configPosition: PositionConfig; activeFocus: string; getZ(x: number, y: number): number; + trailReady?: boolean; toolSlots?: SlotWithTool[]; mountedToolName?: string | undefined; dispatch?: Function; @@ -218,6 +219,7 @@ export const Bot = (props: FarmbotModelProps) => { const distanceToSoil = -props.getZ(x, y) - zDir * z; const defaultTrailWidth = config.perspective ? 500 : 0.1; + const trailReady = props.trailReady !== false; const airTubeEndPosition = (kitVersion: string): [number, number, number] => { switch (kitVersion) { @@ -253,6 +255,19 @@ export const Bot = (props: FarmbotModelProps) => { ...gardenXY(x + cameraMountOffset.x, y + cameraMountOffset.y), zZero - zDir * z - 140 + zGantryOffset + 20, ); + const utmComponent = <Group name={"UTM"} + position={[ + ...gardenXY(x + 11, y), + zZero - zDir * z + utmHeight / 2 - 19, + ]} + rotation={[0, 0, Math.PI / 2]} + scale={1000}> + <Mesh + geometry={utm.nodes.M5_Barb.geometry} + material={utm.materials.PaletteMaterial001} + position={[0.015, 0.009, 0.036]} + rotation={[0, 0, 2.094]} /> + </Group>; return <FocusVisibilityGroup name={"bot"} keepMounted={true} preserveDepthWrite={true} @@ -570,29 +585,19 @@ export const Bot = (props: FarmbotModelProps) => { configPosition={props.configPosition} cameraMountPosition={cameraMountPosition} distanceToSoil={distanceToSoil} /> - <Trail - width={trail ? defaultTrailWidth : 0} - attenuation={t => Math.pow(t, 3)} - color={"red"} - length={100} - decay={0.5} - local={false} - stride={0} - interval={1}> - <Group name={"UTM"} - position={[ - ...gardenXY(x + 11, y), - zZero - zDir * z + utmHeight / 2 - 19, - ]} - rotation={[0, 0, Math.PI / 2]} - scale={1000}> - <Mesh - geometry={utm.nodes.M5_Barb.geometry} - material={utm.materials.PaletteMaterial001} - position={[0.015, 0.009, 0.036]} - rotation={[0, 0, 2.094]} /> - </Group> - </Trail> + {trailReady && trail + ? <Trail + width={defaultTrailWidth} + attenuation={t => Math.pow(t, 3)} + color={"red"} + length={100} + decay={0.5} + local={false} + stride={0} + interval={1}> + {utmComponent} + </Trail> + : utmComponent} <Cylinder visible={laser} material-color={"red"} diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index bf21e7f393..ce307448f1 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -574,6 +574,7 @@ export const GardenModel = (props: GardenModelProps) => { config={config} configPosition={props.configPosition} getZ={getZ} + trailReady={detailsReveal} activeFocus={props.activeFocus} mountedToolName={props.mountedToolName} toolSlots={props.toolSlots} /> From f6fe4e7c691ce2acd7e7d958fffe04cad41490a9 Mon Sep 17 00:00:00 2001 From: Rory Aronson <rory@roryaronson.com> Date: Tue, 26 May 2026 19:26:18 -0700 Subject: [PATCH 77/83] fix nav link test --- frontend/nav/__tests__/nav_links_test.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/nav/__tests__/nav_links_test.tsx b/frontend/nav/__tests__/nav_links_test.tsx index ca164ee687..717973ef87 100644 --- a/frontend/nav/__tests__/nav_links_test.tsx +++ b/frontend/nav/__tests__/nav_links_test.tsx @@ -53,9 +53,11 @@ describe("<NavLinks />", () => { designer: fakeDesignerState(), }); - const plantsLink = (container: ParentNode) => - Array.from(container.querySelectorAll("a")) - .find(a => a.getAttribute("href") == Path.plants()); + const plantsLink = (container: ParentNode) => { + const link = container.querySelector("img[title='Plants']")?.closest("a"); + if (!link) { throw new Error("Plants link not found."); } + return link; + }; it("toggles the mobile nav menu", () => { const p = fakeProps(); @@ -94,7 +96,7 @@ describe("<NavLinks />", () => { it("shows active link", () => { location.pathname = Path.mock(Path.plants()); const { container } = render(<NavLinks {...fakeProps()} />); - expect(plantsLink(container)?.className).toContain("active"); + expect(plantsLink(container).className).toContain("active"); }); it("clicks active link: closes panel", () => { @@ -103,7 +105,7 @@ describe("<NavLinks />", () => { const dispatch = jest.fn(); p.dispatch = mockDispatch(dispatch); const { container } = render(<NavLinks {...p} />); - fireEvent.click(plantsLink(container) as Element); + fireEvent.click(plantsLink(container)); expect(p.close).toHaveBeenCalled(); expect(dispatch).toHaveBeenCalledWith({ type: Actions.SET_PANEL_OPEN, payload: false, @@ -116,7 +118,7 @@ describe("<NavLinks />", () => { const dispatch = jest.fn(); p.dispatch = mockDispatch(dispatch); const { container } = render(<NavLinks {...p} />); - fireEvent.click(plantsLink(container) as Element); + fireEvent.click(plantsLink(container)); expect(p.close).toHaveBeenCalled(); expect(dispatch).toHaveBeenCalledWith({ type: Actions.SET_PANEL_OPEN, payload: true, From ac138e340e6a0e9b3d134fbf6830548999a75b3d Mon Sep 17 00:00:00 2001 From: Rory Aronson <rory@roryaronson.com> Date: Wed, 27 May 2026 11:23:13 -0700 Subject: [PATCH 78/83] fix plant label visibility --- frontend/three_d_garden/garden_model.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index ce307448f1..dabc2eb724 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -479,7 +479,7 @@ export const GardenModel = (props: GardenModelProps) => { distance={200}> <FocusVisibilityGroup name={"plant-labels"} - visible={!props.activeFocus}> + visible={!props.activeFocus && plantsVisible}> {plantLabelNodes} </FocusVisibilityGroup> <FocusVisibilityGroup name={"plants"} From 88b0fac8eb7f4535cacd43e58949b82f08f7ef6a Mon Sep 17 00:00:00 2001 From: Rory Aronson <rory@roryaronson.com> Date: Wed, 27 May 2026 11:31:28 -0700 Subject: [PATCH 79/83] prevent spread from intersecting raycasting when adding objects --- frontend/three_d_garden/garden/plants.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index 5d48f0d8e6..353a2886f9 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -7,10 +7,12 @@ import { Group as GroupType, Color, WebGLProgramParametersWithUniforms, - InstancedMesh as InstancedMeshType, + InstancedMesh as ThreeInstancedMesh, Matrix4, Quaternion, InstancedBufferAttribute, + type Raycaster, + type Intersection, } from "three"; import { getGardenPositionFunc, @@ -110,6 +112,15 @@ interface PlantSpreadUpdateState { lastUpdateKey: string; } +const plantSpreadRaycast = function ( + this: ThreeInstancedMesh, + raycaster: Raycaster, + intersects: Intersection[], +) { + if (HOVER_OBJECT_MODES.includes(getMode())) { return; } + ThreeInstancedMesh.prototype.raycast.call(this, raycaster, intersects); +}; + const newPlantSpreadUpdateState = (): PlantSpreadUpdateState => ({ needsInstanceUpdate: true, lastUpdateKey: "", @@ -122,7 +133,7 @@ export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps const instanceCapacity = Math.max(props.instanceCapacity || 0, plants.length); const navigate = useNavigate(); // eslint-disable-next-line no-null/no-null - const instancedRef = React.useRef<InstancedMeshType>(null); + const instancedRef = React.useRef<ThreeInstancedMesh>(null); const tempMatrix = React.useMemo(() => new Matrix4(), []); const tempPosition = React.useMemo(() => new Vector3(), []); const tempScale = React.useMemo(() => new Vector3(), []); @@ -161,7 +172,7 @@ export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps const hasTransientPlant = React.useMemo(() => plants.some(plant => !plant.id), [plants]); - const ensureInstanceColor = React.useCallback((mesh: InstancedMeshType) => { + const ensureInstanceColor = React.useCallback((mesh: ThreeInstancedMesh) => { const needsResize = !mesh.instanceColor || mesh.instanceColor.count != instanceCapacity; if (needsResize) { @@ -292,6 +303,7 @@ export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps count={plants.length} userData={{ plantIndexes }} visible={visible} + raycast={plantSpreadRaycast} onClick={onClick}> <SphereGeometry args={[1, 32, 32]} /> <MeshPhongMaterial From 49df48f017f9b3ca08106ac159479d8f7fd9974c Mon Sep 17 00:00:00 2001 From: Rory Aronson <rory@roryaronson.com> Date: Wed, 27 May 2026 11:46:01 -0700 Subject: [PATCH 80/83] fix point radius torus visibility --- .../garden/__tests__/plants_test.tsx | 57 ++++++++++++++++++- .../garden/__tests__/point_test.tsx | 11 ++++ frontend/three_d_garden/garden/point.tsx | 16 +++++- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 70f96727b5..bb6433e50a 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -16,10 +16,20 @@ import { Actions } from "../../../constants"; import { convertPlants } from "../../../farm_designer/three_d_garden_map"; import { setMockInstanceId } from "../../../__test_support__/three_d_mocks"; import { useFrame } from "@react-three/fiber"; -import { Quaternion, WebGLProgramParametersWithUniforms } from "three"; +import { + InstancedMesh as ThreeInstancedMesh, + Quaternion, + WebGLProgramParametersWithUniforms, + type Intersection, + type Raycaster, +} from "three"; import { Mode } from "../../../farm_designer/map/interfaces"; import * as mapUtil from "../../../farm_designer/map/util"; import * as meshKey from "../instanced_mesh_key"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; interface MockRef { current: { @@ -257,6 +267,51 @@ describe("<ThreeDPlantSpread />", () => { expect(mockNavigate).not.toHaveBeenCalled(); }); + const spreadRaycast = (p = fakeProps()) => { + queueMeshRef(); + const wrapper = createRenderer(<PlantSpreadInstances {...p} />); + const mesh = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh")[0]; + const raycast = mesh.props.raycast as ( + this: ThreeInstancedMesh, + raycaster: Raycaster, + intersects: Intersection[], + ) => void; + unmountRenderer(wrapper); + return raycast; + }; + + it.each([ + Mode.clickToAdd, + Mode.createPoint, + Mode.createWeed, + ])("allows %s raycasts through spread spheres", mode => { + getModeSpy.mockReturnValue(mode); + const defaultRaycast = jest.spyOn( + ThreeInstancedMesh.prototype, + "raycast", + ); + const intersects: Intersection[] = []; + const raycaster = {} as Raycaster; + spreadRaycast().call({} as ThreeInstancedMesh, raycaster, intersects); + expect(defaultRaycast).not.toHaveBeenCalled(); + expect(intersects).toEqual([]); + defaultRaycast.mockRestore(); + }); + + it("keeps spread sphere raycasts outside placement modes", () => { + getModeSpy.mockReturnValue(Mode.none); + const defaultRaycast = jest.spyOn( + ThreeInstancedMesh.prototype, + "raycast", + ).mockImplementation(() => undefined); + const intersects: Intersection[] = []; + const raycaster = {} as Raycaster; + spreadRaycast().call({} as ThreeInstancedMesh, raycaster, intersects); + expect(defaultRaycast).toHaveBeenCalledWith(raycaster, intersects); + defaultRaycast.mockRestore(); + }); + it("updates instance colors on frame", () => { queueMeshRef(); const p = fakeProps(); diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx index c82c50a4e5..71954b186f 100644 --- a/frontend/three_d_garden/garden/__tests__/point_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx @@ -141,6 +141,17 @@ describe("<DrawnPoint />", () => { expect(container).toContainHTML("position=\"0,0,0\""); }); + it("draws point radius preview", () => { + location.pathname = Path.mock(Path.points("add")); + const p = fakeProps(); + const point = fakeDrawnPoint(); + point.r = 0; + p.designer.drawnPoint = point; + p.torusRef = { current: undefined as never }; + const { container } = render(<DrawnPoint {...p} />); + expect(container.querySelector(".torus")).not.toBeNull(); + }); + it("doesn't draw point", () => { location.pathname = Path.mock(Path.points("add")); const p = fakeProps(); diff --git a/frontend/three_d_garden/garden/point.tsx b/frontend/three_d_garden/garden/point.tsx index dd530105fc..636a250510 100644 --- a/frontend/three_d_garden/garden/point.tsx +++ b/frontend/three_d_garden/garden/point.tsx @@ -10,6 +10,7 @@ import { Euler, InstancedMesh as InstancedMeshType, Matrix4, + Mesh as ThreeMesh, Quaternion, Vector3, } from "three"; @@ -334,7 +335,7 @@ const PointBase = (props: PointBaseProps) => { opacity={1 * alpha} /> </Sphere> </Group> - {radius > 0 && + {(radius > 0 || torusRef) && <HollowCylinder torusRef={torusRef} radius={radius} @@ -352,12 +353,23 @@ interface HollowCylinderProps { torusRef?: TorusRef; } +const setTorusRefCurrent = (torusRef: TorusRef, node: ThreeMesh | null) => { + (torusRef as React.MutableRefObject<ThreeMesh | null>).current = node; +}; + const HollowCylinder = ( { radius, color, alpha, torusRef }: HollowCylinderProps, ) => { + const setTorusRef = React.useCallback((node: ThreeMesh | null) => { + if (!torusRef) { return; } + const maybeMesh = node as Partial<ThreeMesh> | null; + if (!node || maybeMesh?.scale) { + setTorusRefCurrent(torusRef, node); + } + }, [torusRef]); return torusRef ? <Torus - ref={torusRef} + ref={setTorusRef} scale={[radius, radius, POINT_CYLINDER_SCALE_FACTOR]} rotation={[-Math.PI / 2, 0, 0]} args={[1, POINT_CYLINDER_TUBE_SIZE, SEGMENTS, SEGMENTS]}> From b406ea3bba66a2c40556c0c9012f8a33d902489d Mon Sep 17 00:00:00 2001 From: Rory Aronson <rory@roryaronson.com> Date: Wed, 27 May 2026 11:54:31 -0700 Subject: [PATCH 81/83] don't add plant/point/weed after orbit drag release --- .../__tests__/pointer_objects_test.tsx | 37 +++++++++++++++++++ .../bed/objects/pointer_objects.tsx | 3 ++ 2 files changed, 40 insertions(+) diff --git a/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx b/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx index d9364d49c9..cfc2aeef37 100644 --- a/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx +++ b/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx @@ -16,6 +16,7 @@ import { import { render } from "@testing-library/react"; import { INITIAL } from "../../../config"; import { fakeAddPlantProps } from "../../../../__test_support__/fake_props"; +import { fakeDrawnPoint } from "../../../../__test_support__/fake_designer_state"; import { clone } from "lodash"; import { Path } from "../../../../internal_urls"; import { Vector3 } from "three"; @@ -130,6 +131,42 @@ describe("soilClick()", () => { gardenCoords: { x: 1360, y: 660 }, })); }); + + it("doesn't create a plant after a drag", () => { + location.pathname = Path.mock(Path.cropSearch("mint")); + mockIsMobile = false; + const p = fakeProps(); + const e = { + stopPropagation: jest.fn(), + point: { x: 1, y: 2 }, + delta: 3, + } as unknown as ThreeEvent<MouseEvent>; + soilClick(p)(e); + expect(e.stopPropagation).toHaveBeenCalled(); + expect(dropPlantSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ["point", Path.points("add")], + ["weed", Path.weeds("add")], + ])("doesn't set %s location after a drag", (_label, path) => { + location.pathname = Path.mock(path); + mockIsMobile = false; + const p = fakeProps(); + const point = fakeDrawnPoint(); + point.cx = undefined; + point.cy = undefined; + point.r = 0; + p.addPlantProps.designer.drawnPoint = point; + const e = { + stopPropagation: jest.fn(), + point: { x: 1, y: 2 }, + delta: 3, + } as unknown as ThreeEvent<MouseEvent>; + soilClick(p)(e); + expect(e.stopPropagation).toHaveBeenCalled(); + expect(p.addPlantProps.dispatch).not.toHaveBeenCalled(); + }); }); describe("soilPointerMove()", () => { diff --git a/frontend/three_d_garden/bed/objects/pointer_objects.tsx b/frontend/three_d_garden/bed/objects/pointer_objects.tsx index 2993f9048f..b90e4b0c8c 100644 --- a/frontend/three_d_garden/bed/objects/pointer_objects.tsx +++ b/frontend/three_d_garden/bed/objects/pointer_objects.tsx @@ -178,11 +178,14 @@ export interface SoilClickProps { getZ(x: number, y: number): number; } +export const MAX_SOIL_CLICK_DELTA = 2; + export const soilClick = (props: SoilClickProps) => (e: ThreeEvent<MouseEvent>) => { const { config, navigate, addPlantProps, pointerPlantRef } = props; const getGardenPosition = getGardenPositionFunc(config); e.stopPropagation(); + if ((e.delta || 0) > MAX_SOIL_CLICK_DELTA) { return; } if (addPlantProps) { if (getMode() == Mode.clickToAdd) { dropPlant({ From 3cb3fb33f1c605870a239986554529c5927c7ee6 Mon Sep 17 00:00:00 2001 From: Rory Aronson <rory@roryaronson.com> Date: Wed, 27 May 2026 12:00:19 -0700 Subject: [PATCH 82/83] prevent plant icons from intersecting raycasting when adding objects --- .../garden/__tests__/plant_instances_test.tsx | 51 ++++++++++++++++++- .../three_d_garden/garden/plant_instances.tsx | 16 +++++- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx index 4de005c393..a2f47c86b0 100644 --- a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx @@ -22,7 +22,12 @@ import { fireEvent, render } from "@testing-library/react"; import { clone } from "lodash"; import { useFrame } from "@react-three/fiber"; import { useTexture } from "@react-three/drei"; -import { Quaternion } from "three"; +import { + InstancedMesh as ThreeInstancedMesh, + Quaternion, + type Intersection, + type Raycaster, +} from "three"; import { fakePlant } from "../../../__test_support__/fake_state/resources"; import { INITIAL } from "../../config"; import { @@ -202,6 +207,50 @@ describe("<PlantInstances />", () => { expect(mockNavigate).not.toHaveBeenCalled(); }); + const iconRaycast = (p = fakeProps()) => { + const wrapper = createRenderer(<PlantInstances {...p} />); + const mesh = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh")[0]; + const raycast = mesh.props.raycast as ( + this: ThreeInstancedMesh, + raycaster: Raycaster, + intersects: Intersection[], + ) => void; + unmountRenderer(wrapper); + return raycast; + }; + + it.each([ + Mode.clickToAdd, + Mode.createPoint, + Mode.createWeed, + ])("allows %s raycasts through plant icons", mode => { + getModeSpy.mockReturnValue(mode); + const defaultRaycast = jest.spyOn( + ThreeInstancedMesh.prototype, + "raycast", + ); + const intersects: Intersection[] = []; + const raycaster = {} as Raycaster; + iconRaycast().call({} as ThreeInstancedMesh, raycaster, intersects); + expect(defaultRaycast).not.toHaveBeenCalled(); + expect(intersects).toEqual([]); + defaultRaycast.mockRestore(); + }); + + it("keeps plant icon raycasts outside placement modes", () => { + getModeSpy.mockReturnValue(Mode.none); + const defaultRaycast = jest.spyOn( + ThreeInstancedMesh.prototype, + "raycast", + ).mockImplementation(() => undefined); + const intersects: Intersection[] = []; + const raycaster = {} as Raycaster; + iconRaycast().call({} as ThreeInstancedMesh, raycaster, intersects); + expect(defaultRaycast).toHaveBeenCalledWith(raycaster, intersects); + defaultRaycast.mockRestore(); + }); + it("doesn't navigate with missing instanceId", () => { setMockInstanceId(undefined); const p = fakeProps(); diff --git a/frontend/three_d_garden/garden/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx index c6679e12d9..5793de5c4f 100644 --- a/frontend/three_d_garden/garden/plant_instances.tsx +++ b/frontend/three_d_garden/garden/plant_instances.tsx @@ -1,10 +1,12 @@ import React from "react"; import { - InstancedMesh as InstancedMeshType, + InstancedMesh as ThreeInstancedMesh, Matrix4, Quaternion, Vector3, MeshBasicMaterial as ThreeMeshBasicMaterial, + type Intersection, + type Raycaster, } from "three"; import { ThreeEvent, useFrame } from "@react-three/fiber"; import { useNavigate } from "react-router"; @@ -60,6 +62,15 @@ const newPlantIconUpdateState = (): PlantIconUpdateState => ({ export const plantIconBrightness = (sunFactor?: number) => Math.max(0.25, sunFactor ?? 1); +const plantIconRaycast = function ( + this: ThreeInstancedMesh, + raycaster: Raycaster, + intersects: Intersection[], +) { + if (HOVER_OBJECT_MODES.includes(getMode())) { return; } + ThreeInstancedMesh.prototype.raycast.call(this, raycaster, intersects); +}; + const PlantIconInstances = (props: PlantIconInstancesProps) => { const { config, plants, icon, visible, startTimeRef, dispatch, getZ, plantIndexes, @@ -71,7 +82,7 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { () => getPlantIconTexture(baseTexture, icon), [baseTexture, icon]); // eslint-disable-next-line no-null/no-null - const instancedRef = React.useRef<InstancedMeshType>(null); + const instancedRef = React.useRef<ThreeInstancedMesh>(null); // eslint-disable-next-line no-null/no-null const materialRef = React.useRef<ThreeMeshBasicMaterial>(null); const lastBrightness = React.useRef<number | undefined>(undefined); @@ -168,6 +179,7 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { frustumCulled={false} userData={{ plantIndexes }} visible={visible} + raycast={plantIconRaycast} onClick={onClick} renderOrder={RenderOrder.plants}> <PlaneGeometry args={[1, 1]} /> From 35f68c09c0aba8dbc75da267c016d50435c325a4 Mon Sep 17 00:00:00 2001 From: Rory Aronson <rory@roryaronson.com> Date: Wed, 27 May 2026 12:12:42 -0700 Subject: [PATCH 83/83] clickWasDragged helper --- .../bed/objects/pointer_objects.tsx | 5 +- frontend/three_d_garden/click_event.ts | 7 ++ .../garden/__tests__/plant_instances_test.tsx | 13 +++ .../garden/__tests__/plants_test.tsx | 14 ++++ .../garden/__tests__/point_test.tsx | 28 +++++++ .../garden/__tests__/weed_test.tsx | 80 +++++++++++++++++++ .../three_d_garden/garden/plant_instances.tsx | 2 + frontend/three_d_garden/garden/plants.tsx | 2 + frontend/three_d_garden/garden/point.tsx | 7 +- frontend/three_d_garden/garden/weed.tsx | 8 +- 10 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 frontend/three_d_garden/click_event.ts diff --git a/frontend/three_d_garden/bed/objects/pointer_objects.tsx b/frontend/three_d_garden/bed/objects/pointer_objects.tsx index b90e4b0c8c..e856b368ee 100644 --- a/frontend/three_d_garden/bed/objects/pointer_objects.tsx +++ b/frontend/three_d_garden/bed/objects/pointer_objects.tsx @@ -41,6 +41,7 @@ import { getPlantIconTexture, getPlantIconTextureUrl, } from "../../garden/plant_icon_atlas"; +import { clickWasDragged } from "../../click_event"; export type PointerPlantRef = React.RefObject<GroupType | null>; export type RadiusRef = React.RefObject<MeshType | null>; @@ -178,14 +179,12 @@ export interface SoilClickProps { getZ(x: number, y: number): number; } -export const MAX_SOIL_CLICK_DELTA = 2; - export const soilClick = (props: SoilClickProps) => (e: ThreeEvent<MouseEvent>) => { const { config, navigate, addPlantProps, pointerPlantRef } = props; const getGardenPosition = getGardenPositionFunc(config); e.stopPropagation(); - if ((e.delta || 0) > MAX_SOIL_CLICK_DELTA) { return; } + if (clickWasDragged(e)) { return; } if (addPlantProps) { if (getMode() == Mode.clickToAdd) { dropPlant({ diff --git a/frontend/three_d_garden/click_event.ts b/frontend/three_d_garden/click_event.ts new file mode 100644 index 0000000000..2abec2af7a --- /dev/null +++ b/frontend/three_d_garden/click_event.ts @@ -0,0 +1,7 @@ +import type { ThreeEvent } from "@react-three/fiber"; + +export const MAX_POINTER_CLICK_DELTA = 1; + +export const clickWasDragged = ( + event: Pick<ThreeEvent<MouseEvent>, "delta">, +) => (event.delta || 0) > MAX_POINTER_CLICK_DELTA; diff --git a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx index a2f47c86b0..6873f958a7 100644 --- a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx @@ -185,6 +185,19 @@ describe("<PlantInstances />", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.plants("1")); }); + it("doesn't navigate after orbiting over a plant icon", () => { + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + const wrapper = createRenderer(<PlantInstances {...p} />); + const mesh = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh")[0]; + mesh.props.onClick({ instanceId: 0, delta: 3 }); + unmountRenderer(wrapper); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it("doesn't navigate without dispatch", () => { setMockInstanceId(0); const p = fakeProps(); diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index bb6433e50a..cb24893b58 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -253,6 +253,20 @@ describe("<ThreeDPlantSpread />", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.plants("1")); }); + it("doesn't navigate after orbiting over a spread sphere", () => { + queueMeshRef(); + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + const wrapper = createRenderer(<PlantSpreadInstances {...p} />); + const mesh = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh")[0]; + mesh.props.onClick({ instanceId: 0, delta: 3 }); + unmountRenderer(wrapper); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it("doesn't navigate on spread click in camera selection mode", () => { setMockInstanceId(0); getModeSpy.mockReturnValue(Mode.cameraSelection); diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx index 71954b186f..e354275c95 100644 --- a/frontend/three_d_garden/garden/__tests__/point_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx @@ -78,6 +78,20 @@ describe("<Point />", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.points("1")); }); + it("doesn't navigate after orbiting over a point", () => { + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.point.body.id = 1; + const wrapper = createRenderer(<Point {...p} />); + mountedWrappers.push(wrapper); + const point = wrapper.root + .findAll(node => node.props.name == "marker")[0]; + point.props.onClick({ delta: 3 }); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it("doesn't navigate to point info", () => { const p = fakeProps(); p.dispatch = undefined; @@ -119,6 +133,20 @@ describe("<Point />", () => { }); expect(mockNavigate).toHaveBeenCalledWith(Path.points("1")); }); + + it("doesn't navigate after orbiting over a point instance", () => { + const p = fakeInstanceProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.points[0].body.id = 1; + const wrapper = createRenderer(<PointInstances {...p} />); + mountedWrappers.push(wrapper); + const marker = wrapper.root + .findAll(node => node.props.name == "marker")[0]; + marker.props.onClick({ instanceId: 0, delta: 3 }); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); }); describe("<DrawnPoint />", () => { diff --git a/frontend/three_d_garden/garden/__tests__/weed_test.tsx b/frontend/three_d_garden/garden/__tests__/weed_test.tsx index fcec0d76f7..7046275197 100644 --- a/frontend/three_d_garden/garden/__tests__/weed_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/weed_test.tsx @@ -9,6 +9,8 @@ import { Actions } from "../../../constants"; import { mockDispatch } from "../../../__test_support__/fake_dispatch"; import * as mapUtil from "../../../farm_designer/map/util"; import { Mode } from "../../../farm_designer/map/interfaces"; +import { useFrame } from "@react-three/fiber"; +import { Quaternion } from "three"; import { createRenderer, unmountRenderer, @@ -16,16 +18,20 @@ import { describe("<Weed />", () => { let getModeSpy: jest.SpyInstance; + let reactUseRefSpy: jest.SpyInstance | undefined; const mountedWrappers: ReturnType<typeof createRenderer>[] = []; beforeEach(() => { getModeSpy = jest.spyOn(mapUtil, "getMode").mockReturnValue(Mode.none); + (useFrame as jest.Mock).mockClear(); }); afterEach(() => { mountedWrappers.splice(0).forEach(wrapper => unmountRenderer(wrapper)); getModeSpy.mockRestore(); + reactUseRefSpy?.mockRestore(); + reactUseRefSpy = undefined; }); const fakeProps = (): WeedProps => ({ @@ -66,6 +72,20 @@ describe("<Weed />", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1")); }); + it("doesn't navigate after orbiting over a weed", () => { + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.weed.body.id = 1; + const wrapper = createRenderer(<Weed {...p} />); + mountedWrappers.push(wrapper); + const weed = wrapper.root + .findAll(node => node.props.name == "weed-1")[0]; + weed.props.onClick({ delta: 3 }); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it("doesn't navigate to weed info", () => { const p = fakeProps(); p.dispatch = undefined; @@ -108,4 +128,64 @@ describe("<Weed />", () => { }); expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1")); }); + + it("navigates from a weed radius instance", () => { + const p = fakeInstanceProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.weeds[0].body.id = 1; + const wrapper = createRenderer(<WeedInstances {...p} />); + mountedWrappers.push(wrapper); + const weedRadius = wrapper.root + .findAll(node => node.props.name == "weed-radius")[0]; + weedRadius.props.onClick({ instanceId: 0 }); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_PANEL_OPEN, payload: true, + }); + expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1")); + }); + + it("doesn't navigate after orbiting over weed instances", () => { + const p = fakeInstanceProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.weeds[0].body.id = 1; + const wrapper = createRenderer(<WeedInstances {...p} />); + mountedWrappers.push(wrapper); + const weedIcons = wrapper.root + .findAll(node => node.props.name == "weed-icons")[0]; + const weedRadius = wrapper.root + .findAll(node => node.props.name == "weed-radius")[0]; + weedIcons.props.onClick({ instanceId: 0, delta: 3 }); + weedRadius.props.onClick({ instanceId: 0, delta: 3 }); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("updates weed icon matrices on frame", () => { + const iconRef = { + current: { + setMatrixAt: jest.fn(), + instanceMatrix: { needsUpdate: false }, + }, + }; + const radiusRef = { + current: { + setMatrixAt: jest.fn(), + instanceMatrix: { needsUpdate: false }, + }, + }; + const updateStateRef = { current: {} }; + reactUseRefSpy = jest.spyOn(React, "useRef") + .mockImplementationOnce(() => iconRef) + .mockImplementationOnce(() => updateStateRef) + .mockImplementationOnce(() => radiusRef) + .mockImplementation(value => ({ current: value })); + const wrapper = createRenderer(<WeedInstances {...fakeInstanceProps()} />); + mountedWrappers.push(wrapper); + const frameFn = (useFrame as jest.Mock).mock.calls[0][0]; + frameFn({ camera: { quaternion: new Quaternion() } }); + expect(iconRef.current.setMatrixAt).toHaveBeenCalled(); + expect(iconRef.current.instanceMatrix.needsUpdate).toEqual(true); + }); }); diff --git a/frontend/three_d_garden/garden/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx index 5793de5c4f..618133b7db 100644 --- a/frontend/three_d_garden/garden/plant_instances.tsx +++ b/frontend/three_d_garden/garden/plant_instances.tsx @@ -29,6 +29,7 @@ import { Mode } from "../../farm_designer/map/interfaces"; import { calcSunCoordinate, calcSunI, getAnimatedSeasonDate, } from "./sun"; +import { clickWasDragged } from "../click_event"; export interface PlantInstancesProps { plants: ThreeDGardenPlant[]; @@ -162,6 +163,7 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { }); const onClick = (event: ThreeEvent<MouseEvent>) => { + if (clickWasDragged(event)) { return; } const instanceId = event.instanceId; if (isUndefined(instanceId)) { return; } const plant = plants[instanceId]; diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index 353a2886f9..37f5438b5b 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -35,6 +35,7 @@ import { ActivePositionRef } from "../bed/objects/pointer_objects"; import { Mode } from "../../farm_designer/map/interfaces"; import { findCrop } from "../../crops/find"; import { perfMeasure } from "../../performance/perf"; +import { clickWasDragged } from "../click_event"; export interface ThreeDGardenPlant { id?: number | undefined; @@ -286,6 +287,7 @@ export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps }); const onClick = (event: ThreeEvent<MouseEvent>) => { + if (clickWasDragged(event)) { return; } const instanceId = event.instanceId; if (isUndefined(instanceId)) { return; } const plant = plants[instanceId]; diff --git a/frontend/three_d_garden/garden/point.tsx b/frontend/three_d_garden/garden/point.tsx index 636a250510..3243026010 100644 --- a/frontend/three_d_garden/garden/point.tsx +++ b/frontend/three_d_garden/garden/point.tsx @@ -28,6 +28,7 @@ import { HOVER_OBJECT_MODES, RenderOrder } from "../constants"; import { BillboardRef, ImageRef, RadiusRef, TorusRef, } from "../bed/objects/pointer_objects"; +import { clickWasDragged } from "../click_event"; const POINT_PIN_RADIUS = 12.5; const POINT_PIN_HEIGHT = 50; @@ -58,7 +59,8 @@ export const Point = (props: PointProps) => { y: point.body.y, z: props.getZ(point.body.x, point.body.y), }} - onClick={() => { + onClick={(event) => { + if (clickWasDragged(event)) { return; } if (point.body.id && !isUndefined(props.dispatch) && props.visible && !HOVER_OBJECT_MODES.includes(getMode())) { props.dispatch(setPanelOpen(true)); @@ -186,6 +188,7 @@ const PointBucketInstances = (props: PointInstanceBucketProps) => { const onClick = (instances: PointInstance[]) => (event: ThreeEvent<MouseEvent>) => { + if (clickWasDragged(event)) { return; } const instanceId = event.instanceId; if (isUndefined(instanceId)) { return; } const point = instances[instanceId]?.point; @@ -292,7 +295,7 @@ export const DrawnPoint = (props: DrawnPointProps) => { interface PointBaseProps { pointName: string; position?: Record<Xyz, number>; - onClick?: () => void; + onClick?: (event: ThreeEvent<MouseEvent>) => void; color: string | undefined; radius: number; alpha: number; diff --git a/frontend/three_d_garden/garden/weed.tsx b/frontend/three_d_garden/garden/weed.tsx index 56d1e461d7..78c8a0c31b 100644 --- a/frontend/three_d_garden/garden/weed.tsx +++ b/frontend/three_d_garden/garden/weed.tsx @@ -20,6 +20,7 @@ import { isUndefined } from "lodash"; import { setPanelOpen } from "../../farm_designer/panel_header"; import { getMode } from "../../farm_designer/map/util"; import { RadiusRef, BillboardRef, ImageRef } from "../bed/objects/pointer_objects"; +import { clickWasDragged } from "../click_event"; export const WEED_IMG_SIZE_FRACTION = 0.89; @@ -37,7 +38,8 @@ export const Weed = (props: WeedProps) => { return <WeedBase pointName={"" + weed.body.id} alpha={1} - onClick={() => { + onClick={(event) => { + if (clickWasDragged(event)) { return; } if (weed.body.id && !isUndefined(props.dispatch) && props.visible && !HOVER_OBJECT_MODES.includes(getMode())) { props.dispatch(setPanelOpen(true)); @@ -57,7 +59,7 @@ export const Weed = (props: WeedProps) => { interface WeedBaseProps { pointName: string; position?: Record<Xyz, number>; - onClick?: () => void; + onClick?: (event: ThreeEvent<MouseEvent>) => void; color: string | undefined; radius: number; alpha: number; @@ -241,6 +243,7 @@ const WeedIconInstances = (props: WeedIconInstancesProps) => { }); const onClick = (event: ThreeEvent<MouseEvent>) => { + if (clickWasDragged(event)) { return; } const instanceId = event.instanceId; if (isUndefined(instanceId)) { return; } navigateToWeed(weedInstances[instanceId]?.weed); @@ -289,6 +292,7 @@ const WeedRadiusInstances = (props: WeedRadiusInstancesProps) => { }, [bucket.weeds, noRotation, tempMatrix, tempPosition, tempScale]); const onClick = (event: ThreeEvent<MouseEvent>) => { + if (clickWasDragged(event)) { return; } const instanceId = event.instanceId; if (isUndefined(instanceId)) { return; } navigateToWeed(bucket.weeds[instanceId]?.weed);