mirror of
https://github.com/Heretek-AI/GDevelop-Heretek.git
synced 2026-10-12 02:02:42 +00:00
Improve the Diagnostic Report (#8090)
- Add a shortcut (**F7** by default) and command in the command palette to open diagnostic report at any time - The report now scans project for missing actions/conditions/expressions from extensions and reports invalid parameters (shown with red underline in Events Sheet) - Click on error location to navigate directly to the event
This commit is contained in:
@@ -99,10 +99,6 @@ void AbstractReadOnlyArbitraryEventsWorker::VisitEventList(const gd::EventsList&
|
||||
break;
|
||||
}
|
||||
events[i].AcceptVisitor(*this);
|
||||
|
||||
if (events[i].CanHaveSubEvents()) {
|
||||
VisitEventList(events[i].GetSubEvents());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +121,12 @@ void AbstractReadOnlyArbitraryEventsWorker::VisitEvent(const gd::BaseEvent& even
|
||||
}
|
||||
VisitInstructionList(*actionsVectors[j], false);
|
||||
}
|
||||
|
||||
// Visit sub-events inside VisitEvent to ensure local variables remain in scope
|
||||
// when using ReadOnlyArbitraryEventsWorkerWithContext
|
||||
if (!shouldStopIteration && event.CanHaveSubEvents()) {
|
||||
VisitEventList(event.GetSubEvents());
|
||||
}
|
||||
}
|
||||
|
||||
void AbstractReadOnlyArbitraryEventsWorker::VisitLinkEvent(const gd::LinkEvent& linkEvent) {
|
||||
|
||||
@@ -3642,6 +3642,21 @@ interface EventsContextAnalyzer {
|
||||
void Launch([Ref] EventsList events, [Const, Ref] ProjectScopedContainers projectScopedContainers);
|
||||
};
|
||||
|
||||
interface ReadOnlyArbitraryEventsWorkerWithContext {
|
||||
void Launch([Const, Ref] EventsList events, [Const, Ref] ProjectScopedContainers projectScopedContainers);
|
||||
};
|
||||
[JSImplementation=ReadOnlyArbitraryEventsWorkerWithContext]
|
||||
interface ReadOnlyArbitraryEventsWorkerWithContextJS {
|
||||
void ReadOnlyArbitraryEventsWorkerWithContextJS();
|
||||
|
||||
// Called for each event visited
|
||||
void DoVisitEvent([Const, Ref] BaseEvent event);
|
||||
|
||||
// Called for each instruction visited, with the current scoped containers (includes local variables)
|
||||
void DoVisitInstruction([Const, Ref] Instruction instruction, boolean isCondition, [Const, Ref] ProjectScopedContainers projectScopedContainers);
|
||||
};
|
||||
ReadOnlyArbitraryEventsWorkerWithContextJS implements ReadOnlyArbitraryEventsWorkerWithContext;
|
||||
|
||||
interface ArbitraryResourceWorker {
|
||||
};
|
||||
[JSImplementation=ArbitraryResourceWorker]
|
||||
|
||||
@@ -371,6 +371,42 @@ class AbstractFileSystemJS : public AbstractFileSystem {
|
||||
virtual ~AbstractFileSystemJS(){};
|
||||
};
|
||||
|
||||
/**
|
||||
* \brief Manual binding of gd::ReadOnlyArbitraryEventsWorkerWithContext to allow
|
||||
* overriding DoVisitEvent and DoVisitInstruction from JavaScript.
|
||||
*/
|
||||
class ReadOnlyArbitraryEventsWorkerWithContextJS : public ReadOnlyArbitraryEventsWorkerWithContext {
|
||||
public:
|
||||
ReadOnlyArbitraryEventsWorkerWithContextJS(){};
|
||||
virtual ~ReadOnlyArbitraryEventsWorkerWithContextJS(){};
|
||||
|
||||
virtual void DoVisitEvent(const gd::BaseEvent &event) {
|
||||
EM_ASM(
|
||||
{
|
||||
var self = Module['getCache'](Module['ReadOnlyArbitraryEventsWorkerWithContextJS'])[$0];
|
||||
if (!self.hasOwnProperty('doVisitEvent'))
|
||||
throw 'a JSImplementation must implement all functions, you forgot ReadOnlyArbitraryEventsWorkerWithContextJS::doVisitEvent.';
|
||||
self.doVisitEvent(wrapPointer($1, Module['BaseEvent']));
|
||||
},
|
||||
(int)this,
|
||||
(int)&event);
|
||||
}
|
||||
|
||||
virtual void DoVisitInstruction(const gd::Instruction &instruction, bool isCondition) {
|
||||
EM_ASM(
|
||||
{
|
||||
var self = Module['getCache'](Module['ReadOnlyArbitraryEventsWorkerWithContextJS'])[$0];
|
||||
if (!self.hasOwnProperty('doVisitInstruction'))
|
||||
throw 'a JSImplementation must implement all functions, you forgot ReadOnlyArbitraryEventsWorkerWithContextJS::doVisitInstruction.';
|
||||
self.doVisitInstruction(wrapPointer($1, Module['Instruction']), !!$2, wrapPointer($3, Module['ProjectScopedContainers']));
|
||||
},
|
||||
(int)this,
|
||||
(int)&instruction,
|
||||
isCondition,
|
||||
(int)&GetProjectScopedContainers());
|
||||
}
|
||||
};
|
||||
|
||||
class InitialInstanceJSFunctorWrapper : public gd::InitialInstanceFunctor {
|
||||
public:
|
||||
InitialInstanceJSFunctorWrapper(){};
|
||||
|
||||
Vendored
+10
@@ -2673,6 +2673,16 @@ export class EventsContextAnalyzer extends EmscriptenObject {
|
||||
launch(events: EventsList, projectScopedContainers: ProjectScopedContainers): void;
|
||||
}
|
||||
|
||||
export class ReadOnlyArbitraryEventsWorkerWithContext extends EmscriptenObject {
|
||||
launch(events: EventsList, projectScopedContainers: ProjectScopedContainers): void;
|
||||
}
|
||||
|
||||
export class ReadOnlyArbitraryEventsWorkerWithContextJS extends ReadOnlyArbitraryEventsWorkerWithContext {
|
||||
constructor();
|
||||
doVisitEvent(event: BaseEvent): void;
|
||||
doVisitInstruction(instruction: Instruction, isCondition: boolean, projectScopedContainers: ProjectScopedContainers): void;
|
||||
}
|
||||
|
||||
export class ArbitraryResourceWorker extends EmscriptenObject {}
|
||||
|
||||
export class ArbitraryResourceWorkerJS extends ArbitraryResourceWorker {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Automatically generated by GDevelop.js/scripts/generate-types.js
|
||||
declare class gdReadOnlyArbitraryEventsWorkerWithContext {
|
||||
launch(events: gdEventsList, projectScopedContainers: gdProjectScopedContainers): void;
|
||||
delete(): void;
|
||||
ptr: number;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// Automatically generated by GDevelop.js/scripts/generate-types.js
|
||||
declare class gdReadOnlyArbitraryEventsWorkerWithContextJS extends gdReadOnlyArbitraryEventsWorkerWithContext {
|
||||
constructor(): void;
|
||||
doVisitEvent(event: gdBaseEvent): void;
|
||||
doVisitInstruction(instruction: gdInstruction, isCondition: boolean, projectScopedContainers: gdProjectScopedContainers): void;
|
||||
delete(): void;
|
||||
ptr: number;
|
||||
};
|
||||
@@ -260,6 +260,8 @@ declare class libGDevelop {
|
||||
InstructionsTypeRenamer: Class<gdInstructionsTypeRenamer>;
|
||||
EventsContext: Class<gdEventsContext>;
|
||||
EventsContextAnalyzer: Class<gdEventsContextAnalyzer>;
|
||||
ReadOnlyArbitraryEventsWorkerWithContext: Class<gdReadOnlyArbitraryEventsWorkerWithContext>;
|
||||
ReadOnlyArbitraryEventsWorkerWithContextJS: Class<gdReadOnlyArbitraryEventsWorkerWithContextJS>;
|
||||
ArbitraryResourceWorker: Class<gdArbitraryResourceWorker>;
|
||||
ArbitraryResourceWorkerJS: Class<gdArbitraryResourceWorkerJS>;
|
||||
ResourcesMergingHelper: Class<gdResourcesMergingHelper>;
|
||||
|
||||
@@ -64,11 +64,14 @@ function patchGlueCppFile(cb) {
|
||||
'BehaviorJsImplementation',
|
||||
'ObjectJsImplementation',
|
||||
'BehaviorSharedDataJsImplementation',
|
||||
'ReadOnlyArbitraryEventsWorkerWithContextJS',
|
||||
];
|
||||
var functionsToErase = [
|
||||
'emscripten_bind_ArbitraryResourceWorkerJS_ExposeImage_1',
|
||||
'emscripten_bind_ArbitraryResourceWorkerJS_ExposeShader_1',
|
||||
'emscripten_bind_ArbitraryResourceWorkerJS_ExposeFile_1',
|
||||
'emscripten_bind_ReadOnlyArbitraryEventsWorkerWithContextJS_DoVisitEvent_1',
|
||||
'emscripten_bind_ReadOnlyArbitraryEventsWorkerWithContextJS_DoVisitInstruction_3',
|
||||
];
|
||||
fs.readFile(file, function(err, data) {
|
||||
if (err) cb(err);
|
||||
|
||||
@@ -10,6 +10,7 @@ export type CommandName =
|
||||
| 'LAUNCH_NETWORK_PREVIEW'
|
||||
| 'HOT_RELOAD_PREVIEW'
|
||||
| 'LAUNCH_PREVIEW_WITH_DIAGNOSTIC_REPORT'
|
||||
| 'OPEN_DIAGNOSTIC_REPORT'
|
||||
| 'OPEN_HOME_PAGE'
|
||||
| 'CREATE_NEW_PROJECT'
|
||||
| 'OPEN_PROJECT'
|
||||
@@ -118,6 +119,10 @@ const commandsList: { [CommandName]: CommandMetadata } = {
|
||||
area: 'PROJECT',
|
||||
displayText: t`Launch preview with diagnostic report`,
|
||||
},
|
||||
OPEN_DIAGNOSTIC_REPORT: {
|
||||
area: 'PROJECT',
|
||||
displayText: t`Show diagnostic report`,
|
||||
},
|
||||
OPEN_HOME_PAGE: { area: 'IDE', displayText: t`Show Home` },
|
||||
CREATE_NEW_PROJECT: {
|
||||
area: 'GENERAL',
|
||||
|
||||
@@ -124,6 +124,7 @@ import LocalVariablesDialog from '../VariablesList/LocalVariablesDialog';
|
||||
import GlobalAndSceneVariablesDialog from '../VariablesList/GlobalAndSceneVariablesDialog';
|
||||
import { type HotReloadPreviewButtonProps } from '../HotReload/HotReloadPreviewButton';
|
||||
import { useHighlightedAiGeneratedEvent } from './UseHighlightedAiGeneratedEvent';
|
||||
import { findEventByPath } from '../Utils/EventsValidationScanner';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
@@ -222,6 +223,7 @@ type State = {|
|
||||
showSearchPanel: boolean,
|
||||
searchResults: ?Array<gdBaseEvent>,
|
||||
searchFocusOffset: ?number,
|
||||
navigationHighlightEvent: ?gdBaseEvent,
|
||||
|
||||
layoutVariablesDialogOpen: boolean,
|
||||
|
||||
@@ -317,6 +319,7 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
|
||||
showSearchPanel: false,
|
||||
searchResults: null,
|
||||
searchFocusOffset: null,
|
||||
navigationHighlightEvent: null,
|
||||
|
||||
layoutVariablesDialogOpen: false,
|
||||
|
||||
@@ -391,6 +394,33 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
|
||||
);
|
||||
};
|
||||
|
||||
scrollToEventPath = (eventPath: Array<number>) => {
|
||||
const eventsTree = this._eventsTree;
|
||||
if (!eventsTree || eventPath.length === 0) return;
|
||||
|
||||
// Find the event at the path
|
||||
const event = findEventByPath(this.props.events, eventPath);
|
||||
if (!event) return;
|
||||
|
||||
// Unfold and scroll to the event
|
||||
eventsTree.unfoldForEvent(event);
|
||||
|
||||
// Highlight the event like search results
|
||||
this.setState({ navigationHighlightEvent: event });
|
||||
|
||||
setTimeout(() => {
|
||||
const row = eventsTree.getEventRow(event);
|
||||
if (row !== -1) {
|
||||
eventsTree.scrollToRow(row);
|
||||
}
|
||||
}, 100 /* Give some time for the events sheet to render before scrolling */);
|
||||
|
||||
// Clear the highlight after a few seconds
|
||||
setTimeout(() => {
|
||||
this.setState({ navigationHighlightEvent: null });
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
updateToolbar() {
|
||||
if (!this.props.setToolbar) return;
|
||||
|
||||
@@ -2228,8 +2258,14 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
|
||||
}}
|
||||
onOpenExternalEvents={onOpenExternalEvents}
|
||||
onOpenLayout={onOpenLayout}
|
||||
searchResults={eventsSearchResultEvents}
|
||||
searchFocusOffset={searchFocusOffset}
|
||||
searchResults={
|
||||
this.state.navigationHighlightEvent
|
||||
? [this.state.navigationHighlightEvent]
|
||||
: eventsSearchResultEvents
|
||||
}
|
||||
searchFocusOffset={
|
||||
this.state.navigationHighlightEvent ? 0 : searchFocusOffset
|
||||
}
|
||||
onEventMoved={this._onEventMoved}
|
||||
onEndEditingEvent={this._onEndEditingStringEvent}
|
||||
showObjectThumbnails={
|
||||
@@ -2468,6 +2504,7 @@ export type EventsSheetInterface = {|
|
||||
updateToolbar: () => void,
|
||||
onResourceExternallyChanged: ({| identifier: string |}) => void,
|
||||
onEventsModifiedOutsideEditor: (changes: OutOfEditorChanges) => void,
|
||||
scrollToEventPath: (eventPath: Array<number>) => void,
|
||||
|};
|
||||
|
||||
// EventsSheet is a wrapper so that the component can use multiple
|
||||
@@ -2477,6 +2514,7 @@ const EventsSheet = (props, ref) => {
|
||||
updateToolbar,
|
||||
onResourceExternallyChanged,
|
||||
onEventsModifiedOutsideEditor,
|
||||
scrollToEventPath,
|
||||
}));
|
||||
|
||||
const {
|
||||
@@ -2496,6 +2534,9 @@ const EventsSheet = (props, ref) => {
|
||||
addNewAiGeneratedEventIds(changes.newOrChangedAiGeneratedEventIds);
|
||||
if (component.current) component.current.onEventsModifiedOutsideEditor();
|
||||
};
|
||||
const scrollToEventPath = (eventPath: Array<number>) => {
|
||||
if (component.current) component.current.scrollToEventPath(eventPath);
|
||||
};
|
||||
|
||||
const authenticatedUser = React.useContext(AuthenticatedUserContext);
|
||||
const preferences = React.useContext(PreferencesContext);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Trans } from '@lingui/macro';
|
||||
import * as React from 'react';
|
||||
import Text from '../UI/Text';
|
||||
import Toggle from '../UI/Toggle';
|
||||
import { ColumnStackLayout } from '../UI/Layout';
|
||||
import { ColumnStackLayout, LineStackLayout } from '../UI/Layout';
|
||||
import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
|
||||
import { mapFor } from '../Utils/MapFor';
|
||||
import {
|
||||
@@ -16,12 +16,208 @@ import {
|
||||
} from '../UI/Table';
|
||||
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
|
||||
import PreferencesContext from '../MainFrame/Preferences/PreferencesContext';
|
||||
import AlertMessage from '../UI/AlertMessage';
|
||||
import {
|
||||
scanProjectForValidationErrors,
|
||||
groupValidationErrors,
|
||||
type ValidationError,
|
||||
} from '../Utils/EventsValidationScanner';
|
||||
import { getFunctionNameFromType } from '../EventsFunctionsExtensionsLoader';
|
||||
import Link from '../UI/Link';
|
||||
import IconButton from '../UI/IconButton';
|
||||
import ChevronArrowRight from '../UI/CustomSvgIcons/ChevronArrowRight';
|
||||
import ChevronArrowBottom from '../UI/CustomSvgIcons/ChevronArrowBottom';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
const styles = {
|
||||
table: {
|
||||
tableLayout: 'fixed',
|
||||
width: '100%',
|
||||
},
|
||||
locationCell: {
|
||||
width: '33%',
|
||||
verticalAlign: 'top',
|
||||
},
|
||||
locationText: {
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
instructionCell: {
|
||||
width: '67%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
instructionContent: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
},
|
||||
instructionTextCollapsed: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
instructionTextExpanded: {
|
||||
flex: 1,
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
minWidth: 0,
|
||||
},
|
||||
expandButtonContainer: {
|
||||
flexShrink: 0,
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
expandButton: {
|
||||
padding: 4,
|
||||
},
|
||||
typeLabel: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
};
|
||||
|
||||
type InvalidParameterRowProps = {|
|
||||
error: ValidationError,
|
||||
navigateToError: (error: ValidationError) => void,
|
||||
backgroundColor: string,
|
||||
|};
|
||||
|
||||
// Threshold for assuming text might be truncated in the table cell
|
||||
const TRUNCATION_THRESHOLD_CHARS = 60;
|
||||
|
||||
const InvalidParameterRow = ({
|
||||
error,
|
||||
navigateToError,
|
||||
backgroundColor,
|
||||
}: InvalidParameterRowProps) => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const typeLabel = error.isCondition ? 'Condition' : 'Action';
|
||||
const couldBeTruncated =
|
||||
error.instructionSentence.length > TRUNCATION_THRESHOLD_CHARS;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
style={{
|
||||
backgroundColor,
|
||||
}}
|
||||
>
|
||||
<TableRowColumn style={styles.locationCell}>
|
||||
<div style={styles.locationText}>
|
||||
<Link href="#" onClick={() => navigateToError(error)}>
|
||||
{error.locationType === 'scene' ? 'scene' : 'external-events'}:{' '}
|
||||
{error.locationName}
|
||||
</Link>
|
||||
</div>
|
||||
</TableRowColumn>
|
||||
<TableRowColumn style={styles.instructionCell}>
|
||||
<div style={styles.instructionContent}>
|
||||
<div
|
||||
style={{
|
||||
...(isExpanded
|
||||
? styles.instructionTextExpanded
|
||||
: styles.instructionTextCollapsed),
|
||||
cursor: couldBeTruncated ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={
|
||||
couldBeTruncated ? () => setIsExpanded(!isExpanded) : undefined
|
||||
}
|
||||
title={
|
||||
couldBeTruncated && !isExpanded
|
||||
? error.instructionSentence
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span style={styles.typeLabel}>{typeLabel}</span>{' '}
|
||||
{error.instructionSentence}
|
||||
</div>
|
||||
{couldBeTruncated && (
|
||||
<div style={styles.expandButtonContainer}>
|
||||
<IconButton
|
||||
size="small"
|
||||
style={styles.expandButton}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? <ChevronArrowBottom /> : <ChevronArrowRight />}
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableRowColumn>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
type InvalidParametersSectionProps = {|
|
||||
validationErrors: Array<ValidationError>,
|
||||
invalidParametersCount: number,
|
||||
navigateToError: (error: ValidationError) => void,
|
||||
gdevelopTheme: any,
|
||||
|};
|
||||
|
||||
const InvalidParametersSection = ({
|
||||
validationErrors,
|
||||
invalidParametersCount,
|
||||
navigateToError,
|
||||
gdevelopTheme,
|
||||
}: InvalidParametersSectionProps) => {
|
||||
return (
|
||||
<ColumnStackLayout noMargin>
|
||||
<Text size="block-title">
|
||||
<Trans>Invalid parameters in events ({invalidParametersCount})</Trans>
|
||||
</Text>
|
||||
<AlertMessage kind="error">
|
||||
<Trans>
|
||||
The following events have invalid parameters (shown with red underline
|
||||
in the events sheet). Click a location to navigate there.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
<Table style={styles.table}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderColumn style={styles.locationCell}>
|
||||
<Trans>Location</Trans>
|
||||
</TableHeaderColumn>
|
||||
<TableHeaderColumn style={styles.instructionCell}>
|
||||
<Trans>Instruction</Trans>
|
||||
</TableHeaderColumn>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{validationErrors
|
||||
.filter(error => error.type !== 'missing-instruction')
|
||||
.map((error, index) => (
|
||||
<InvalidParameterRow
|
||||
key={`${error.locationName}-${error.eventPath.join('-')}-${
|
||||
error.instructionType
|
||||
}-${error.parameterIndex ?? ''}-${index}`}
|
||||
error={error}
|
||||
navigateToError={navigateToError}
|
||||
backgroundColor={gdevelopTheme.list.itemsBackgroundColor}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ColumnStackLayout>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
wholeProjectDiagnosticReport: gdWholeProjectDiagnosticReport,
|
||||
onClose: () => void,
|
||||
onNavigateToLayoutEvent: (
|
||||
layoutName: string,
|
||||
eventPath: Array<number>
|
||||
) => void,
|
||||
onNavigateToExternalEventsEvent: (
|
||||
externalEventsName: string,
|
||||
eventPath: Array<number>
|
||||
) => void,
|
||||
|};
|
||||
|
||||
const addFor = (map, key, value) => {
|
||||
@@ -34,12 +230,63 @@ const addFor = (map, key, value) => {
|
||||
};
|
||||
|
||||
export default function DiagnosticReportDialog({
|
||||
project,
|
||||
wholeProjectDiagnosticReport,
|
||||
onClose,
|
||||
onNavigateToLayoutEvent,
|
||||
onNavigateToExternalEventsEvent,
|
||||
}: Props) {
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
const preferences = React.useContext(PreferencesContext);
|
||||
|
||||
// Scan project for validation errors (missing instructions, invalid parameters)
|
||||
const validationErrors = React.useMemo(
|
||||
() => {
|
||||
try {
|
||||
return scanProjectForValidationErrors(project);
|
||||
} catch (error) {
|
||||
console.error('Error scanning project for validation errors:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[project]
|
||||
);
|
||||
|
||||
const groupedErrors = React.useMemo(
|
||||
() => groupValidationErrors(validationErrors),
|
||||
[validationErrors]
|
||||
);
|
||||
|
||||
const missingInstructionsCount = validationErrors.filter(
|
||||
e => e.type === 'missing-instruction'
|
||||
).length;
|
||||
const invalidParametersCount = validationErrors.filter(
|
||||
e => e.type !== 'missing-instruction'
|
||||
).length;
|
||||
const hasMissingInstructions = missingInstructionsCount > 0;
|
||||
const hasInvalidParameters = invalidParametersCount > 0;
|
||||
const hasValidationErrors = hasMissingInstructions || hasInvalidParameters;
|
||||
|
||||
const navigateToError = React.useCallback(
|
||||
(error: ValidationError) => {
|
||||
onClose();
|
||||
if (error.locationType === 'scene') {
|
||||
onNavigateToLayoutEvent(error.locationName, error.eventPath);
|
||||
} else if (error.locationType === 'external-events') {
|
||||
onNavigateToExternalEventsEvent(error.locationName, error.eventPath);
|
||||
}
|
||||
},
|
||||
[onClose, onNavigateToLayoutEvent, onNavigateToExternalEventsEvent]
|
||||
);
|
||||
|
||||
const renderMissingInstructionName = (type: string) => {
|
||||
const { name, behaviorName } = getFunctionNameFromType(type);
|
||||
if (behaviorName) {
|
||||
return `${name} (${behaviorName})`;
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
const renderDiagnosticReport = React.useCallback(
|
||||
(diagnosticReport: gdDiagnosticReport) => {
|
||||
// TODO Generalize error aggregation when enough errors are handled to have a clearer view.
|
||||
@@ -224,6 +471,9 @@ export default function DiagnosticReportDialog({
|
||||
[gdevelopTheme.list.itemsBackgroundColor]
|
||||
);
|
||||
|
||||
const hasNativeReport = wholeProjectDiagnosticReport.hasAnyIssue();
|
||||
const hasAnyIssue = hasNativeReport || hasValidationErrors;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id="diagnostic-report-dialog"
|
||||
@@ -251,9 +501,116 @@ export default function DiagnosticReportDialog({
|
||||
onRequestClose={onClose}
|
||||
onApply={onClose}
|
||||
open
|
||||
maxWidth="sm"
|
||||
maxWidth="md"
|
||||
>
|
||||
<ColumnStackLayout noMargin useLargeSpacer>
|
||||
{!hasAnyIssue && (
|
||||
<AlertMessage kind="info">
|
||||
<Trans>No issues found in your project.</Trans>
|
||||
</AlertMessage>
|
||||
)}
|
||||
|
||||
{/* Missing instructions from extensions */}
|
||||
{hasMissingInstructions && (
|
||||
<ColumnStackLayout noMargin>
|
||||
<Text size="block-title">
|
||||
<Trans>
|
||||
Missing actions/conditions/expressions (
|
||||
{missingInstructionsCount})
|
||||
</Trans>
|
||||
</Text>
|
||||
<AlertMessage kind="warning">
|
||||
<Trans>
|
||||
The following actions, conditions, or expressions no longer
|
||||
exist in their extensions. This can happen when an extension's
|
||||
API has changed or when functionality has been removed. Update
|
||||
or remove these instructions.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderColumn>
|
||||
<Trans>Extension</Trans>
|
||||
</TableHeaderColumn>
|
||||
<TableHeaderColumn>
|
||||
<Trans>Missing instructions</Trans>
|
||||
</TableHeaderColumn>
|
||||
<TableHeaderColumn>
|
||||
<Trans>Location</Trans>
|
||||
</TableHeaderColumn>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...groupedErrors.missingInstructions.entries()].map(
|
||||
([extensionName, errors]) => (
|
||||
<TableRow
|
||||
key={`missing-ext-${extensionName}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
gdevelopTheme.list.itemsBackgroundColor,
|
||||
}}
|
||||
>
|
||||
<TableRowColumn>
|
||||
<Text size="body" color="error">
|
||||
{extensionName}
|
||||
</Text>
|
||||
</TableRowColumn>
|
||||
<TableRowColumn>
|
||||
<Text size="body" allowSelection>
|
||||
{[
|
||||
...new Set(
|
||||
errors.map(e =>
|
||||
renderMissingInstructionName(e.instructionType)
|
||||
)
|
||||
),
|
||||
].join(', ')}
|
||||
</Text>
|
||||
</TableRowColumn>
|
||||
<TableRowColumn>
|
||||
{[
|
||||
...new Set(
|
||||
errors.map(
|
||||
e => `${e.locationType}: ${e.locationName}`
|
||||
)
|
||||
),
|
||||
].map(location => {
|
||||
const error = errors.find(
|
||||
e =>
|
||||
`${e.locationType}: ${e.locationName}` ===
|
||||
location
|
||||
);
|
||||
return (
|
||||
<LineStackLayout key={location} noMargin>
|
||||
<Link
|
||||
href="#"
|
||||
onClick={() => error && navigateToError(error)}
|
||||
>
|
||||
{location}
|
||||
</Link>
|
||||
</LineStackLayout>
|
||||
);
|
||||
})}
|
||||
</TableRowColumn>
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ColumnStackLayout>
|
||||
)}
|
||||
|
||||
{/* Invalid parameters */}
|
||||
{hasInvalidParameters && (
|
||||
<InvalidParametersSection
|
||||
validationErrors={validationErrors}
|
||||
invalidParametersCount={invalidParametersCount}
|
||||
navigateToError={navigateToError}
|
||||
gdevelopTheme={gdevelopTheme}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Native diagnostic report (from C++ code) */}
|
||||
{mapFor(0, wholeProjectDiagnosticReport.count(), index => {
|
||||
const diagnosticReport = wholeProjectDiagnosticReport.get(index);
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ const defaultShortcuts: ShortcutMap = {
|
||||
LAUNCH_DEBUG_PREVIEW: 'F6',
|
||||
HOT_RELOAD_PREVIEW: 'F5',
|
||||
LAUNCH_NETWORK_PREVIEW: 'F8',
|
||||
OPEN_DIAGNOSTIC_REPORT: 'F7',
|
||||
OPEN_HOME_PAGE: '',
|
||||
CREATE_NEW_PROJECT: 'CmdOrCtrl+Alt+KeyN',
|
||||
OPEN_PROJECT: 'CmdOrCtrl+KeyO',
|
||||
|
||||
@@ -63,6 +63,10 @@ export class EventsEditorContainer extends React.Component<RenderEditorContainer
|
||||
}
|
||||
}
|
||||
|
||||
scrollToEventPath(eventPath: Array<number>) {
|
||||
if (this.editor) this.editor.scrollToEventPath(eventPath);
|
||||
}
|
||||
|
||||
forceUpdateEditor() {
|
||||
// No updates to be done.
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ export class ExternalEventsEditorContainer extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
scrollToEventPath(eventPath: Array<number>) {
|
||||
if (this.editor) this.editor.scrollToEventPath(eventPath);
|
||||
}
|
||||
|
||||
forceUpdateEditor() {
|
||||
// No updates to be done.
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ type CommandHandlers = {|
|
||||
onLaunchNetworkPreview: () => Promise<void>,
|
||||
onHotReloadPreview: () => Promise<void>,
|
||||
onLaunchPreviewWithDiagnosticReport: () => Promise<void>,
|
||||
onOpenDiagnosticReport: () => void,
|
||||
allowNetworkPreview: boolean,
|
||||
onOpenHomePage: () => void,
|
||||
onCreateProject: () => void,
|
||||
@@ -112,6 +113,10 @@ const useMainFrameCommands = (handlers: CommandHandlers) => {
|
||||
}
|
||||
);
|
||||
|
||||
useCommand('OPEN_DIAGNOSTIC_REPORT', !!handlers.project, {
|
||||
handler: handlers.onOpenDiagnosticReport,
|
||||
});
|
||||
|
||||
useCommand('OPEN_HOME_PAGE', true, {
|
||||
handler: handlers.onOpenHomePage,
|
||||
});
|
||||
|
||||
@@ -214,6 +214,7 @@ export type PreferencesValues = {|
|
||||
| 'icon'
|
||||
| 'icon-and-deprecated-warning-text',
|
||||
openDiagnosticReportAutomatically: boolean,
|
||||
blockPreviewAndExportOnDiagnosticErrors: boolean,
|
||||
use3DEditor: boolean,
|
||||
showBasicProfilingCounters: boolean,
|
||||
inAppTutorialsProgress: InAppTutorialProgressDatabase,
|
||||
@@ -312,6 +313,8 @@ export type Preferences = {|
|
||||
setShowInAppTutorialDeveloperMode: (enabled: boolean) => void,
|
||||
setOpenDiagnosticReportAutomatically: (enabled: boolean) => void,
|
||||
getOpenDiagnosticReportAutomatically: () => boolean,
|
||||
setBlockPreviewAndExportOnDiagnosticErrors: (enabled: boolean) => void,
|
||||
getBlockPreviewAndExportOnDiagnosticErrors: () => boolean,
|
||||
setShowDeprecatedInstructionWarning: (
|
||||
value: 'no' | 'icon' | 'icon-and-deprecated-warning-text'
|
||||
) => void,
|
||||
@@ -402,6 +405,7 @@ export const initialPreferences = {
|
||||
showCreateSectionByDefault: false,
|
||||
showInAppTutorialDeveloperMode: false,
|
||||
openDiagnosticReportAutomatically: true,
|
||||
blockPreviewAndExportOnDiagnosticErrors: false,
|
||||
showDeprecatedInstructionWarning: 'no',
|
||||
use3DEditor: isWebGLSupported(),
|
||||
showBasicProfilingCounters: false,
|
||||
@@ -481,6 +485,8 @@ export const initialPreferences = {
|
||||
) => {},
|
||||
getOpenDiagnosticReportAutomatically: () => true,
|
||||
setOpenDiagnosticReportAutomatically: (enabled: boolean) => {},
|
||||
getBlockPreviewAndExportOnDiagnosticErrors: () => false,
|
||||
setBlockPreviewAndExportOnDiagnosticErrors: (enabled: boolean) => {},
|
||||
getShowDeprecatedInstructionWarning: () => 'no',
|
||||
setUse3DEditor: (enabled: boolean) => {},
|
||||
getUse3DEditor: () => false,
|
||||
|
||||
@@ -73,6 +73,7 @@ const PreferencesDialog = ({
|
||||
setShowExperimentalExtensions,
|
||||
setShowInAppTutorialDeveloperMode,
|
||||
setOpenDiagnosticReportAutomatically,
|
||||
setBlockPreviewAndExportOnDiagnosticErrors,
|
||||
setShowDeprecatedInstructionWarning,
|
||||
setUse3DEditor,
|
||||
setShowBasicProfilingCounters,
|
||||
@@ -444,6 +445,15 @@ const PreferencesDialog = ({
|
||||
t`Automatically open the diagnostic report at preview`
|
||||
)}
|
||||
/>
|
||||
<CompactToggleField
|
||||
labelColor="primary"
|
||||
hideTooltip
|
||||
onCheck={setBlockPreviewAndExportOnDiagnosticErrors}
|
||||
checked={values.blockPreviewAndExportOnDiagnosticErrors}
|
||||
label={i18n._(
|
||||
t`Block preview and export when diagnostic errors are found`
|
||||
)}
|
||||
/>
|
||||
<CompactToggleField
|
||||
labelColor="primary"
|
||||
hideTooltip
|
||||
|
||||
@@ -171,6 +171,12 @@ export default class PreferencesProvider extends React.Component<Props, State> {
|
||||
getOpenDiagnosticReportAutomatically: this._getOpenDiagnosticReportAutomatically.bind(
|
||||
this
|
||||
),
|
||||
setBlockPreviewAndExportOnDiagnosticErrors: this._setBlockPreviewAndExportOnDiagnosticErrors.bind(
|
||||
this
|
||||
),
|
||||
getBlockPreviewAndExportOnDiagnosticErrors: this._getBlockPreviewAndExportOnDiagnosticErrors.bind(
|
||||
this
|
||||
),
|
||||
setShowDeprecatedInstructionWarning: this._setShowDeprecatedInstructionWarning.bind(
|
||||
this
|
||||
),
|
||||
@@ -515,6 +521,24 @@ export default class PreferencesProvider extends React.Component<Props, State> {
|
||||
return this.state.values.openDiagnosticReportAutomatically;
|
||||
}
|
||||
|
||||
_setBlockPreviewAndExportOnDiagnosticErrors(
|
||||
blockPreviewAndExportOnDiagnosticErrors: boolean
|
||||
) {
|
||||
this.setState(
|
||||
state => ({
|
||||
values: {
|
||||
...state.values,
|
||||
blockPreviewAndExportOnDiagnosticErrors,
|
||||
},
|
||||
}),
|
||||
() => this._persistValuesToLocalStorage(this.state)
|
||||
);
|
||||
}
|
||||
|
||||
_getBlockPreviewAndExportOnDiagnosticErrors() {
|
||||
return this.state.values.blockPreviewAndExportOnDiagnosticErrors;
|
||||
}
|
||||
|
||||
_setShowDeprecatedInstructionWarning(
|
||||
showDeprecatedInstructionWarning:
|
||||
| 'no'
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { type EditorTabsState } from './EditorTabs/EditorTabsHandler';
|
||||
|
||||
export type EventNavigationTarget = {|
|
||||
name: string,
|
||||
locationType: 'layout' | 'external-events',
|
||||
eventPath: Array<number>,
|
||||
|};
|
||||
|
||||
type UseNavigationToEventProps = {|
|
||||
editorTabs: EditorTabsState,
|
||||
|};
|
||||
|
||||
type UseNavigationToEventResult = {|
|
||||
setPendingEventNavigation: (target: EventNavigationTarget | null) => void,
|
||||
|};
|
||||
|
||||
const EDITOR_MOUNT_DELAY_MS = 300;
|
||||
|
||||
/**
|
||||
* Hook to handle navigation to a specific event in an events editor.
|
||||
* Sets up a pending navigation that will scroll to the event once the editor is mounted.
|
||||
*/
|
||||
export const useNavigationToEvent = ({
|
||||
editorTabs,
|
||||
}: UseNavigationToEventProps): UseNavigationToEventResult => {
|
||||
const [
|
||||
pendingEventNavigation,
|
||||
setPendingEventNavigation,
|
||||
] = React.useState<?EventNavigationTarget>(null);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (!pendingEventNavigation) return;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
const { name, locationType, eventPath } = pendingEventNavigation;
|
||||
const editorKind =
|
||||
locationType === 'layout' ? 'layout events' : 'external events';
|
||||
|
||||
for (const paneIdentifier in editorTabs.panes) {
|
||||
const pane = editorTabs.panes[paneIdentifier];
|
||||
for (const editor of pane.editors) {
|
||||
if (
|
||||
editor.kind === editorKind &&
|
||||
editor.projectItemName === name &&
|
||||
editor.editorRef &&
|
||||
editor.editorRef.scrollToEventPath
|
||||
) {
|
||||
// $FlowFixMe - scrollToEventPath is checked above
|
||||
editor.editorRef.scrollToEventPath(eventPath);
|
||||
setPendingEventNavigation(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPendingEventNavigation(null);
|
||||
}, EDITOR_MOUNT_DELAY_MS);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
},
|
||||
[pendingEventNavigation, editorTabs]
|
||||
);
|
||||
|
||||
return { setPendingEventNavigation };
|
||||
};
|
||||
@@ -192,6 +192,7 @@ import { type CourseChapter } from '../Utils/GDevelopServices/Asset';
|
||||
import useVersionHistory from '../VersionHistory/UseVersionHistory';
|
||||
import { ProjectManagerDrawer } from '../ProjectManager/ProjectManagerDrawer';
|
||||
import DiagnosticReportDialog from '../ExportAndShare/DiagnosticReportDialog';
|
||||
import { scanProjectForValidationErrors } from '../Utils/EventsValidationScanner';
|
||||
import useSaveReminder from './UseSaveReminder';
|
||||
import { useMultiplayerLobbyConfigurator } from './UseMultiplayerLobbyConfigurator';
|
||||
import { useAuthenticatedPlayer } from './UseAuthenticatedPlayer';
|
||||
@@ -212,6 +213,7 @@ import {
|
||||
isEditorHotReloadNeeded,
|
||||
} from '../EmbeddedGame/EmbeddedGameFrame';
|
||||
import useHomePageSwitch from './useHomePageSwitch';
|
||||
import { useNavigationToEvent } from './UseNavigationToEvent';
|
||||
import RobotIcon from '../ProjectCreation/RobotIcon';
|
||||
import PublicProfileContext from '../Profile/PublicProfileContext';
|
||||
import { useGamesPlatformFrame } from './EditorContainers/HomePage/PlaySection/UseGamesPlatformFrame';
|
||||
@@ -466,6 +468,57 @@ const MainFrame = (props: Props) => {
|
||||
const preferences = React.useContext(PreferencesContext);
|
||||
const { setHasProjectOpened } = preferences;
|
||||
const { previewLoadingRef, setPreviewLoading } = usePreviewLoadingState();
|
||||
const shortcutMap = useShortcutMap();
|
||||
const [
|
||||
diagnosticReportDialogOpen,
|
||||
setDiagnosticReportDialogOpen,
|
||||
] = React.useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Checks for diagnostic errors in the project if blocking is enabled.
|
||||
* Returns true if there are errors and the action should be blocked.
|
||||
*/
|
||||
const checkDiagnosticErrorsAndIfShouldBlock = React.useCallback(
|
||||
async (
|
||||
project: ?gdProject,
|
||||
actionType: 'preview' | 'export'
|
||||
): Promise<boolean> => {
|
||||
if (
|
||||
!project ||
|
||||
!preferences.getBlockPreviewAndExportOnDiagnosticErrors()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const validationErrors = scanProjectForValidationErrors(project);
|
||||
if (validationErrors.length > 0) {
|
||||
const openReport = await showConfirmation({
|
||||
title: t`Diagnostic errors found`,
|
||||
message:
|
||||
actionType === 'preview'
|
||||
? t`Your project has ${
|
||||
validationErrors.length
|
||||
} diagnostic error(s). Please fix them before launching a preview.`
|
||||
: t`Your project has ${
|
||||
validationErrors.length
|
||||
} diagnostic error(s). Please fix them before exporting.`,
|
||||
dismissButtonLabel: t`Close`,
|
||||
confirmButtonLabel: t`Open report`,
|
||||
});
|
||||
if (openReport) {
|
||||
setDiagnosticReportDialogOpen(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error scanning project for validation errors:', error);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[preferences, showConfirmation, setDiagnosticReportDialogOpen]
|
||||
);
|
||||
const [previewState, setPreviewState] = React.useState(initialPreviewState);
|
||||
const commandPaletteRef = React.useRef((null: ?CommandPaletteInterface));
|
||||
const inAppTutorialOrchestratorRef = React.useRef<?InAppTutorialOrchestratorInterface>(
|
||||
@@ -535,10 +588,9 @@ const MainFrame = (props: Props) => {
|
||||
quitInAppTutorialDialogOpen,
|
||||
setQuitInAppTutorialDialogOpen,
|
||||
] = React.useState<boolean>(false);
|
||||
const [
|
||||
diagnosticReportDialogOpen,
|
||||
setDiagnosticReportDialogOpen,
|
||||
] = React.useState<boolean>(false);
|
||||
const { setPendingEventNavigation } = useNavigationToEvent({
|
||||
editorTabs: state.editorTabs,
|
||||
});
|
||||
const [
|
||||
fileMetadataOpeningProgress,
|
||||
setFileMetadataOpeningProgress,
|
||||
@@ -807,13 +859,19 @@ const MainFrame = (props: Props) => {
|
||||
);
|
||||
|
||||
const openShareDialog = React.useCallback(
|
||||
(initialTab?: ShareTab) => {
|
||||
async (initialTab?: ShareTab) => {
|
||||
if (
|
||||
await checkDiagnosticErrorsAndIfShouldBlock(currentProject, 'export')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifyPreviewOrExportWillStart(state.editorTabs);
|
||||
|
||||
setShareDialogInitialTab(initialTab || null);
|
||||
setShareDialogOpen(true);
|
||||
},
|
||||
[state.editorTabs]
|
||||
[state.editorTabs, currentProject, checkDiagnosticErrorsAndIfShouldBlock]
|
||||
);
|
||||
|
||||
const closeShareDialog = React.useCallback(
|
||||
@@ -2183,6 +2241,12 @@ const MainFrame = (props: Props) => {
|
||||
if (!currentProject) return;
|
||||
if (currentProject.getLayoutsCount() === 0) return;
|
||||
|
||||
if (
|
||||
await checkDiagnosticErrorsAndIfShouldBlock(currentProject, 'preview')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`Launching a new ${
|
||||
isForInGameEdition ? 'in-game edition preview' : 'preview'
|
||||
@@ -2400,6 +2464,7 @@ const MainFrame = (props: Props) => {
|
||||
inGameEditorSettings,
|
||||
previewLoadingRef,
|
||||
setPreviewLoading,
|
||||
checkDiagnosticErrorsAndIfShouldBlock,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -4644,6 +4709,7 @@ const MainFrame = (props: Props) => {
|
||||
onLaunchDebugPreview: launchDebuggerAndPreview,
|
||||
onLaunchNetworkPreview: launchNetworkPreview,
|
||||
onLaunchPreviewWithDiagnosticReport: launchPreviewWithDiagnosticReport,
|
||||
onOpenDiagnosticReport: () => setDiagnosticReportDialogOpen(true),
|
||||
onOpenHomePage: openHomePage,
|
||||
onCreateProject: () => setNewProjectSetupDialogOpen(true),
|
||||
onOpenProject: () => openOpenFromStorageProviderDialog(),
|
||||
@@ -4653,8 +4719,12 @@ const MainFrame = (props: Props) => {
|
||||
onCloseProject: async () => {
|
||||
askToCloseProject();
|
||||
},
|
||||
onExportGame: () => openShareDialog('publish'),
|
||||
onInviteCollaborators: () => openShareDialog('invite'),
|
||||
onExportGame: () => {
|
||||
openShareDialog('publish');
|
||||
},
|
||||
onInviteCollaborators: () => {
|
||||
openShareDialog('invite');
|
||||
},
|
||||
onOpenLayout: name => {
|
||||
openLayout(name);
|
||||
},
|
||||
@@ -4737,7 +4807,6 @@ const MainFrame = (props: Props) => {
|
||||
previewLoading === 'hot-reload-for-in-game-edition';
|
||||
const showLoaderImmediately =
|
||||
isProjectOpening || isLoadingProject || previewLoading === 'preview';
|
||||
const shortcutMap = useShortcutMap();
|
||||
|
||||
const buildMainMenuProps = {
|
||||
i18n: i18n,
|
||||
@@ -4756,8 +4825,12 @@ const MainFrame = (props: Props) => {
|
||||
onShowVersionHistory: openVersionHistoryPanel,
|
||||
onCloseProject: askToCloseProject,
|
||||
onCloseApp: closeApp,
|
||||
onExportProject: () => openShareDialog('publish'),
|
||||
onInviteCollaborators: () => openShareDialog('invite'),
|
||||
onExportProject: () => {
|
||||
openShareDialog('publish');
|
||||
},
|
||||
onInviteCollaborators: () => {
|
||||
openShareDialog('invite');
|
||||
},
|
||||
onCreateProject: () => setNewProjectSetupDialogOpen(true),
|
||||
onOpenProjectManager: () => openProjectManager(true),
|
||||
onOpenHomePage: openHomePage,
|
||||
@@ -4800,7 +4873,9 @@ const MainFrame = (props: Props) => {
|
||||
saveProjectAsWithStorageProvider: saveProjectAsWithStorageProvider,
|
||||
onCheckoutVersion: onCheckoutVersion,
|
||||
getOrLoadProjectVersion: getOrLoadProjectVersion,
|
||||
openShareDialog: openShareDialog,
|
||||
openShareDialog: tab => {
|
||||
openShareDialog(tab);
|
||||
},
|
||||
launchDebuggerAndPreview: launchDebuggerAndPreview,
|
||||
launchNewPreview: launchNewPreview,
|
||||
launchNetworkPreview: launchNetworkPreview,
|
||||
@@ -4898,7 +4973,9 @@ const MainFrame = (props: Props) => {
|
||||
sourceGameId: quickCustomizationDialogOpenedFromGameId || '',
|
||||
getIncludeFileHashs:
|
||||
eventsFunctionsExtensionsContext.getIncludeFileHashs,
|
||||
onExport: () => openShareDialog('publish'),
|
||||
onExport: () => {
|
||||
openShareDialog('publish');
|
||||
},
|
||||
onCaptureFinished,
|
||||
},
|
||||
(previewLauncher: ?PreviewLauncherInterface) => {
|
||||
@@ -4959,7 +5036,9 @@ const MainFrame = (props: Props) => {
|
||||
onExtensionInstalled={onExtensionInstalled}
|
||||
onSceneAdded={onSceneAdded}
|
||||
onExternalLayoutAdded={onExternalLayoutAdded}
|
||||
onShareProject={() => openShareDialog()}
|
||||
onShareProject={() => {
|
||||
openShareDialog();
|
||||
}}
|
||||
isOpen={projectManagerOpen}
|
||||
hotReloadPreviewButtonProps={hotReloadPreviewButtonProps}
|
||||
resourceManagementProps={resourceManagementProps}
|
||||
@@ -5265,8 +5344,29 @@ const MainFrame = (props: Props) => {
|
||||
)}
|
||||
{diagnosticReportDialogOpen && currentProject && (
|
||||
<DiagnosticReportDialog
|
||||
project={currentProject}
|
||||
wholeProjectDiagnosticReport={currentProject.getWholeProjectDiagnosticReport()}
|
||||
onClose={() => setDiagnosticReportDialogOpen(false)}
|
||||
onNavigateToLayoutEvent={(layoutName, eventPath) => {
|
||||
setPendingEventNavigation({
|
||||
name: layoutName,
|
||||
locationType: 'layout',
|
||||
eventPath,
|
||||
});
|
||||
openLayout(layoutName, {
|
||||
openEventsEditor: true,
|
||||
openSceneEditor: false,
|
||||
focusWhenOpened: 'events',
|
||||
});
|
||||
}}
|
||||
onNavigateToExternalEventsEvent={(externalEventsName, eventPath) => {
|
||||
setPendingEventNavigation({
|
||||
name: externalEventsName,
|
||||
locationType: 'external-events',
|
||||
eventPath,
|
||||
});
|
||||
openExternalEvents(externalEventsName);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{standaloneDialogOpen && (
|
||||
|
||||
@@ -15,11 +15,17 @@ type TableCellCommonProps = {|
|
||||
paddingRight?: number,
|
||||
textAlign?: string,
|
||||
wordBreak?: 'break-word',
|
||||
verticalAlign?: string,
|
||||
overflow?: string,
|
||||
|},
|
||||
|};
|
||||
|
||||
type TableProps = {|
|
||||
children: React.Node, // Should be TableHeader, TableBody or TableFooter
|
||||
style?: {|
|
||||
tableLayout?: string,
|
||||
width?: string | number,
|
||||
|},
|
||||
|};
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,7 @@ const allowedPreferenceKeys: $ReadOnlyArray<
|
||||
'showInAppTutorialDeveloperMode',
|
||||
'showDeprecatedInstructionWarning',
|
||||
'openDiagnosticReportAutomatically',
|
||||
'blockPreviewAndExportOnDiagnosticErrors',
|
||||
'use3DEditor',
|
||||
'showBasicProfilingCounters',
|
||||
'newProjectsDefaultFolder',
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
// @flow
|
||||
// Scanner for validation errors in events (missing instructions, invalid parameters)
|
||||
import { mapFor } from './MapFor';
|
||||
import { getFunctionNameFromType } from '../EventsFunctionsExtensionsLoader';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
export type ValidationErrorType =
|
||||
| 'missing-instruction'
|
||||
| 'invalid-parameter'
|
||||
| 'missing-parameter';
|
||||
|
||||
export type ValidationError = {|
|
||||
type: ValidationErrorType,
|
||||
isCondition: boolean,
|
||||
instructionType: string,
|
||||
instructionSentence: string,
|
||||
parameterIndex?: number,
|
||||
parameterValue?: string,
|
||||
locationName: string,
|
||||
locationType: 'scene' | 'external-events' | 'extension',
|
||||
eventPath: Array<number>,
|
||||
|};
|
||||
|
||||
const getInstructionSentence = (
|
||||
instruction: gdInstruction,
|
||||
metadata: gdInstructionMetadata
|
||||
): string => {
|
||||
const formatter = gd.InstructionSentenceFormatter.get();
|
||||
const formattedTexts = formatter.getAsFormattedText(instruction, metadata);
|
||||
let sentence = '';
|
||||
mapFor(0, formattedTexts.size(), i => {
|
||||
sentence += formattedTexts.getString(i);
|
||||
});
|
||||
return sentence.trim() || instruction.getType();
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a map from event pointer to its path in the events list.
|
||||
* This allows us to track event paths when using the C++ worker.
|
||||
*/
|
||||
const buildEventPtrToPathMap = (
|
||||
eventsList: gdEventsList,
|
||||
parentPath: Array<number> = []
|
||||
): Map<number, Array<number>> => {
|
||||
const map = new Map<number, Array<number>>();
|
||||
mapFor(0, eventsList.getEventsCount(), index => {
|
||||
const event = eventsList.getEventAt(index);
|
||||
const currentPath = [...parentPath, index];
|
||||
// $FlowFixMe - ptr is a number identifying the C++ object
|
||||
map.set(event.ptr, currentPath);
|
||||
|
||||
if (event.canHaveSubEvents()) {
|
||||
const subEventsMap = buildEventPtrToPathMap(
|
||||
event.getSubEvents(),
|
||||
currentPath
|
||||
);
|
||||
subEventsMap.forEach((path, ptr) => map.set(ptr, path));
|
||||
}
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a validation worker that uses C++ event traversal.
|
||||
* This leverages ReadOnlyArbitraryEventsWorkerWithContext which properly
|
||||
* handles local variable scoping as it traverses the event tree.
|
||||
*/
|
||||
const createValidationWorker = (
|
||||
platform: gdPlatform,
|
||||
locationName: string,
|
||||
locationType: 'scene' | 'external-events' | 'extension',
|
||||
eventPtrToPathMap: Map<number, Array<number>>,
|
||||
errors: Array<ValidationError>
|
||||
): gdReadOnlyArbitraryEventsWorkerWithContextJS => {
|
||||
const worker = new gd.ReadOnlyArbitraryEventsWorkerWithContextJS();
|
||||
|
||||
let currentEventPath: Array<number> = [];
|
||||
|
||||
// $FlowFixMe - overriding C++ method:
|
||||
worker.doVisitEvent = (event: gdBaseEvent) => {
|
||||
const path = eventPtrToPathMap.get(event.ptr);
|
||||
if (path) {
|
||||
currentEventPath = path;
|
||||
}
|
||||
};
|
||||
|
||||
// $FlowFixMe - overriding C++ method:
|
||||
worker.doVisitInstruction = (
|
||||
instruction: gdInstruction,
|
||||
isCondition: boolean,
|
||||
projectScopedContainers: gdProjectScopedContainers
|
||||
) => {
|
||||
const type = instruction.getType();
|
||||
|
||||
// Skip empty instruction types
|
||||
if (!type || type.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get metadata
|
||||
const metadata = isCondition
|
||||
? gd.MetadataProvider.getConditionMetadata(gd.JsPlatform.get(), type)
|
||||
: gd.MetadataProvider.getActionMetadata(gd.JsPlatform.get(), type);
|
||||
|
||||
const isBad = gd.MetadataProvider.isBadInstructionMetadata(metadata);
|
||||
|
||||
// Check if instruction is missing (from uninstalled extension)
|
||||
if (isBad) {
|
||||
errors.push({
|
||||
type: 'missing-instruction',
|
||||
isCondition,
|
||||
instructionType: type,
|
||||
instructionSentence: type,
|
||||
locationName,
|
||||
locationType,
|
||||
eventPath: [...currentEventPath],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const instructionSentence = getInstructionSentence(instruction, metadata);
|
||||
|
||||
// Validate parameters
|
||||
const parametersCount = metadata.getParametersCount();
|
||||
mapFor(0, parametersCount, parameterIndex => {
|
||||
const parameterMetadata = metadata.getParameter(parameterIndex);
|
||||
const parameterType = parameterMetadata.getType();
|
||||
const value = instruction.getParameter(parameterIndex).getPlainString();
|
||||
|
||||
// Skip validation for layer parameter with empty value (default layer)
|
||||
if (parameterType === 'layer' && value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip codeOnly parameters
|
||||
if (parameterMetadata.isCodeOnly()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip optional parameters with empty values (they will use defaults)
|
||||
if (value === '' && parameterMetadata.isOptional()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip parameters with empty values that have default values
|
||||
if (value === '' && parameterMetadata.getDefaultValue() !== '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip yesorno parameters with empty values (they default to "no")
|
||||
if (parameterType === 'yesorno' && value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if parameter is valid using the projectScopedContainers
|
||||
// passed from C++, which includes local variables in scope
|
||||
const isValid = gd.InstructionValidator.isParameterValid(
|
||||
platform,
|
||||
projectScopedContainers,
|
||||
instruction,
|
||||
metadata,
|
||||
parameterIndex,
|
||||
value
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
errors.push({
|
||||
type: value === '' ? 'missing-parameter' : 'invalid-parameter',
|
||||
isCondition,
|
||||
instructionType: type,
|
||||
instructionSentence,
|
||||
parameterIndex,
|
||||
parameterValue: value,
|
||||
locationName,
|
||||
locationType,
|
||||
eventPath: [...currentEventPath],
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return worker;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scans the entire project for validation errors in events.
|
||||
* This includes missing instructions (from uninstalled extensions)
|
||||
* and invalid parameters.
|
||||
*/
|
||||
export const scanProjectForValidationErrors = (
|
||||
project: gdProject
|
||||
): Array<ValidationError> => {
|
||||
const errors: Array<ValidationError> = [];
|
||||
const platform = gd.JsPlatform.get();
|
||||
|
||||
// Scan all layouts (scenes)
|
||||
mapFor(0, project.getLayoutsCount(), index => {
|
||||
const layout = project.getLayoutAt(index);
|
||||
const locationName = layout.getName();
|
||||
|
||||
const projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProjectAndLayout(
|
||||
project,
|
||||
layout
|
||||
);
|
||||
|
||||
const eventPtrToPathMap = buildEventPtrToPathMap(layout.getEvents());
|
||||
const worker = createValidationWorker(
|
||||
platform,
|
||||
locationName,
|
||||
'scene',
|
||||
eventPtrToPathMap,
|
||||
errors
|
||||
);
|
||||
worker.launch(layout.getEvents(), projectScopedContainers);
|
||||
worker.delete();
|
||||
});
|
||||
|
||||
// Scan all external events
|
||||
mapFor(0, project.getExternalEventsCount(), index => {
|
||||
const externalEvents = project.getExternalEventsAt(index);
|
||||
const locationName = externalEvents.getName();
|
||||
|
||||
// External events are associated with a layout
|
||||
const associatedLayoutName = externalEvents.getAssociatedLayout();
|
||||
let projectScopedContainers;
|
||||
if (associatedLayoutName && project.hasLayoutNamed(associatedLayoutName)) {
|
||||
const layout = project.getLayout(associatedLayoutName);
|
||||
projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProjectAndLayout(
|
||||
project,
|
||||
layout
|
||||
);
|
||||
} else {
|
||||
projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProject(
|
||||
project
|
||||
);
|
||||
}
|
||||
|
||||
const eventPtrToPathMap = buildEventPtrToPathMap(
|
||||
externalEvents.getEvents()
|
||||
);
|
||||
const worker = createValidationWorker(
|
||||
platform,
|
||||
locationName,
|
||||
'external-events',
|
||||
eventPtrToPathMap,
|
||||
errors
|
||||
);
|
||||
worker.launch(externalEvents.getEvents(), projectScopedContainers);
|
||||
worker.delete();
|
||||
});
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export type GroupedValidationErrors = {|
|
||||
missingInstructions: Map<string, Array<ValidationError>>,
|
||||
invalidParameters: Map<string, Array<ValidationError>>,
|
||||
|};
|
||||
|
||||
/**
|
||||
* Finds an event by its path in the events list.
|
||||
* Returns null if the event cannot be found.
|
||||
*/
|
||||
export const findEventByPath = (
|
||||
eventsList: gdEventsList,
|
||||
path: Array<number>
|
||||
): ?gdBaseEvent => {
|
||||
if (path.length === 0) return null;
|
||||
|
||||
let currentEventsList = eventsList;
|
||||
let event: ?gdBaseEvent = null;
|
||||
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const index = path[i];
|
||||
if (index < 0 || index >= currentEventsList.getEventsCount()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
event = currentEventsList.getEventAt(index);
|
||||
|
||||
// If not at the last index, go to sub-events
|
||||
if (i < path.length - 1) {
|
||||
if (!event.canHaveSubEvents()) {
|
||||
return null;
|
||||
}
|
||||
currentEventsList = event.getSubEvents();
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups validation errors by type for display in the UI.
|
||||
*/
|
||||
export const groupValidationErrors = (
|
||||
errors: Array<ValidationError>
|
||||
): GroupedValidationErrors => {
|
||||
const missingInstructions = new Map<string, Array<ValidationError>>();
|
||||
const invalidParameters = new Map<string, Array<ValidationError>>();
|
||||
|
||||
for (const error of errors) {
|
||||
if (error.type === 'missing-instruction') {
|
||||
// Group by extension name
|
||||
const { extensionName } = getFunctionNameFromType(error.instructionType);
|
||||
const key = extensionName || 'Unknown';
|
||||
if (!missingInstructions.has(key)) {
|
||||
missingInstructions.set(key, []);
|
||||
}
|
||||
const missingList = missingInstructions.get(key);
|
||||
if (missingList) missingList.push(error);
|
||||
} else {
|
||||
// Group by location
|
||||
const key = `${error.locationType}: ${error.locationName}`;
|
||||
if (!invalidParameters.has(key)) {
|
||||
invalidParameters.set(key, []);
|
||||
}
|
||||
const invalidList = invalidParameters.get(key);
|
||||
if (invalidList) invalidList.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
return { missingInstructions, invalidParameters };
|
||||
};
|
||||
@@ -0,0 +1,465 @@
|
||||
// @flow
|
||||
import {
|
||||
scanProjectForValidationErrors,
|
||||
groupValidationErrors,
|
||||
findEventByPath,
|
||||
} from './EventsValidationScanner';
|
||||
import { makeTestProject } from '../fixtures/TestProject';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
describe('EventsValidationScanner', () => {
|
||||
describe('scanProjectForValidationErrors', () => {
|
||||
it('returns empty array for a project without events with errors', () => {
|
||||
const { project } = makeTestProject(gd);
|
||||
const errors = scanProjectForValidationErrors(project);
|
||||
// Test project has valid events, should have no or very few errors
|
||||
expect(Array.isArray(errors)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects missing instructions for invalid action types', () => {
|
||||
const { project, testLayout } = makeTestProject(gd);
|
||||
const events = testLayout.getEvents();
|
||||
|
||||
// Add an event with an invalid action type
|
||||
const event = events.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
0
|
||||
);
|
||||
const standardEvent = gd.asStandardEvent(event);
|
||||
const actions = standardEvent.getActions();
|
||||
const invalidAction = new gd.Instruction();
|
||||
invalidAction.setType('NonExistentExtension::NonExistentAction');
|
||||
actions.insert(invalidAction, 0);
|
||||
invalidAction.delete();
|
||||
|
||||
const errors = scanProjectForValidationErrors(project);
|
||||
|
||||
const missingInstructionErrors = errors.filter(
|
||||
e => e.type === 'missing-instruction'
|
||||
);
|
||||
expect(missingInstructionErrors.length).toBeGreaterThan(0);
|
||||
|
||||
const targetError = missingInstructionErrors.find(
|
||||
e => e.instructionType === 'NonExistentExtension::NonExistentAction'
|
||||
);
|
||||
expect(targetError).toBeDefined();
|
||||
if (targetError) {
|
||||
expect(targetError.isCondition).toBe(false);
|
||||
expect(targetError.locationType).toBe('scene');
|
||||
expect(targetError.locationName).toBe(testLayout.getName());
|
||||
}
|
||||
});
|
||||
|
||||
it('detects missing conditions for invalid condition types', () => {
|
||||
const { project, testLayout } = makeTestProject(gd);
|
||||
const events = testLayout.getEvents();
|
||||
|
||||
// Add an event with an invalid condition type
|
||||
const event = events.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
0
|
||||
);
|
||||
const standardEvent = gd.asStandardEvent(event);
|
||||
const conditions = standardEvent.getConditions();
|
||||
const invalidCondition = new gd.Instruction();
|
||||
invalidCondition.setType('NonExistentExtension::NonExistentCondition');
|
||||
conditions.insert(invalidCondition, 0);
|
||||
invalidCondition.delete();
|
||||
|
||||
const errors = scanProjectForValidationErrors(project);
|
||||
|
||||
const missingInstructionErrors = errors.filter(
|
||||
e =>
|
||||
e.type === 'missing-instruction' &&
|
||||
e.instructionType === 'NonExistentExtension::NonExistentCondition'
|
||||
);
|
||||
expect(missingInstructionErrors.length).toBeGreaterThan(0);
|
||||
|
||||
const targetError = missingInstructionErrors[0];
|
||||
expect(targetError.isCondition).toBe(true);
|
||||
});
|
||||
|
||||
it('includes eventPath for each error', () => {
|
||||
const { project, testLayout } = makeTestProject(gd);
|
||||
const events = testLayout.getEvents();
|
||||
|
||||
// Add an event with an invalid action
|
||||
const event = events.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
0
|
||||
);
|
||||
const standardEvent = gd.asStandardEvent(event);
|
||||
const actions = standardEvent.getActions();
|
||||
const invalidAction = new gd.Instruction();
|
||||
invalidAction.setType('Test::InvalidAction');
|
||||
actions.insert(invalidAction, 0);
|
||||
invalidAction.delete();
|
||||
|
||||
const errors = scanProjectForValidationErrors(project);
|
||||
|
||||
const targetError = errors.find(
|
||||
e => e.instructionType === 'Test::InvalidAction'
|
||||
);
|
||||
expect(targetError).toBeDefined();
|
||||
if (targetError) {
|
||||
expect(Array.isArray(targetError.eventPath)).toBe(true);
|
||||
expect(targetError.eventPath.length).toBeGreaterThan(0);
|
||||
expect(targetError.eventPath[0]).toBe(0); // First event
|
||||
}
|
||||
});
|
||||
|
||||
describe('external events scanning', () => {
|
||||
it('detects errors in external events', () => {
|
||||
const { project, testExternalEvents1 } = makeTestProject(gd);
|
||||
const events = testExternalEvents1.getEvents();
|
||||
|
||||
// Add an event with an invalid action
|
||||
const event = events.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
0
|
||||
);
|
||||
const standardEvent = gd.asStandardEvent(event);
|
||||
const actions = standardEvent.getActions();
|
||||
const invalidAction = new gd.Instruction();
|
||||
invalidAction.setType('External::InvalidAction');
|
||||
actions.insert(invalidAction, 0);
|
||||
invalidAction.delete();
|
||||
|
||||
const errors = scanProjectForValidationErrors(project);
|
||||
|
||||
const targetError = errors.find(
|
||||
e => e.instructionType === 'External::InvalidAction'
|
||||
);
|
||||
expect(targetError).toBeDefined();
|
||||
if (targetError) {
|
||||
expect(targetError.locationType).toBe('external-events');
|
||||
expect(targetError.locationName).toBe(testExternalEvents1.getName());
|
||||
expect(targetError.type).toBe('missing-instruction');
|
||||
}
|
||||
});
|
||||
|
||||
it('scans external events with associated layout context', () => {
|
||||
const { project, testExternalEvents1, testLayout } = makeTestProject(
|
||||
gd
|
||||
);
|
||||
// Associate external events with a layout
|
||||
testExternalEvents1.setAssociatedLayout(testLayout.getName());
|
||||
|
||||
const events = testExternalEvents1.getEvents();
|
||||
const event = events.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
0
|
||||
);
|
||||
const standardEvent = gd.asStandardEvent(event);
|
||||
const actions = standardEvent.getActions();
|
||||
const invalidAction = new gd.Instruction();
|
||||
invalidAction.setType('AssociatedLayout::InvalidAction');
|
||||
actions.insert(invalidAction, 0);
|
||||
invalidAction.delete();
|
||||
|
||||
const errors = scanProjectForValidationErrors(project);
|
||||
|
||||
const targetError = errors.find(
|
||||
e => e.instructionType === 'AssociatedLayout::InvalidAction'
|
||||
);
|
||||
expect(targetError).toBeDefined();
|
||||
if (targetError) {
|
||||
expect(targetError.locationType).toBe('external-events');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhileEvent scanning', () => {
|
||||
it('detects errors in WhileEvent conditions', () => {
|
||||
const { project, testLayout } = makeTestProject(gd);
|
||||
const events = testLayout.getEvents();
|
||||
|
||||
const event = events.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::While',
|
||||
0
|
||||
);
|
||||
const whileEvent = gd.asWhileEvent(event);
|
||||
const conditions = whileEvent.getConditions();
|
||||
const invalidCondition = new gd.Instruction();
|
||||
invalidCondition.setType('While::InvalidCondition');
|
||||
conditions.insert(invalidCondition, 0);
|
||||
invalidCondition.delete();
|
||||
|
||||
const errors = scanProjectForValidationErrors(project);
|
||||
|
||||
const targetError = errors.find(
|
||||
e => e.instructionType === 'While::InvalidCondition'
|
||||
);
|
||||
expect(targetError).toBeDefined();
|
||||
if (targetError) {
|
||||
expect(targetError.isCondition).toBe(true);
|
||||
expect(targetError.type).toBe('missing-instruction');
|
||||
}
|
||||
});
|
||||
|
||||
it('detects errors in WhileEvent while-conditions', () => {
|
||||
const { project, testLayout } = makeTestProject(gd);
|
||||
const events = testLayout.getEvents();
|
||||
|
||||
const event = events.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::While',
|
||||
0
|
||||
);
|
||||
const whileEvent = gd.asWhileEvent(event);
|
||||
const whileConditions = whileEvent.getWhileConditions();
|
||||
const invalidCondition = new gd.Instruction();
|
||||
invalidCondition.setType('While::InvalidWhileCondition');
|
||||
whileConditions.insert(invalidCondition, 0);
|
||||
invalidCondition.delete();
|
||||
|
||||
const errors = scanProjectForValidationErrors(project);
|
||||
|
||||
const targetError = errors.find(
|
||||
e => e.instructionType === 'While::InvalidWhileCondition'
|
||||
);
|
||||
expect(targetError).toBeDefined();
|
||||
if (targetError) {
|
||||
expect(targetError.isCondition).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('detects errors in WhileEvent actions', () => {
|
||||
const { project, testLayout } = makeTestProject(gd);
|
||||
const events = testLayout.getEvents();
|
||||
|
||||
const event = events.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::While',
|
||||
0
|
||||
);
|
||||
const whileEvent = gd.asWhileEvent(event);
|
||||
const actions = whileEvent.getActions();
|
||||
const invalidAction = new gd.Instruction();
|
||||
invalidAction.setType('While::InvalidAction');
|
||||
actions.insert(invalidAction, 0);
|
||||
invalidAction.delete();
|
||||
|
||||
const errors = scanProjectForValidationErrors(project);
|
||||
|
||||
const targetError = errors.find(
|
||||
e => e.instructionType === 'While::InvalidAction'
|
||||
);
|
||||
expect(targetError).toBeDefined();
|
||||
if (targetError) {
|
||||
expect(targetError.isCondition).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ForEachEvent scanning', () => {
|
||||
it('detects errors in ForEachEvent conditions', () => {
|
||||
const { project, testLayout } = makeTestProject(gd);
|
||||
const events = testLayout.getEvents();
|
||||
|
||||
const event = events.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::ForEach',
|
||||
0
|
||||
);
|
||||
const forEachEvent = gd.asForEachEvent(event);
|
||||
const conditions = forEachEvent.getConditions();
|
||||
const invalidCondition = new gd.Instruction();
|
||||
invalidCondition.setType('ForEach::InvalidCondition');
|
||||
conditions.insert(invalidCondition, 0);
|
||||
invalidCondition.delete();
|
||||
|
||||
const errors = scanProjectForValidationErrors(project);
|
||||
|
||||
const targetError = errors.find(
|
||||
e => e.instructionType === 'ForEach::InvalidCondition'
|
||||
);
|
||||
expect(targetError).toBeDefined();
|
||||
if (targetError) {
|
||||
expect(targetError.isCondition).toBe(true);
|
||||
expect(targetError.type).toBe('missing-instruction');
|
||||
}
|
||||
});
|
||||
|
||||
it('detects errors in ForEachEvent actions', () => {
|
||||
const { project, testLayout } = makeTestProject(gd);
|
||||
const events = testLayout.getEvents();
|
||||
|
||||
const event = events.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::ForEach',
|
||||
0
|
||||
);
|
||||
const forEachEvent = gd.asForEachEvent(event);
|
||||
const actions = forEachEvent.getActions();
|
||||
const invalidAction = new gd.Instruction();
|
||||
invalidAction.setType('ForEach::InvalidAction');
|
||||
actions.insert(invalidAction, 0);
|
||||
invalidAction.delete();
|
||||
|
||||
const errors = scanProjectForValidationErrors(project);
|
||||
|
||||
const targetError = errors.find(
|
||||
e => e.instructionType === 'ForEach::InvalidAction'
|
||||
);
|
||||
expect(targetError).toBeDefined();
|
||||
if (targetError) {
|
||||
expect(targetError.isCondition).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupValidationErrors', () => {
|
||||
it('groups missing instructions by extension name', () => {
|
||||
const errors = [
|
||||
{
|
||||
type: 'missing-instruction',
|
||||
isCondition: false,
|
||||
instructionType: 'ExtA::Action1',
|
||||
instructionSentence: 'Action 1',
|
||||
locationName: 'Scene1',
|
||||
locationType: 'scene',
|
||||
eventPath: [0],
|
||||
},
|
||||
{
|
||||
type: 'missing-instruction',
|
||||
isCondition: true,
|
||||
instructionType: 'ExtA::Condition1',
|
||||
instructionSentence: 'Condition 1',
|
||||
locationName: 'Scene1',
|
||||
locationType: 'scene',
|
||||
eventPath: [1],
|
||||
},
|
||||
{
|
||||
type: 'missing-instruction',
|
||||
isCondition: false,
|
||||
instructionType: 'ExtB::Action1',
|
||||
instructionSentence: 'Action B',
|
||||
locationName: 'Scene2',
|
||||
locationType: 'scene',
|
||||
eventPath: [0],
|
||||
},
|
||||
];
|
||||
|
||||
const grouped = groupValidationErrors(errors);
|
||||
|
||||
expect(grouped.missingInstructions.size).toBe(2);
|
||||
const extAErrors = grouped.missingInstructions.get('ExtA');
|
||||
const extBErrors = grouped.missingInstructions.get('ExtB');
|
||||
expect(extAErrors && extAErrors.length).toBe(2);
|
||||
expect(extBErrors && extBErrors.length).toBe(1);
|
||||
});
|
||||
|
||||
it('groups invalid parameters by location', () => {
|
||||
const errors = [
|
||||
{
|
||||
type: 'invalid-parameter',
|
||||
isCondition: false,
|
||||
instructionType: 'Action1',
|
||||
instructionSentence: 'Action 1',
|
||||
parameterIndex: 0,
|
||||
parameterValue: '',
|
||||
locationName: 'Scene1',
|
||||
locationType: 'scene',
|
||||
eventPath: [0],
|
||||
},
|
||||
{
|
||||
type: 'invalid-parameter',
|
||||
isCondition: false,
|
||||
instructionType: 'Action2',
|
||||
instructionSentence: 'Action 2',
|
||||
parameterIndex: 1,
|
||||
parameterValue: '',
|
||||
locationName: 'Scene1',
|
||||
locationType: 'scene',
|
||||
eventPath: [1],
|
||||
},
|
||||
{
|
||||
type: 'invalid-parameter',
|
||||
isCondition: false,
|
||||
instructionType: 'Action3',
|
||||
instructionSentence: 'Action 3',
|
||||
parameterIndex: 0,
|
||||
parameterValue: '',
|
||||
locationName: 'Events1',
|
||||
locationType: 'external-events',
|
||||
eventPath: [0],
|
||||
},
|
||||
];
|
||||
|
||||
const grouped = groupValidationErrors(errors);
|
||||
|
||||
expect(grouped.invalidParameters.size).toBe(2);
|
||||
const sceneErrors = grouped.invalidParameters.get('scene: Scene1');
|
||||
const extEventsErrors = grouped.invalidParameters.get(
|
||||
'external-events: Events1'
|
||||
);
|
||||
expect(sceneErrors && sceneErrors.length).toBe(2);
|
||||
expect(extEventsErrors && extEventsErrors.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findEventByPath', () => {
|
||||
it('returns null for empty path', () => {
|
||||
const { testLayout } = makeTestProject(gd);
|
||||
const events = testLayout.getEvents();
|
||||
const result = findEventByPath(events, []);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid index', () => {
|
||||
const { testLayout } = makeTestProject(gd);
|
||||
const events = testLayout.getEvents();
|
||||
const result = findEventByPath(events, [999]);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('finds event at root level', () => {
|
||||
const { project, testLayout } = makeTestProject(gd);
|
||||
const events = testLayout.getEvents();
|
||||
|
||||
// Add an event
|
||||
events.insertNewEvent(project, 'BuiltinCommonInstructions::Standard', 0);
|
||||
|
||||
const result = findEventByPath(events, [0]);
|
||||
expect(result).not.toBeNull();
|
||||
if (result) {
|
||||
expect(result.getType()).toBe('BuiltinCommonInstructions::Standard');
|
||||
}
|
||||
});
|
||||
|
||||
it('finds nested event', () => {
|
||||
const { project, testLayout } = makeTestProject(gd);
|
||||
const events = testLayout.getEvents();
|
||||
|
||||
// Add a parent event
|
||||
const parentEvent = events.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
0
|
||||
);
|
||||
|
||||
// Add a child event
|
||||
const subEvents = parentEvent.getSubEvents();
|
||||
subEvents.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::Comment',
|
||||
0
|
||||
);
|
||||
|
||||
const result = findEventByPath(events, [0, 0]);
|
||||
expect(result).not.toBeNull();
|
||||
if (result) {
|
||||
expect(result.getType()).toBe('BuiltinCommonInstructions::Comment');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
// Keep first as it creates the `global.gd` object:
|
||||
import { testProject } from '../../GDevelopJsInitializerDecorator';
|
||||
|
||||
import DiagnosticReportDialog from '../../../ExportAndShare/DiagnosticReportDialog';
|
||||
|
||||
export default {
|
||||
title: 'ExportAndShare/DiagnosticReportDialog',
|
||||
component: DiagnosticReportDialog,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the diagnostic report dialog with no errors.
|
||||
* The dialog displays a message indicating no issues were found.
|
||||
*/
|
||||
export const Empty = () => (
|
||||
<DiagnosticReportDialog
|
||||
project={testProject.project}
|
||||
wholeProjectDiagnosticReport={testProject.project.getWholeProjectDiagnosticReport()}
|
||||
onClose={action('onClose')}
|
||||
onNavigateToLayoutEvent={action('onNavigateToLayoutEvent')}
|
||||
onNavigateToExternalEventsEvent={action('onNavigateToExternalEventsEvent')}
|
||||
/>
|
||||
);
|
||||
Empty.storyName = 'Empty (No errors)';
|
||||
|
||||
/**
|
||||
* Shows the diagnostic report dialog with navigation callbacks.
|
||||
* When errors are found, clicking on them will trigger navigation to the
|
||||
* corresponding event in the layout or external events sheet.
|
||||
*/
|
||||
export const WithNavigationCallbacks = () => (
|
||||
<DiagnosticReportDialog
|
||||
project={testProject.project}
|
||||
wholeProjectDiagnosticReport={testProject.project.getWholeProjectDiagnosticReport()}
|
||||
onClose={action('onClose')}
|
||||
onNavigateToLayoutEvent={(layoutName, eventPath) => {
|
||||
action('onNavigateToLayoutEvent')({ layoutName, eventPath });
|
||||
}}
|
||||
onNavigateToExternalEventsEvent={(externalEventsName, eventPath) => {
|
||||
action('onNavigateToExternalEventsEvent')({
|
||||
externalEventsName,
|
||||
eventPath,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
WithNavigationCallbacks.storyName = 'With navigation callbacks';
|
||||
Reference in New Issue
Block a user