Skip to main content

allframe_core/router/
ts_codegen.rs

1//! TypeScript client code generation from Router handler metadata
2//!
3//! Generates typed async functions that call AllFrame handlers via IPC,
4//! eliminating stringly-typed `allframe_call` invocations.
5//!
6//! # Example
7//!
8//! ```rust
9//! use allframe_core::router::{Router, TsField, TsType};
10//!
11//! let mut router = Router::new();
12//! router.register("get_user", || async { r#"{"id":1}"#.to_string() });
13//! router.describe_handler("get_user", vec![], TsType::Object(vec![
14//!     TsField::new("id", TsType::Number),
15//!     TsField::new("name", TsType::String),
16//! ]));
17//!
18//! let ts_code = router.generate_ts_client();
19//! assert!(ts_code.contains("export async function getUser()"));
20//! ```
21
22use std::collections::HashMap;
23use std::fmt::Write;
24
25/// TypeScript type representation
26#[derive(Debug, Clone, PartialEq)]
27pub enum TsType {
28    /// `string`
29    String,
30    /// `number`
31    Number,
32    /// `boolean`
33    Boolean,
34    /// `null`
35    Null,
36    /// `T | null`
37    Optional(Box<TsType>),
38    /// `T[]`
39    Array(Box<TsType>),
40    /// `{ field: Type, ... }`
41    Object(Vec<TsField>),
42    /// Named interface reference (e.g., `UserResponse`)
43    Named(String),
44    /// `void`
45    Void,
46    /// Raw TS type string (escape hatch)
47    Raw(String),
48}
49
50/// A field in a TS object/interface
51#[derive(Debug, Clone, PartialEq)]
52pub struct TsField {
53    /// Field name
54    pub name: String,
55    /// Field type
56    pub ty: TsType,
57    /// Whether the field is optional (renders as `name?: Type`)
58    pub optional: bool,
59}
60
61impl TsField {
62    /// Create a required field
63    pub fn new(name: &str, ty: TsType) -> Self {
64        Self {
65            name: name.to_string(),
66            ty,
67            optional: false,
68        }
69    }
70
71    /// Create an optional field
72    pub fn optional(name: &str, ty: TsType) -> Self {
73        Self {
74            name: name.to_string(),
75            ty,
76            optional: true,
77        }
78    }
79}
80
81/// Metadata describing a handler's argument and return types
82#[derive(Debug, Clone)]
83pub struct HandlerMeta {
84    /// Argument fields (empty = no args)
85    pub args: Vec<TsField>,
86    /// Return type
87    pub returns: TsType,
88    /// Whether this is a streaming handler
89    pub streaming: bool,
90    /// For streaming handlers: the type of each stream item
91    pub stream_item: Option<TsType>,
92}
93
94impl HandlerMeta {
95    /// Create metadata for a request/response handler
96    pub fn new(args: Vec<TsField>, returns: TsType) -> Self {
97        Self {
98            args,
99            returns,
100            streaming: false,
101            stream_item: None,
102        }
103    }
104
105    /// Create metadata for a streaming handler
106    pub fn streaming(args: Vec<TsField>, item_type: TsType, final_type: TsType) -> Self {
107        Self {
108            args,
109            returns: final_type,
110            streaming: true,
111            stream_item: Some(item_type),
112        }
113    }
114}
115
116impl TsType {
117    /// Render this type as a TypeScript type string
118    pub fn render(&self) -> String {
119        match self {
120            TsType::String => "string".to_string(),
121            TsType::Number => "number".to_string(),
122            TsType::Boolean => "boolean".to_string(),
123            TsType::Null => "null".to_string(),
124            TsType::Void => "void".to_string(),
125            TsType::Optional(inner) => format!("{} | null", inner.render()),
126            TsType::Array(inner) => {
127                let inner_str = inner.render();
128                if inner_str.contains('|') {
129                    format!("({inner_str})[]")
130                } else {
131                    format!("{inner_str}[]")
132                }
133            }
134            TsType::Object(fields) => {
135                if fields.is_empty() {
136                    return "Record<string, never>".to_string();
137                }
138                let mut s = "{ ".to_string();
139                for (i, field) in fields.iter().enumerate() {
140                    if i > 0 {
141                        s.push_str("; ");
142                    }
143                    if field.optional {
144                        write!(s, "{}?: {}", field.name, field.ty.render()).unwrap();
145                    } else {
146                        write!(s, "{}: {}", field.name, field.ty.render()).unwrap();
147                    }
148                }
149                s.push_str(" }");
150                s
151            }
152            TsType::Named(name) => name.clone(),
153            TsType::Raw(raw) => raw.clone(),
154        }
155    }
156}
157
158/// Convert a snake_case handler name to camelCase for TS function name.
159///
160/// Lowercases the entire input first, then capitalizes the first char after
161/// each underscore. Handles ALL_CAPS input correctly.
162fn to_camel_case(s: &str) -> String {
163    let lower = s.to_lowercase();
164    let mut result = String::new();
165    let mut capitalize_next = false;
166
167    for c in lower.chars() {
168        if c == '_' {
169            capitalize_next = true;
170        } else if capitalize_next {
171            result.push(c.to_uppercase().next().unwrap_or(c));
172            capitalize_next = false;
173        } else {
174            result.push(c);
175        }
176    }
177
178    result
179}
180
181/// Convert a snake_case name to PascalCase for TS interface name
182fn to_pascal_case(s: &str) -> String {
183    s.split('_')
184        .map(|word| {
185            let mut chars = word.chars();
186            match chars.next() {
187                None => String::new(),
188                Some(c) => {
189                    let upper: String = c.to_uppercase().collect();
190                    let rest: String = chars.flat_map(|ch| ch.to_lowercase()).collect();
191                    format!("{upper}{rest}")
192                }
193            }
194        })
195        .collect()
196}
197
198/// Generate a complete TypeScript client module from handler metadata
199pub fn generate_ts_client(handler_metas: &HashMap<String, HandlerMeta>) -> String {
200    let mut output = String::new();
201
202    // Header
203    output.push_str("// Auto-generated by AllFrame. Do not edit manually.\n");
204    output.push_str("// Regenerate with: allframe generate-ts-client\n\n");
205    output.push_str("import { invoke } from \"@tauri-apps/api/core\";\n\n");
206
207    // Imports for streaming (listen for events)
208    let has_streaming = handler_metas.values().any(|m| m.streaming);
209    if has_streaming {
210        output.push_str("import { listen, type UnlistenFn } from \"@tauri-apps/api/event\";\n\n");
211    }
212
213    // Internal helper that unwraps CallResponse and parses JSON
214    output.push_str("/** @internal Unwrap CallResponse and parse the JSON result. */\n");
215    output.push_str("async function callHandler<T>(handler: string, args: Record<string, unknown> = {}): Promise<T> {\n");
216    output.push_str("  const response = await invoke<{ result: string }>(\"plugin:allframe|allframe_call\", { handler, args });\n");
217    output.push_str("  return JSON.parse(response.result) as T;\n");
218    output.push_str("}\n\n");
219
220    // StreamSubscription interface and helper (only if streaming handlers exist)
221    if has_streaming {
222        output.push_str("/** Observer for streaming handler updates. */\n");
223        output.push_str("export interface StreamObserver<T, F = void> {\n");
224        output.push_str("  next: (item: T) => void;\n");
225        output.push_str("  error?: (err: Error) => void;\n");
226        output.push_str("  complete?: (result: F) => void;\n");
227        output.push_str("}\n\n");
228
229        output.push_str("/** Subscription handle returned by streaming handlers. */\n");
230        output.push_str("export interface StreamSubscription {\n");
231        output.push_str("  unsubscribe: () => void;\n");
232        output.push_str("}\n\n");
233
234        output.push_str("/** @internal Start a streaming handler, wire events to observer. */\n");
235        output.push_str("async function callStreamHandler<T, F>(\n");
236        output.push_str("  handler: string,\n");
237        output.push_str("  args: Record<string, unknown>,\n");
238        output.push_str("  observer: StreamObserver<T, F>,\n");
239        output.push_str("): Promise<StreamSubscription> {\n");
240        output.push_str("  const { stream_id } = await invoke<{ stream_id: string }>(\"plugin:allframe|allframe_stream\", { handler, args });\n");
241        output.push_str("  const unlistens: UnlistenFn[] = [];\n");
242        output.push_str("  const eventBase = `allframe:stream:${handler}:${stream_id}`;\n");
243        output.push_str("  const cleanup = () => unlistens.forEach(fn => fn());\n");
244        output.push_str("  unlistens.push(await listen<string>(eventBase, (e) => observer.next(JSON.parse(e.payload) as T)));\n");
245        output.push_str("  unlistens.push(await listen<string>(`${eventBase}:complete`, (e) => { cleanup(); observer.complete?.(JSON.parse(e.payload) as F); }));\n");
246        output.push_str("  unlistens.push(await listen<string>(`${eventBase}:error`, (e) => { cleanup(); observer.error?.(new Error(e.payload)); }));\n");
247        output.push_str("  unlistens.push(await listen<void>(`${eventBase}:cancelled`, () => { cleanup(); observer.error?.(new Error('Stream cancelled')); }));\n");
248        output.push_str("  return {\n");
249        output.push_str("    unsubscribe: () => {\n");
250        output.push_str("      cleanup();\n");
251        output.push_str("      invoke(\"plugin:allframe|allframe_stream_cancel\", { streamId: stream_id }).catch(() => {});\n");
252        output.push_str("    },\n");
253        output.push_str("  };\n");
254        output.push_str("}\n\n");
255
256        output.push_str("/**\n");
257        output.push_str(" * Convert an AllFrame streaming handler to an RxJS Observable.\n");
258        output.push_str(" * Requires `rxjs` as a peer dependency: `bun add rxjs`\n");
259        output.push_str(" * @example\n");
260        output.push_str(" * const obs$ = await toObservable((observer) => streamChat({ prompt: \"Hi\" }, observer));\n");
261        output.push_str(" * obs$.subscribe(token => console.log(token));\n");
262        output.push_str(" */\n");
263        output.push_str("export async function toObservable<T>(\n");
264        output.push_str("  start: (observer: StreamObserver<T, unknown>) => Promise<StreamSubscription>,\n");
265        output.push_str("): Promise<import(\"rxjs\").Observable<T>> {\n");
266        output.push_str("  const { Observable } = await import(\"rxjs\");\n");
267        output.push_str("  const subPromise = new Promise<StreamSubscription>((resolve) => {\n");
268        output.push_str("    // Resolved inside the Observable constructor below\n");
269        output.push_str("    (subPromise as any).__resolve = resolve;\n");
270        output.push_str("  });\n");
271        output.push_str("  return new Observable<T>((subscriber) => {\n");
272        output.push_str("    start({\n");
273        output.push_str("      next: (item) => subscriber.next(item),\n");
274        output.push_str("      error: (err) => subscriber.error(err),\n");
275        output.push_str("      complete: () => subscriber.complete(),\n");
276        output.push_str("    }).then((s) => (subPromise as any).__resolve(s));\n");
277        output.push_str("    return () => { subPromise.then(s => s.unsubscribe()); };\n");
278        output.push_str("  });\n");
279        output.push_str("}\n\n");
280    }
281
282    // Collect interfaces to generate
283    let mut interfaces: Vec<(String, &[TsField])> = Vec::new();
284
285    // Sort handlers by name for deterministic output
286    let mut sorted_handlers: Vec<_> = handler_metas.iter().collect();
287    sorted_handlers.sort_by(|(a, _), (b, _)| a.cmp(b));
288
289    // First pass: collect interfaces from Object types
290    for (handler_name, meta) in &sorted_handlers {
291        let pascal = to_pascal_case(handler_name);
292
293        // Args interface
294        if !meta.args.is_empty() {
295            interfaces.push((format!("{pascal}Args"), &meta.args));
296        }
297
298        // Return interface (if Object type)
299        if let TsType::Object(fields) = &meta.returns {
300            interfaces.push((format!("{pascal}Response"), fields));
301        }
302    }
303
304    // Generate interfaces
305    for (name, fields) in &interfaces {
306        writeln!(output, "export interface {name} {{").unwrap();
307        for field in *fields {
308            if field.optional {
309                writeln!(output, "  {}?: {};", field.name, field.ty.render()).unwrap();
310            } else {
311                writeln!(output, "  {}: {};", field.name, field.ty.render()).unwrap();
312            }
313        }
314        output.push_str("}\n\n");
315    }
316
317    // Generate functions
318    for (handler_name, meta) in &sorted_handlers {
319        let fn_name = to_camel_case(handler_name);
320        let pascal = to_pascal_case(handler_name);
321
322        if meta.streaming {
323            // Streaming handler function
324            let item_type = meta
325                .stream_item
326                .as_ref()
327                .map(|t| t.render())
328                .unwrap_or_else(|| "unknown".to_string());
329
330            let final_type = if let TsType::Object(_) = &meta.returns {
331                format!("{pascal}Response")
332            } else {
333                meta.returns.render()
334            };
335
336            if meta.args.is_empty() {
337                writeln!(
338                    output,
339                    "export async function {fn_name}(observer: StreamObserver<{item_type}, {final_type}>): Promise<StreamSubscription> {{",
340                )
341                .unwrap();
342                writeln!(
343                    output,
344                    "  return callStreamHandler<{item_type}, {final_type}>(\"{handler_name}\", {{}}, observer);",
345                )
346                .unwrap();
347            } else {
348                let args_type = format!("{pascal}Args");
349                writeln!(
350                    output,
351                    "export async function {fn_name}(args: {args_type}, observer: StreamObserver<{item_type}, {final_type}>): Promise<StreamSubscription> {{",
352                )
353                .unwrap();
354                writeln!(
355                    output,
356                    "  return callStreamHandler<{item_type}, {final_type}>(\"{handler_name}\", args, observer);",
357                )
358                .unwrap();
359            }
360        } else {
361            // Regular request/response handler function
362            let return_type = if let TsType::Object(_) = &meta.returns {
363                format!("{pascal}Response")
364            } else {
365                meta.returns.render()
366            };
367
368            if meta.args.is_empty() {
369                writeln!(
370                    output,
371                    "export async function {fn_name}(): Promise<{return_type}> {{",
372                )
373                .unwrap();
374                writeln!(
375                    output,
376                    "  return callHandler<{return_type}>(\"{handler_name}\");",
377                )
378                .unwrap();
379            } else {
380                let args_type = format!("{pascal}Args");
381                writeln!(
382                    output,
383                    "export async function {fn_name}(args: {args_type}): Promise<{return_type}> {{",
384                )
385                .unwrap();
386                writeln!(
387                    output,
388                    "  return callHandler<{return_type}>(\"{handler_name}\", args);",
389                )
390                .unwrap();
391            }
392        }
393
394        output.push_str("}\n\n");
395    }
396
397    output.trim_end().to_string()
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn test_to_camel_case() {
406        assert_eq!(to_camel_case("get_user"), "getUser");
407        assert_eq!(to_camel_case("create_new_item"), "createNewItem");
408        assert_eq!(to_camel_case("hello"), "hello");
409        assert_eq!(to_camel_case("GET_USER"), "getUser");
410    }
411
412    #[test]
413    fn test_to_pascal_case() {
414        assert_eq!(to_pascal_case("get_user"), "GetUser");
415        assert_eq!(to_pascal_case("create_new_item"), "CreateNewItem");
416        assert_eq!(to_pascal_case("hello"), "Hello");
417        assert_eq!(to_pascal_case("GET_USER"), "GetUser");
418    }
419
420    #[test]
421    fn test_ts_type_render_primitives() {
422        assert_eq!(TsType::String.render(), "string");
423        assert_eq!(TsType::Number.render(), "number");
424        assert_eq!(TsType::Boolean.render(), "boolean");
425        assert_eq!(TsType::Null.render(), "null");
426        assert_eq!(TsType::Void.render(), "void");
427    }
428
429    #[test]
430    fn test_ts_type_render_optional() {
431        let opt = TsType::Optional(Box::new(TsType::String));
432        assert_eq!(opt.render(), "string | null");
433    }
434
435    #[test]
436    fn test_ts_type_render_array() {
437        let arr = TsType::Array(Box::new(TsType::Number));
438        assert_eq!(arr.render(), "number[]");
439
440        // Union in array gets parens
441        let arr_opt = TsType::Array(Box::new(TsType::Optional(Box::new(TsType::String))));
442        assert_eq!(arr_opt.render(), "(string | null)[]");
443    }
444
445    #[test]
446    fn test_ts_type_render_object() {
447        let obj = TsType::Object(vec![
448            TsField::new("id", TsType::Number),
449            TsField::new("name", TsType::String),
450        ]);
451        assert_eq!(obj.render(), "{ id: number; name: string }");
452    }
453
454    #[test]
455    fn test_ts_type_render_named() {
456        assert_eq!(TsType::Named("UserResponse".to_string()).render(), "UserResponse");
457    }
458
459    #[test]
460    fn test_generate_no_args_handler() {
461        let mut metas = HashMap::new();
462        metas.insert(
463            "get_status".to_string(),
464            HandlerMeta::new(
465                vec![], TsType::String,
466            ),
467        );
468
469        let ts = generate_ts_client(&metas);
470        assert!(ts.contains("export async function getStatus(): Promise<string>"));
471        assert!(ts.contains("callHandler<string>(\"get_status\")"));
472    }
473
474    #[test]
475    fn test_generate_with_args_handler() {
476        let mut metas = HashMap::new();
477        metas.insert(
478            "greet".to_string(),
479            HandlerMeta::new(
480                vec![
481                    TsField::new("name", TsType::String),
482                    TsField::new("age", TsType::Number),
483                ],
484                TsType::Object(vec![TsField::new("greeting", TsType::String)]),
485            ),
486        );
487
488        let ts = generate_ts_client(&metas);
489
490        // Should generate args interface
491        assert!(ts.contains("export interface GreetArgs {"));
492        assert!(ts.contains("  name: string;"));
493        assert!(ts.contains("  age: number;"));
494
495        // Should generate response interface
496        assert!(ts.contains("export interface GreetResponse {"));
497        assert!(ts.contains("  greeting: string;"));
498
499        // Should generate function using callHandler
500        assert!(ts.contains("export async function greet(args: GreetArgs): Promise<GreetResponse>"));
501        assert!(ts.contains("callHandler<GreetResponse>(\"greet\", args)"));
502    }
503
504    #[test]
505    fn test_generate_optional_field() {
506        let mut metas = HashMap::new();
507        metas.insert(
508            "search".to_string(),
509            HandlerMeta::new(
510                vec![
511                    TsField::new("query", TsType::String),
512                    TsField::optional("limit", TsType::Number),
513                ],
514                TsType::Array(Box::new(TsType::String)),
515            ),
516        );
517
518        let ts = generate_ts_client(&metas);
519        assert!(ts.contains("  query: string;"));
520        assert!(ts.contains("  limit?: number;"));
521        assert!(ts.contains("Promise<string[]>"));
522    }
523
524    #[test]
525    fn test_generate_multiple_handlers_sorted() {
526        let mut metas = HashMap::new();
527        metas.insert(
528            "delete_user".to_string(),
529            HandlerMeta::new(
530                vec![TsField::new("id", TsType::Number)],
531                TsType::Void,
532            ),
533        );
534        metas.insert(
535            "create_user".to_string(),
536            HandlerMeta::new(
537                vec![TsField::new("name", TsType::String)],
538                TsType::Object(vec![TsField::new("id", TsType::Number)]),
539            ),
540        );
541
542        let ts = generate_ts_client(&metas);
543
544        // create_user should come before delete_user (sorted)
545        let create_pos = ts.find("createUser").unwrap();
546        let delete_pos = ts.find("deleteUser").unwrap();
547        assert!(create_pos < delete_pos);
548    }
549
550    #[test]
551    fn test_generate_named_return_type() {
552        let mut metas = HashMap::new();
553        metas.insert(
554            "get_user".to_string(),
555            HandlerMeta::new(
556                vec![TsField::new("id", TsType::Number)],
557                TsType::Named("User".to_string()),
558            ),
559        );
560
561        let ts = generate_ts_client(&metas);
562        assert!(ts.contains("Promise<User>"));
563        // Named types don't generate interfaces
564        assert!(!ts.contains("export interface GetUserResponse"));
565    }
566
567    #[test]
568    fn test_generate_header_and_helper() {
569        let metas = HashMap::new();
570        let ts = generate_ts_client(&metas);
571        assert!(ts.contains("Auto-generated by AllFrame"));
572        assert!(ts.contains("import { invoke }"));
573        assert!(ts.contains("async function callHandler<T>"));
574        assert!(ts.contains("JSON.parse(response.result)"));
575    }
576
577    #[test]
578    fn test_generate_idempotent() {
579        let mut metas = HashMap::new();
580        metas.insert(
581            "greet".to_string(),
582            HandlerMeta::new(
583                vec![TsField::new("name", TsType::String)],
584                TsType::String,
585            ),
586        );
587
588        let ts1 = generate_ts_client(&metas);
589        let ts2 = generate_ts_client(&metas);
590        assert_eq!(ts1, ts2);
591    }
592
593    #[test]
594    fn test_full_example_output() {
595        let mut metas = HashMap::new();
596        metas.insert(
597            "get_user".to_string(),
598            HandlerMeta::new(
599                vec![TsField::new("id", TsType::Number)],
600                TsType::Object(vec![
601                    TsField::new("id", TsType::Number),
602                    TsField::new("name", TsType::String),
603                    TsField::optional("email", TsType::String),
604                ]),
605            ),
606        );
607
608        let ts = generate_ts_client(&metas);
609
610        // Verify complete structure
611        assert!(ts.contains("export interface GetUserArgs {\n  id: number;\n}"));
612        assert!(ts.contains("export interface GetUserResponse {\n  id: number;\n  name: string;\n  email?: string;\n}"));
613        assert!(ts.contains(
614            "export async function getUser(args: GetUserArgs): Promise<GetUserResponse>"
615        ));
616        assert!(ts.contains("callHandler<GetUserResponse>(\"get_user\", args)"));
617    }
618
619    // ─── Streaming codegen tests ────────────────────────────────────────
620
621    #[test]
622    fn test_generate_streaming_handler_no_args() {
623        let mut metas = HashMap::new();
624        metas.insert(
625            "stream_updates".to_string(),
626            HandlerMeta::streaming(vec![], TsType::String, TsType::Boolean),
627        );
628
629        let ts = generate_ts_client(&metas);
630
631        // Should generate streaming infrastructure
632        assert!(ts.contains("import { listen"));
633        assert!(ts.contains("export interface StreamObserver"));
634        assert!(ts.contains("export interface StreamSubscription"));
635        assert!(ts.contains("async function callStreamHandler"));
636        assert!(ts.contains("allframe_stream"));
637
638        // Should generate streaming function
639        assert!(ts.contains("export async function streamUpdates(observer: StreamObserver<string, boolean>): Promise<StreamSubscription>"));
640        assert!(ts.contains("callStreamHandler<string, boolean>(\"stream_updates\", {}, observer)"));
641    }
642
643    #[test]
644    fn test_generate_streaming_handler_with_args() {
645        let mut metas = HashMap::new();
646        metas.insert(
647            "stream_chat".to_string(),
648            HandlerMeta::streaming(
649                vec![TsField::new("prompt", TsType::String)],
650                TsType::Object(vec![TsField::new("token", TsType::String)]),
651                TsType::Object(vec![TsField::new("done", TsType::Boolean)]),
652            ),
653        );
654
655        let ts = generate_ts_client(&metas);
656
657        // Should generate args interface
658        assert!(ts.contains("export interface StreamChatArgs {"));
659        assert!(ts.contains("  prompt: string;"));
660
661        // Should generate response interface for final type
662        assert!(ts.contains("export interface StreamChatResponse {"));
663        assert!(ts.contains("  done: boolean;"));
664
665        // Should generate streaming function with args
666        assert!(ts.contains("export async function streamChat(args: StreamChatArgs, observer: StreamObserver<"));
667        assert!(ts.contains("callStreamHandler<"));
668    }
669
670    #[test]
671    fn test_generate_mixed_handlers() {
672        let mut metas = HashMap::new();
673        metas.insert(
674            "get_user".to_string(),
675            HandlerMeta::new(
676                vec![TsField::new("id", TsType::Number)],
677                TsType::String,
678            ),
679        );
680        metas.insert(
681            "stream_data".to_string(),
682            HandlerMeta::streaming(vec![], TsType::String, TsType::Void),
683        );
684
685        let ts = generate_ts_client(&metas);
686
687        // Regular handler uses callHandler
688        assert!(ts.contains("callHandler<string>(\"get_user\""));
689        // Streaming handler uses callStreamHandler
690        assert!(ts.contains("callStreamHandler<string, void>(\"stream_data\""));
691        // Both helpers present
692        assert!(ts.contains("async function callHandler"));
693        assert!(ts.contains("async function callStreamHandler"));
694    }
695
696    #[test]
697    fn test_no_streaming_infrastructure_when_no_streaming_handlers() {
698        let mut metas = HashMap::new();
699        metas.insert(
700            "get_user".to_string(),
701            HandlerMeta::new(vec![], TsType::String),
702        );
703
704        let ts = generate_ts_client(&metas);
705
706        // Should NOT contain streaming infrastructure
707        assert!(!ts.contains("StreamObserver"));
708        assert!(!ts.contains("StreamSubscription"));
709        assert!(!ts.contains("callStreamHandler"));
710        assert!(!ts.contains("listen"));
711    }
712
713    #[test]
714    fn test_handler_meta_new_defaults() {
715        let meta = HandlerMeta::new(vec![], TsType::String);
716        assert!(!meta.streaming);
717        assert!(meta.stream_item.is_none());
718    }
719
720    #[test]
721    fn test_handler_meta_streaming_constructor() {
722        let meta = HandlerMeta::streaming(vec![], TsType::Number, TsType::Boolean);
723        assert!(meta.streaming);
724        assert_eq!(meta.stream_item, Some(TsType::Number));
725        assert_eq!(meta.returns, TsType::Boolean);
726    }
727
728    #[test]
729    fn test_generate_rxjs_adapter() {
730        let mut metas = HashMap::new();
731        metas.insert(
732            "stream_data".to_string(),
733            HandlerMeta::streaming(vec![], TsType::String, TsType::Void),
734        );
735
736        let ts = generate_ts_client(&metas);
737
738        assert!(ts.contains("export async function toObservable"));
739        assert!(ts.contains("import(\"rxjs\")"));
740        assert!(ts.contains("new Observable"));
741        assert!(ts.contains("subscriber.next"));
742        assert!(ts.contains("s.unsubscribe()"));
743    }
744
745    #[test]
746    fn test_no_rxjs_adapter_without_streaming() {
747        let mut metas = HashMap::new();
748        metas.insert(
749            "get_user".to_string(),
750            HandlerMeta::new(vec![], TsType::String),
751        );
752
753        let ts = generate_ts_client(&metas);
754        assert!(!ts.contains("toObservable"));
755    }
756}