use super::*;
#[test]
fn test_early_bailout_no_derive_returns_unchanged() {
let source = r#"
class User {
name: string;
constructor(name: string) {
this.name = name;
}
}
"#;
let result = expand_test(source);
assert!(
!result.changed,
"Code without @derive should be returned unchanged"
);
assert!(result.diagnostics.is_empty(), "No diagnostics expected");
}
#[test]
fn test_early_bailout_svelte_runes_unchanged() {
let source = r#"
let count = $state(0);
let double = $derived(count * 2);
function increment() {
count++;
}
"#;
let result = expand_test_file(source, "Counter.svelte.ts");
assert!(
!result.changed,
"Svelte runes without @derive should be unchanged"
);
assert!(
result.diagnostics.is_empty(),
"No diagnostics for Svelte runes"
);
}
#[test]
fn test_early_bailout_svelte_props_unchanged() {
let source = r#"
interface Props {
name: string;
count?: number;
}
let { name, count = 0 }: Props = $props();
"#;
let result = expand_test_file(source, "Component.svelte.ts");
assert!(
!result.changed,
"Svelte $props() without @derive should be unchanged"
);
assert!(
result.diagnostics.is_empty(),
"No diagnostics for Svelte $props"
);
}
#[test]
fn test_early_bailout_complex_svelte_component_unchanged() {
let source = r#"
interface Props {
items: string[];
selected?: string;
}
let { items, selected = '' }: Props = $props();
let filteredItems = $derived(
items.filter(item => item.includes(selected))
);
let count = $state(0);
let message = $derived.by(() => {
if (count === 0) return 'No items';
return `${count} items`;
});
function handleClick() {
count++;
}
$effect(() => {
console.log('Count changed:', count);
});
"#;
let result = expand_test_file(source, "List.svelte.ts");
assert!(
!result.changed,
"Complex Svelte code without @derive should be unchanged"
);
assert!(result.diagnostics.is_empty(), "No diagnostics expected");
}
#[test]
fn test_with_derive_processes_normally() {
let source = r#"
/** @derive(Debug) */
class User {
name: string;
}
"#;
let result = expand_test(source);
assert!(
result.code.contains("toString"),
"Debug macro should generate toString"
);
assert!(result.changed, "Code with @derive should be modified");
}
#[test]
fn test_derive_in_string_literal_still_skipped() {
let source = r#"
const msg = "Use @derive to add methods";
class User {
name: string;
}
"#;
let result = expand_test(source);
assert!(
result
.diagnostics
.iter()
.all(|d| d.level != DiagnosticLevel::Error),
"No errors expected even with @derive in string literal"
);
}
#[test]
fn test_early_bailout_empty_file() {
let source = "";
let result = expand_test_file(source, "empty.ts");
assert!(!result.changed, "Empty file should be returned unchanged");
assert!(
result.diagnostics.is_empty(),
"No diagnostics for empty file"
);
}
#[test]
fn test_early_bailout_only_comments() {
let source = r#"
// This is a comment
/* Another comment */
/**
* JSDoc comment without derive
*/
"#;
let result = expand_test_file(source, "comments.ts");
assert!(
!result.changed,
"Comments-only file should be returned unchanged"
);
assert!(
result.diagnostics.is_empty(),
"No diagnostics for comments-only file"
);
}
#[test]
fn test_early_bailout_regular_typescript() {
let source = r#"
interface User {
id: string;
name: string;
email: string;
}
type Role = 'admin' | 'user' | 'guest';
enum Status {
Active,
Inactive,
Pending
}
function createUser(name: string): User {
return {
id: crypto.randomUUID(),
name,
email: `${name}@example.com`
};
}
const users: Map<string, User> = new Map();
export { User, Role, Status, createUser, users };
"#;
let result = expand_test_file(source, "types.ts");
assert!(
!result.changed,
"Regular TypeScript should be returned unchanged"
);
assert!(
result.diagnostics.is_empty(),
"No diagnostics for regular TypeScript"
);
}
#[test]
fn test_no_false_positive_derive_in_prose_jsdoc() {
let source = r#"
import type { Option } from "effect";
import { Exit } from "effect";
import type { Utc } from "effect/DateTime";
/** Deserialize result format from @derive(Deserialize) */
export type DeserializeResult<T> =
| { success: true; value: T }
| { success: false; errors: Array<{ field: string; message: string }> };
/** Converts a deserialize result to an Effect Exit */
export function toExit<T>(
result: DeserializeResult<T>,
): Exit.Exit<T, Array<{ field: string; message: string }>> {
if (result.success) {
return Exit.succeed(result.value);
} else {
return Exit.fail(result.errors);
}
}
/** Base interface for field controllers */
export interface FieldController<T> {
readonly path: ReadonlyArray<string | number>;
readonly name: string;
readonly constraints: Record<string, unknown>;
readonly label?: string;
readonly description?: string;
readonly placeholder?: string;
readonly disabled?: boolean;
readonly readonly?: boolean;
get(): T;
set(value: T): void;
/** Transform input value before setting (applies configured format like uppercase, trim, etc.) */
transform(value: T): T;
getError(): Option.Option<Array<string>>;
setError(value: Option.Option<Array<string>>): void;
getTainted(): Option.Option<boolean>;
setTainted(value: Option.Option<boolean>): void;
validate(): Array<string>;
}
/** Number field controller with numeric constraints */
export interface NumberFieldController extends FieldController<number | null> {
readonly min?: number;
readonly max?: number;
readonly step?: number;
}
/** Select field controller with options */
export interface SelectFieldController<T = string> extends FieldController<T> {
readonly options: ReadonlyArray<{ label: string; value: T }>;
}
/** Toggle/boolean field controller */
export interface ToggleFieldController extends FieldController<boolean> {
readonly styleClasses?: string;
}
/** Checkbox field controller */
export interface CheckboxFieldController extends FieldController<boolean> {
readonly styleClasses?: string;
}
/** Switch field controller */
export type SwitchFieldController = FieldController<boolean>;
/** Text area field controller */
export type TextAreaFieldController = FieldController<string | null>;
/** Radio group option */
export interface RadioGroupOption {
readonly label: string;
readonly value: string;
readonly icon?: unknown;
}
/** Radio group field controller */
export interface RadioGroupFieldController extends FieldController<string> {
readonly options: ReadonlyArray<RadioGroupOption>;
readonly orientation?: "horizontal" | "vertical";
}
/** Tags field controller (array of strings) */
export type TagsFieldController = FieldController<ReadonlyArray<string>>;
/** Combobox item type */
export interface ComboboxItem<T = unknown> {
readonly label: string;
readonly value: T;
}
/** Configuration for combobox fields that store graph edge objects */
export interface EdgeConfig {
/** Field path within the edge object that references the entity (e.g. "in") */
readonly entityField: string;
}
/** Combobox field controller */
export interface ComboboxFieldController<
T = string,
> extends FieldController<T | null> {
readonly items: ReadonlyArray<ComboboxItem<T>>;
readonly allowCustom?: boolean;
readonly roundedClass?: string;
/** URLs to fetch items from (populated by @comboboxController({ fetchUrls: [...] })) */
readonly fetchUrls?: ReadonlyArray<string>;
/** Key path to extract the display label from fetched items (default: "name") */
readonly itemLabelKeyName?: string;
/** Key path to extract the value from fetched items (default: "id") */
readonly itemValueKeyName?: string;
/** Edge configuration for fields that store graph edges instead of direct entities */
readonly edgeConfig?: EdgeConfig;
}
/** Combobox multiple field controller */
export interface ComboboxMultipleFieldController<
T = string,
> extends FieldController<ReadonlyArray<T>> {
readonly items: ReadonlyArray<ComboboxItem<T>>;
readonly allowCustom?: boolean;
readonly roundedClass?: string;
/** URLs to fetch items from (populated by @comboboxController({ fetchUrls: [...] })) */
readonly fetchUrls?: ReadonlyArray<string>;
/** Key path to extract the display label from fetched items (default: "name") */
readonly itemLabelKeyName?: string;
/** Key path to extract the value from fetched items (default: "id") */
readonly itemValueKeyName?: string;
/** Edge configuration for fields that store graph edges instead of direct entities */
readonly edgeConfig?: EdgeConfig;
}
/** Duration field controller - value is a [seconds, nanos] tuple */
export type DurationFieldController = FieldController<[number, number] | null>;
/** Date-time field controller */
export type DateTimeFieldController = FieldController<Utc | null>;
/** Date-only field controller (no time component) */
export type DateFieldController = FieldController<Utc | null>;
/** Multi-date picker field controller */
export type DatePickerMultipleFieldController = FieldController<Array<Utc> | null>;
/** Email field controller with subcontrollers */
export interface EmailFieldController extends FieldController<string | null> {
/** Controller for the email string input */
readonly emailController: FieldController<string | null>;
/** Controller for the "can email" toggle */
readonly canEmailController: FieldController<boolean>;
}
/** Phone field controller with subcontrollers */
export interface PhoneFieldController extends FieldController<unknown> {
readonly phoneTypeController: ComboboxFieldController<string>;
readonly numberController: FieldController<string | null>;
readonly canCallController: ToggleFieldController;
readonly canTextController: ToggleFieldController;
}
/** Enum/Variant field controller */
export interface EnumFieldController<
TVariant extends string = string,
TVariantControllers = { [K in TVariant]?: Record<string, FieldController<unknown>> },
> extends FieldController<{ type: TVariant; [key: string]: unknown } | null> {
readonly variants: Record<
TVariant,
{ label: string; fields?: Record<string, unknown> }
>;
readonly defaultVariant?: TVariant;
/** Derived variant detected from the current value (tag field or shape matching). */
readonly currentVariant: TVariant;
readonly legend?: string;
readonly selectLabel?: string;
readonly variantControllers?: TVariantControllers;
}
/** Base interface for array field controllers */
export interface ArrayFieldController<T> extends FieldController<ReadonlyArray<T>> {
at(index: number): FieldController<T>;
push(value: T): void;
remove(index: number): void;
swap(a: number, b: number): void;
}
/** Item state for array fieldset items */
export interface ArrayFieldsetItem<T> {
readonly _id: string;
readonly isLeaving: boolean;
readonly variant: "object" | "tuple";
readonly val?: [PropertyKey, T];
}
/** Combobox hydration config for array fieldsets */
export interface ArrayFieldsetComboboxConfig<T = unknown> {
readonly items: Array<{ label: string; value: T }>;
readonly setItems?: (items: Array<{ label: string; value: T }>) => void;
readonly itemLabelKeyName: string;
readonly itemValueKeyName: string;
readonly fetchConfigs: ReadonlyArray<{ url: string; schema?: unknown }>;
readonly skipInitialFetch?: boolean;
}
/** Array fieldset controller with element controllers */
export interface ArrayFieldsetController<
TItem,
TElementControllers extends Record<string, FieldController<unknown>> = Record<
string,
FieldController<unknown>
>,
> extends ArrayFieldController<TItem> {
/** Template structure for new items */
readonly itemStructure: TItem;
/** Legend text for the fieldset */
readonly legendText?: string;
/** Radio group configuration for "main" item selection */
readonly radioGroup?: {
readonly mainFieldKey: string;
};
/** Whether to display items in card style */
readonly card?: boolean;
/** Whether items can be reordered via drag-and-drop */
readonly reorderable?: boolean;
/** Combobox fetch configurations for items */
readonly comboboxFetchConfigs?: ReadonlyArray<ArrayFieldsetComboboxConfig>;
/** Create element controllers for a specific item */
elementControllers(context: {
index: number;
item: ArrayFieldsetItem<TItem>;
}): TElementControllers;
}
/** Base Gigaform interface - generated forms extend this */
export interface BaseGigaform<TData> {
data: TData;
validate(): Exit.Exit<TData, Array<{ field: string; message: string }>>;
asyncValidate(): Promise<
Exit.Exit<TData, ReadonlyArray<{ field: string; message: string }>>
>;
reset(overrides: Partial<TData> | null): void;
}
/** Gigaform with variant support (for unions/enums) */
export interface VariantGigaform<
TData,
TVariant extends string,
> extends BaseGigaform<TData> {
readonly currentVariant: TVariant;
switchVariant(variant: TVariant): void;
}
/** Manual entry controllers for site address fields */
export interface SiteManualEntryControllers {
readonly addressLine1: FieldController<string | null>;
readonly addressLine2: FieldController<string | null>;
readonly locality: FieldController<string | null>;
readonly administrativeAreaLevel1: FieldController<string | null>;
readonly postalCode: FieldController<string | null>;
readonly country: FieldController<string | null>;
}
/** Filter configuration for duplicate site search */
export interface SiteDuplicateSearchFilters {
readonly siteId?: string;
readonly filters: ReadonlyArray<{
field: string;
op: string;
value: unknown;
}>;
}
/** Address lookup field controller for Google Places integration */
export interface AddressLookupFieldController<
TSite = unknown,
> extends FieldController<TSite | null> {
/** Label background class for floating label */
readonly labelBgClass?: string;
}
/** Site fieldset controller with lookup and manual entry modes */
export interface SiteFieldsetController<
TSite = unknown,
> extends FieldController<TSite | null> {
/** Controller for Google Places address lookup */
readonly lookupController: AddressLookupFieldController<TSite>;
/** Controllers for manual address entry fields */
readonly manualEntryControllers: SiteManualEntryControllers;
/** Configuration for duplicate site search */
readonly duplicateSearchFilters?: SiteDuplicateSearchFilters;
/** Optional scrolling container getter for scroll position preservation */
readonly scrollingContainer?: () => HTMLElement | null;
}
/** Wrapper for nullable nested struct field controllers */
export interface NullableControllers<_T> {
isNull(): boolean;
initialize(): void;
clear(): void;
}
"#;
assert!(
!crate::expand_core::has_macro_annotations(source),
"has_macro_annotations should return false for a file with @derive only in prose JSDoc"
);
{
let result = expand_test(source);
assert!(
!result.changed,
"File with @derive in prose JSDoc should NOT trigger expansion. Got changed=true with code:\n{}",
result.code
);
let errors: Vec<_> = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.collect();
assert!(
errors.is_empty(),
"Should have no errors for a file with no macros, got: {:?}",
errors
);
}
}