diff --git a/crates/foxvm/src/bytecode.rs b/crates/foxvm/src/bytecode.rs index 262a724..70a76ba 100644 --- a/crates/foxvm/src/bytecode.rs +++ b/crates/foxvm/src/bytecode.rs @@ -357,6 +357,9 @@ pub enum Instr { /// FOR EACH support: [array, index] -> [array, index+1, element]; at the end pops both and /// jumps to the exit. ForEachNext(u32), + /// `FOR EACH x IN obj.Member`: [obj] -> [items] and a jump past the member's ordinary read + /// when the host enumerates the member as a collection; otherwise [obj] -> [] and on. + ForEachItems { member: u32, target: u32 }, // ---- calls /// `name(args)`: array element access when `name` resolves to an array, else a user function @@ -403,6 +406,15 @@ pub enum Instr { LoadScreen, /// [obj] -> [value or child object] GetMember(u32), + /// [obj, subs...] -> [element] `obj.aProp[1]`: the subscripts are the Access method's + /// arguments when the object has one for the property, and index what it holds when not. + GetMemberIndex { + name: u32, + argc: u8, + }, + /// [obj] -> [value] the property itself, past any Access method: what `ALEN(obj.aProp)` + /// is handed. + GetProp(u32), /// [obj, name] -> [value] the same, for a member named by a variable: `obj.&cName`. GetMemberByName, /// `obj.&cName = v`: the member a variable names is written. [value, obj, name] -> [] diff --git a/crates/foxvm/src/compiler.rs b/crates/foxvm/src/compiler.rs index 6017356..1c06bc0 100644 --- a/crates/foxvm/src/compiler.rs +++ b/crates/foxvm/src/compiler.rs @@ -376,7 +376,8 @@ impl FuncBuilder { | Instr::JumpIfFalseKeep(t) | Instr::JumpIfTrueKeep(t) | Instr::ForTest(t) - | Instr::ForEachNext(t) => *t = target, + | Instr::ForEachNext(t) + | Instr::ForEachItems { target: t, .. } => *t = target, _ => unreachable!("patching a non-jump"), } } @@ -943,7 +944,21 @@ impl ModuleCompiler { } StmtKind::ForEach { var, collection, body } => { let target = self.var_target(fb, var); + // `obj.Member` may be a collection the host walks rather than a value it reads - + // `_SCREEN.Forms` - so the host is asked first, and the member is read as any + // other expression only when it is not one + let items = match &collection.kind { + ExprKind::Member { obj, name } if !matches!(&obj.kind, ExprKind::Var(base) if base.upper == "M") => { + self.expr(fb, obj); + let member = self.member(name); + Some(fb.emit(Instr::ForEachItems { member, target: 0 })) + } + _ => None, + }; self.expr(fb, collection); + if let Some(at) = items { + fb.patch_here(at); + } let zero = self.constant(Constant::num(0.0)); fb.emit(Instr::Const(zero)); let top = fb.pc(); @@ -3871,6 +3886,15 @@ impl ModuleCompiler { } } ExprKind::Call { name, args } => self.call(fb, e, name, args), + // `obj.aProp[1]` is one read, not a read and then a subscript: measured, an Access + // method for an array property is handed the subscript as its argument + ExprKind::Index { base, args } if object_member(base).is_some() => { + let (obj, name) = object_member(base).expect("an object's member"); + self.expr(fb, obj); + let n = self.subscripts(fb, args, e.span); + let m = self.member(name); + fb.emit(Instr::GetMemberIndex { name: m, argc: n }); + } ExprKind::Index { base, args } => { self.expr(fb, base); let n = self.subscripts(fb, args, e.span); @@ -3991,6 +4015,15 @@ impl ModuleCompiler { } let argc = match builtins::array_to_fill(spec.name) { Some(at) => self.args_filling_array(fb, args, at), + // measured: ALEN(obj.aProp) is the array itself, and its Access method is not + // called + None if spec.name == "ALEN" && args.first().is_some_and(|a| !a.by_ref && object_member(&a.expr).is_some()) => { + let (obj, name) = object_member(&args[0].expr).expect("an object's member"); + self.expr(fb, obj); + let m = self.member(name); + fb.emit(Instr::GetProp(m)); + 1 + self.args(fb, &args[1..], true, false) + } None => self.args(fb, args, true, false), }; fb.emit(Instr::CallBuiltin { id, argc }); @@ -4007,6 +4040,15 @@ impl ModuleCompiler { } } +/// `obj.Member` where `obj` is an object: anything but the `m.` prefix, which names a memory +/// variable. +fn object_member(e: &Expr) -> Option<(&Expr, &Name)> { + match &e.kind { + ExprKind::Member { obj, name } if !matches!(&obj.kind, ExprKind::Var(base) if base.upper == "M") => Some((obj, name)), + _ => None, + } +} + /// One column of a `CREATE TABLE` or `ALTER TABLE` as the table format holds it. fn column_of(f: &CursorField) -> crate::bytecode::ColumnDef { crate::bytecode::ColumnDef { field: field_of(f), nullable: f.nullable } diff --git a/crates/foxvm/src/host.rs b/crates/foxvm/src/host.rs index d859d5e..ce9311c 100644 --- a/crates/foxvm/src/host.rs +++ b/crates/foxvm/src/host.rs @@ -93,6 +93,19 @@ pub trait Host { fn members(&mut self, _obj: Handle) -> Option> { None } + /// The objects a collection member holds, in the order `FOR EACH` visits them - an array + /// value - for a member that is a collection rather than a property: `_SCREEN.Forms`, which + /// Visual FoxPro will not read as a value (1924, measured) but will walk. `None` when the + /// member is not one, and `FOR EACH` then reads it as it reads anything else. + fn enumerate(&mut self, _obj: Handle, _member: &str) -> Option { + None + } + /// Whether the object was a form (or a member of one) that has since been released. A + /// variable or array element still holding it reads as .NULL. from then on - measured: + /// `oForm.Release()`, then `ISNULL(oForm)` is .T. and `VARTYPE(oForm)` is "X". + fn released(&mut self, _obj: Handle) -> bool { + false + } /// `None` when the handle has been released ("Object is not valid"). fn object_class(&mut self, obj: Handle) -> Option; /// The file the object was built from, for `SYS(1271, oObject)`: a form's `.scx`. Measured: @@ -118,6 +131,12 @@ pub trait Host { fn class_method(&mut self, _obj: Handle, _name: &str) -> bool { false } + /// True when the object carries FoxPro source for a method of that name, whether its class + /// wrote it or the form or library it came from did. What asks is a property read or write, + /// looking for the `Prop_Access` or `Prop_Assign` method that stands in for it. + fn has_code_method(&mut self, _obj: Handle, _name: &str) -> bool { + false + } /// Where the mouse pointer is over the character screen, in rows and columns from its /// top left, and whether a button is down. A host that does not track it says so by /// leaving the pointer at the corner with nothing pressed. diff --git a/crates/foxvm/src/parser.rs b/crates/foxvm/src/parser.rs index 2aa53d5..ba7b6c0 100644 --- a/crates/foxvm/src/parser.rs +++ b/crates/foxvm/src/parser.rs @@ -1127,10 +1127,6 @@ impl Parser { Name::new("", head.span) } }; - if name.upper.ends_with("_ACCESS") || name.upper.ends_with("_ASSIGN") { - let (span, text) = (name.span, name.text.clone()); - self.warning(span, format!("{text}: access and assign methods are not honoured yet")); - } let mut params = Vec::new(); if self.eat(&TokKind::LParen) { match self.param_list(&TokKind::RParen) { diff --git a/crates/foxvm/src/vm.rs b/crates/foxvm/src/vm.rs index 833073c..f7d8f43 100644 --- a/crates/foxvm/src/vm.rs +++ b/crates/foxvm/src/vm.rs @@ -3189,6 +3189,73 @@ impl Vm { } } + /// What reading `name` of `obj` comes to: its value, or the Access method that answers for + /// it - which `access` false passes over, as `ALEN(obj.aProp)` does. + fn read_member(&self, host: &mut dyn Host, fb: &Fiber, obj: &Value, name: &str, access: bool) -> Result { + if let Some(v) = json_member(obj, name) { + return v.map(MemberRead::Value); + } + let h = self.check_object(host, obj, name)?; + if let Some(v) = self.native_member(h, name) { + return v.map(MemberRead::Value); + } + if access && let Some(method) = self.accessor(host, fb, h, name, "ACCESS") { + return Ok(MemberRead::Access(h, method)); + } + match host.get_member(h, name)? { + Member::Child(c) => Ok(MemberRead::Value(Value::Object(c))), + Member::Property => Ok(MemberRead::Value(host.get_prop(h, name)?.deref())), + Member::Method | Member::None => Err(RtError::unknown_member(name)), + } + } + + /// Calls the Access method `method` of `h` with `args`, its answer going on the stack. + fn call_access(fb: &mut Fiber, h: Handle, method: String, args: Vec) -> Flow { + fb.pending = Some(Pending::Push); + Flow::Suspend(HostRequest::CallMethod { obj: h.0, name: method, args }) + } + + /// What an Access method is handed for a read of its property with no subscript. Measured: + /// an array property named whole - `VARTYPE(obj.aProp)` - hands it 1, the first element. + fn whole_access_args(host: &mut dyn Host, h: Handle, name: &str) -> Vec { + let array = matches!(host.get_member(h, name), Ok(Member::Property)) + && matches!(host.get_prop(h, name).map(|v| v.deref()), Ok(Value::Array(_))); + if array { vec![JsonValue::Num(1.0)] } else { Vec::new() } + } + + /// The Access or Assign method that stands in for reading or writing `name` on `h` - `which` + /// is "ACCESS" or "ASSIGN" - when the object has one. Measured: inside either of the two, + /// `THIS.name` is the property itself, which is the only way they can reach what they guard. + fn accessor(&self, host: &mut dyn Host, fb: &Fiber, h: Handle, name: &str, which: &str) -> Option { + if h.0 == 0 || crate::foxscript::in_range(h) { + return None; + } + let method = format!("{}_{which}", name.to_ascii_uppercase()); + if !host.has_code_method(h, &method) { + return None; + } + let env = env_index(fb, fb.frames.len() - 1); + let frame = &fb.frames[env]; + if frame.this == Some(h) { + let running = &self.proto(frame.module, frame.func).display_name; + let last = running.rsplit('.').next().unwrap_or(running); + let last = last.split('#').next().unwrap_or(last).to_ascii_uppercase(); + let property = name.to_ascii_uppercase(); + if last == format!("{property}_ACCESS") || last == format!("{property}_ASSIGN") { + return None; + } + } + Some(method) + } + + /// A value as a variable reads it: a released form is .NULL. to everything still holding it. + fn live(host: &mut dyn Host, v: Value) -> Value { + match v.deref() { + Value::Object(h) if h.0 != 0 && !crate::foxscript::in_range(h) && host.released(h) => Value::Null, + _ => v, + } + } + fn check_object(&self, host: &mut dyn Host, v: &Value, what: &str) -> Result { let h = match v.deref() { Value::Object(h) => h, @@ -3283,7 +3350,7 @@ impl Vm { } Instr::LoadLocal(s) => { let v = self.load_local(fb, *s)?; - fb.stack.push(v); + fb.stack.push(Self::live(host, v)); } Instr::StoreLocal(s) => { let v = pop!(); @@ -3299,7 +3366,7 @@ impl Vm { FieldRead::Suspend(req) => return Ok(self.ask(fb, req)), } } else if let Some(v) = self.load_name(fb, &name) { - fb.stack.push(v); + fb.stack.push(Self::live(host, v)); } else if let Some(alias) = self.query_field(fb, &name) { // inside a query a name that is nothing else is a field of one of its // sources, which is how a join names the columns it did not have to qualify @@ -3386,7 +3453,7 @@ impl Vm { let subs = pop_subscripts(fb, *n)?; let arr = pop!(); let v = index_array(&arr, &subs)?; - fb.stack.push(v); + fb.stack.push(Self::live(host, v)); } Instr::StoreIndex(n) => { let subs = pop_subscripts(fb, *n)?; @@ -3523,6 +3590,17 @@ impl Vm { fb.frames.last_mut().expect("frame").pc = *t as usize; } } + Instr::ForEachItems { member, target } => { + let obj = pop!(); + let items = match obj.deref() { + Value::Object(h) => host.enumerate(h, &module.members[*member as usize]), + _ => None, + }; + if let Some(items) = items { + fb.stack.push(items); + fb.frames.last_mut().expect("frame").pc = *target as usize; + } + } Instr::ForEachNext(t) => { let len = fb.stack.len(); if len < 2 { @@ -3717,23 +3795,38 @@ impl Vm { Instr::GetMember(m) => { let obj = pop!(); let name = &module.members[*m as usize]; - if let Some(v) = json_member(&obj, name) { - fb.stack.push(v?); - return Ok(Flow::Next); - } - - let h = self.check_object(host, &obj, name)?; - if let Some(v) = self.native_member(h, name) { - fb.stack.push(v?); - return Ok(Flow::Next); + // `Prop_Access` answers for the property when the object has one + match self.read_member(host, fb, &obj, name, true)? { + MemberRead::Value(v) => fb.stack.push(v), + MemberRead::Access(h, method) => { + let args = Self::whole_access_args(host, h, name); + return Ok(Self::call_access(fb, h, method, args)); + } } - match host.get_member(h, name)? { - Member::Child(c) => fb.stack.push(Value::Object(c)), - Member::Property => { - let v = host.get_prop(h, name)?; - fb.stack.push(v.deref()); + } + // `obj.aProp[1]`: measured, the subscripts are the Access method's arguments, and the + // method's own THIS.aProp[n] reads the element without calling it again + Instr::GetMemberIndex { name, argc } => { + let subs = pop_n(fb, *argc as usize)?; + let obj = pop!(); + let name = &module.members[*name as usize]; + match self.read_member(host, fb, &obj, name, true)? { + MemberRead::Value(v) => { + let subs = subs.iter().map(Value::as_usize).collect::, _>>()?; + let v = index_array(&v, &subs)?; + fb.stack.push(Self::live(host, v)); + } + MemberRead::Access(h, method) => { + return Ok(Self::call_access(fb, h, method, subs.iter().map(JsonValue::from_value).collect())); } - Member::Method | Member::None => return Err(RtError::unknown_member(name)), + } + } + Instr::GetProp(m) => { + let obj = pop!(); + let name = &module.members[*m as usize]; + match self.read_member(host, fb, &obj, name, false)? { + MemberRead::Value(v) => fb.stack.push(v), + MemberRead::Access(..) => unreachable!("an Access method was asked for"), } } Instr::SetMember(m) => { @@ -3745,6 +3838,10 @@ impl Vm { return Err(native_write_refused(&self.natives, h, name)); } fb.pending = Some(Pending::Discard); + // `Prop_Assign` is handed the value instead of the property taking it + if let Some(method) = self.accessor(host, fb, h, name, "ASSIGN") { + return Ok(Flow::Suspend(HostRequest::CallMethod { obj: h.0, name: method, args: vec![JsonValue::from_value(&v)] })); + } return Ok(Flow::Suspend(HostRequest::SetProp { obj: h.0, name: name.clone(), @@ -3839,6 +3936,11 @@ impl Vm { if self.natives.contains(h) { return self.native_call(fb, h, name, args); } + // `obj.aProp("x")` is a read of the property as much as `obj.aProp["x"]` is, so + // measured, its Access method is handed what is in the parentheses + if let Some(method) = self.accessor(host, fb, h, name, "ACCESS") { + return Ok(Self::call_access(fb, h, method, args.iter().map(JsonValue::from_value).collect())); + } fb.pending = Some(Pending::Push); return Ok(Flow::Suspend(HostRequest::CallMethod { obj: h.0, @@ -5765,27 +5867,17 @@ impl Vm { if base == "M" { let upper = field.to_ascii_uppercase(); let v = self.load_name(fb, &upper).ok_or_else(|| RtError::variable_not_found(&upper))?; - fb.stack.push(v); + fb.stack.push(Self::live(host, v)); return Ok(Flow::Next); } if let Some(obj) = self.load_name(fb, &base) { - if let Some(v) = json_member(&obj, &field) { - fb.stack.push(v?); - return Ok(Flow::Next); - } - - let h = self.check_object(host, &obj, &field)?; - if let Some(v) = self.native_member(h, &field) { - fb.stack.push(v?); - return Ok(Flow::Next); - } - match host.get_member(h, &field)? { - Member::Child(c) => fb.stack.push(Value::Object(c)), - Member::Property => { - let v = host.get_prop(h, &field)?; - fb.stack.push(v.deref()); + let obj = Self::live(host, obj); + match self.read_member(host, fb, &obj, &field, true)? { + MemberRead::Value(v) => fb.stack.push(v), + MemberRead::Access(h, method) => { + let args = Self::whole_access_args(host, h, &field); + return Ok(Self::call_access(fb, h, method, args)); } - Member::Method | Member::None => return Err(RtError::unknown_member(&field)), } return Ok(Flow::Next); } @@ -9914,6 +10006,13 @@ fn native_write_refused(natives: &crate::foxscript::Natives, h: Handle, name: &s } } +/// What reading an object's member comes to. +enum MemberRead { + Value(Value), + /// The object has an Access method for the member, named here, that answers instead. + Access(Handle, String), +} + /// A member of a JSON value by name, or nothing when the value is not one. /// /// A name a JSON object has not got is the product's own unknown-member error, because that is diff --git a/crates/foxvm/src/wasm.rs b/crates/foxvm/src/wasm.rs index bad7598..177b145 100644 --- a/crates/foxvm/src/wasm.rs +++ b/crates/foxvm/src/wasm.rs @@ -355,6 +355,35 @@ impl Host for JsHost { }) } + /// Optional as well: `hasCodeMethod(obj, name)`, which is how Access and Assign methods are found. + fn has_code_method(&mut self, obj: Handle, name: &str) -> bool { + optional_method(&self.reads, "hasCodeMethod") + .and_then(|f| f.call2(self.reads.as_ref(), &JsValue::from(obj.0), &JsValue::from_str(name)).ok()) + .and_then(|answer| answer.as_bool()) + .unwrap_or(false) + } + + /// Optional as well: `released(obj)` is true for a form that has gone. + fn released(&mut self, obj: Handle) -> bool { + optional_method(&self.reads, "released") + .and_then(|f| f.call1(self.reads.as_ref(), &JsValue::from(obj.0)).ok()) + .and_then(|answer| answer.as_bool()) + .unwrap_or(false) + } + + /// Optional too: `enumerate(obj, member)` answers the collection as an array, or nothing. + fn enumerate(&mut self, obj: Handle, member: &str) -> Option { + let f = optional_method(&self.reads, "enumerate")?; + let answer = f.call2(self.reads.as_ref(), &JsValue::from(obj.0), &JsValue::from_str(member)).ok()?; + if answer.is_undefined() || answer.is_null() { + return None; + } + match from_js(answer) { + items @ Value::Array(_) => Some(items), + _ => None, + } + } + fn object_class(&mut self, obj: Handle) -> Option { self.reads.object_class(obj.0).as_string() } diff --git a/crates/foxvm/tests/classes.rs b/crates/foxvm/tests/classes.rs index dd5981e..54524aa 100644 --- a/crates/foxvm/tests/classes.rs +++ b/crates/foxvm/tests/classes.rs @@ -296,7 +296,7 @@ fn a_duplicate_class_name_is_an_error() { } #[test] -fn of_classlib_and_access_methods_only_warn() { +fn of_classlib_only_warns_and_access_methods_are_plain_methods() { let src = "RETURN\n\ DEFINE CLASS c AS Custom OF mylib.vcx OLEPUBLIC\n\ \tCaption = \"x\"\n\ @@ -307,14 +307,10 @@ fn of_classlib_and_access_methods_only_warn() { assert!(!foxvm::parser::has_errors(&out.diagnostics), "{:#?}", out.diagnostics); assert_eq!( messages(&out.diagnostics), - vec![ - "OF mylib.vcx is ignored: the class is taken from this program", - "Caption_ACCESS: access and assign methods are not honoured yet", - "Caption_ASSIGN: access and assign methods are not honoured yet", - ] + vec!["OF mylib.vcx is ignored: the class is taken from this program"] ); assert_eq!(out.program.classes[0].class_lib.as_deref(), Some("mylib.vcx")); - // The methods are still compiled as ordinary ones. + // They are compiled as ordinary methods; a property read or write is what calls them. let m = module(src); assert_eq!(m.classes[0].methods.len(), 2); } diff --git a/src/renderer/src/runtime/session.ts b/src/renderer/src/runtime/session.ts index 66603dc..ec71128 100644 --- a/src/renderer/src/runtime/session.ts +++ b/src/renderer/src/runtime/session.ts @@ -496,6 +496,12 @@ export const useSessionStore = create((set, get) => { // `oContainer.NewObject(name, class, file)`: a member of a class out of a class library desktop.libraryObject = (className, module, into) => libraryObject(className, module, into); + // _SCREEN.AddObject makes its object the way CREATEOBJECT does, a program's own classes + // included, so it goes the same way a CREATEOBJECT from the VM goes + desktop.createNamedObject = (className) => + Promise.resolve( + perform({ kind: 'CreateObject', class: className, args: [], definition: programClass(className) }, { fiber: 0, generation: 0 }), + ); // an ActiveX control this runtime does not draw is still reachable through COM desktop.createOleObject = (progId) => { @@ -615,6 +621,7 @@ export const useSessionStore = create((set, get) => { noshow, args, cursors: compiled.doc.data, + dataEnvironment: true, // ^oWindows[1,0] among the form's own members: an array property, dimensioned before // anything can read it arrays: compiled.doc.meta?.vfp?.arrays, @@ -1469,6 +1476,16 @@ export const useSessionStore = create((set, get) => { return known.length > 0 ? known : [fallback]; } + /** A `DEFINE CLASS` of that name in any program loaded so far, with the module holding it. */ + function programClass(className: string): VfpClassDef | null { + const wanted = className.toLowerCase(); + for (const id of new Set(modules.values())) { + const found = definedClasses(id).find((c) => c.name.toLowerCase() === wanted); + if (found) return { ...found, module: found.module ?? id }; + } + return null; + } + /** The `DEFINE CLASS` definitions a module holds, read once and kept. */ function definedClasses(module: number): VfpClassDef[] { if (module < 0) return []; diff --git a/src/shared/runtime/host.ts b/src/shared/runtime/host.ts index bc548ae..041e7b9 100644 --- a/src/shared/runtime/host.ts +++ b/src/shared/runtime/host.ts @@ -17,6 +17,22 @@ export interface HostReads { getProp(obj: number, name: string): VmValue | undefined; /** A child object's handle, or what kind of member the name is. */ getMember(obj: number, name: string): number | 'prop' | 'method' | 'none'; + /** + * The objects a collection member holds, in the order `FOR EACH obj IN x.Member` visits them, + * or `undefined` when the member is not a collection. `_SCREEN.Forms` is one: Visual FoxPro + * walks it but will not read it as a value (1924, measured). + */ + enumerate?(obj: number, name: string): VmValue | undefined; + /** + * Whether the handle was a form, or a member of one, that has been released: a variable still + * holding it then reads as .NULL. (measured - `ISNULL(oForm)` after `oForm.Release()` is .T.). + */ + released?(obj: number): boolean; + /** + * True when the object carries FoxPro source for a method of that name - its class's, or the + * form's or library's own. A property read or write asks, for `Prop_Access` and `Prop_Assign`. + */ + hasCodeMethod?(obj: number, name: string): boolean; /** Class name, or `null` when the handle has been released. */ objectClass(obj: number): string | null; /** diff --git a/src/shared/runtime/objectModel.ts b/src/shared/runtime/objectModel.ts index 92be2f6..4bed327 100644 --- a/src/shared/runtime/objectModel.ts +++ b/src/shared/runtime/objectModel.ts @@ -236,6 +236,12 @@ export class RuntimeObject { version = 0; alive = true; parent: RuntimeObject | null = null; + /** + * The Name a program gave it while it ran - `oForm.Name = "one"` - which is what Name reads + * from then on (measured). The node keeps the name the object was built with, because that is + * how its methods are found. + */ + renamed: string | null = null; /** * The compiled module holding this object's own method bodies, or -1 when whatever contains * it holds them. A form has one because it is a document; so does an object built from a @@ -801,10 +807,13 @@ export class RuntimeObject { * methods of its own - `CenterForm`, `GetDirectory` - and those are called like any other. */ hasOwnMethod(name: string): boolean { - const lower = name.toLowerCase(); - return Object.keys(this.node.methods).some((m) => m.toLowerCase() === lower); + this.ownMethods ??= new Set(Object.keys(this.node.methods).map((m) => m.toLowerCase())); + return this.ownMethods.has(name.toLowerCase()); } + /** The node's method names, lower-cased: asked on every property read, for Access methods. */ + private ownMethods: Set | undefined; + /** True when the object's class defines this method in FoxPro source. */ hasClassMethod(name: string): boolean { return this.classMethods.has(name.toLowerCase()); @@ -840,6 +849,7 @@ export class RuntimeObject { setMethodSource(name: string, source: string): void { const key = Object.keys(this.node.methods).find((m) => m.toLowerCase() === name.toLowerCase()) ?? name; this.node.methods[key] = source; + this.ownMethods?.add(key.toLowerCase()); } /** MoveItem: an item of a list control, moved to another place in the list. */ @@ -1100,6 +1110,13 @@ export interface CreateFormOptions { nonVisual?: boolean; /** The tables the form's data environment names, which become its Cursor objects. */ cursors?: FormCursor[]; + /** + * Whether the form has a DataEnvironment object. Only a form read from a form file does: a + * form made from a class - `CREATEOBJECT("Form")` or a `.vcx` form class - has none, and + * `PEMSTATUS(o, "DataEnvironment", 5)` answers .F. for it (measured). Defaults to whether + * cursors were given. + */ + dataEnvironment?: boolean; /** Property values written as expressions, worked out as the form is built. */ expressions?: Record; /** Array properties the objects add for themselves, as `[rows, cols]`. */ @@ -1118,6 +1135,12 @@ export interface CreateFormOptions { */ export class Desktop implements HostReads { readonly forms: FormInstance[] = []; + /** + * What a program has written to `_SCREEN` or given it with AddProperty, by upper-cased name. + * A CodeMine application's first lines set the caption and add a property to carry its + * start-up parameter across a CLEAR ALL, so the screen has to hold what it is told. + */ + private readonly screenValues = new Map(); /** The formsets running: containers of forms, which are not themselves windows on the screen. */ readonly formSets: FormSetInstance[] = []; /** Handle -> object; index 0 is the desktop itself. */ @@ -1131,8 +1154,13 @@ export class Desktop implements HostReads { * application answers to falls through to what `_VFP` was measured holding, so a property this * switch has never heard of still answers rather than being missing. */ + /** What a program wrote on `_VFP`: `_VFP.Height = 500` reads back as 500. */ + private appValues = new Map(); + private appProp(name: string): VmValue | undefined { const measured = BASE_CLASS_MEMBERS['Application']; + const written = this.appValues.get(name.toUpperCase()); + if (written !== undefined) return written; switch (name.toUpperCase()) { // `_VFP` in the product has no Class and no BaseClass at all - asking raises - but a great // deal of code asks any object what it is, and answering is kinder than raising @@ -1159,7 +1187,7 @@ export class Desktop implements HostReads { case 'PROJECTCOUNT': return this.activeProject?.() ? 1 : 0; case 'FORMCOUNT': - return this.forms.length; + return this.visibleForms.length; // the window's title, which is this product's and not Visual FoxPro's. Name is left as the // measurement has it, because a program asking what it is running in is asking about the // language it was written for. @@ -1335,6 +1363,12 @@ export class Desktop implements HostReads { * and `null` when it has one whose Init refused to be created - which are different answers, * because only the first of them means the program named something that does not exist. */ + /** + * An object of the class of that name, made as `CREATEOBJECT()` would make it: a base class, + * a `DEFINE CLASS` of a program already loaded, or a class of a loaded class library. The + * session provides it; `_SCREEN.AddObject` is what asks. + */ + createNamedObject: ((className: string) => Promise) | null = null; libraryObject: | (( className: string, @@ -1368,7 +1402,7 @@ export class Desktop implements HostReads { instance.className = options.className ?? null; instance.nonVisual = options.nonVisual ?? false; instance.modal = options.modal ?? instance.get('WindowType') === 1; - instance.dataEnvironment = new DataEnvironment(options.cursors); + if (options.dataEnvironment ?? options.cursors !== undefined) instance.dataEnvironment = new DataEnvironment(options.cursors); this.handles[instance.handle] = instance; this.buildChildren(form.children, instance); @@ -1586,6 +1620,7 @@ export class Desktop implements HostReads { for (const object of [instance, ...instance.descendants()]) { object.alive = false; this.handles[object.handle] = undefined; + this.gone.add(object.handle); this.unbindEvent(object.handle); this.unbindEvent(undefined, undefined, object.handle); } @@ -1695,12 +1730,36 @@ export class Desktop implements HostReads { return this.forms.filter((f) => !f.nonVisual); } + /** + * `_SCREEN.Forms`, topmost first: the form made last is `Forms(1)`. Measured: a Custom object + * is not in it, a form nobody has shown is. + */ + private screenForms(): FormInstance[] { + return this.visibleForms.reverse(); + } + + /** `_SCREEN.Forms(n)`; past either end is 1924, as the product says it. */ + private screenForm(n: VmValue | undefined): VmValue { + const form = this.screenForms()[Number(n ?? 0) - 1]; + if (!form) throw new HostError(1924, 'FORMS is not an object.'); + return { $obj: form.handle }; + } + // ---- HostReads: called synchronously from inside wasm; no side effects here ---- getProp(obj: number, name: string): VmValue | undefined { + const value = this.readProp(obj, name); + // a property holding a form that has been released holds .NULL. from then on, as a variable does + return value !== null && typeof value === 'object' && '$obj' in value && this.gone.has(value.$obj) ? null : value; + } + + private readProp(obj: number, name: string): VmValue | undefined { if (obj === APP_HANDLE) return this.appProp(name); - if (obj === SCREEN_HANDLE) return this.screenProp(name); + if (obj === SCREEN_HANDLE) { + const own = this.screenValues.get(name.toUpperCase()); + return own !== undefined ? own : this.screenProp(name); + } const host = this.hosted.get(obj); // a property read happens inside wasm, so it can only be something that is already known if (host) return settled(this.fromHost(host.get(name))); @@ -1720,7 +1779,7 @@ export class Desktop implements HostReads { const upper = name.toUpperCase(); switch (upper) { case 'NAME': - return target.name; + return target.renamed ?? target.name; // Parent is what contains the object, and a top-level form is contained by nothing: the // product answers TYPE("THISFORM.Parent") with "U" and raises 1924 on a read - measured - // where the screen would make it "O". A sample's Close button asks exactly that question @@ -1751,6 +1810,15 @@ export class Desktop implements HostReads { return target.parentClass; case 'CONTROLCOUNT': return target.children.length; + // a page's place among its pageframe's pages, counting from 1, unless a program moved it; + // CodeMine's ActivePage_Assign turns a page number into its PageOrder this way + case 'PAGEORDER': { + if (target.baseClass !== 'Page') break; + const set = target.get('PageOrder'); + if (typeof set === 'number' && set > 0) return set; + const pages = target.parent?.children.filter((c) => c.baseClass === 'Page') ?? []; + return pages.indexOf(target) + 1; + } case 'LISTCOUNT': return target.isListControl ? target.items.length : undefined; case 'LISTINDEX': @@ -1788,13 +1856,19 @@ export class Desktop implements HostReads { case 'PARENT': throw new HostError(1924, 'PARENT is not an object.'); case 'FORMCOUNT': - return this.forms.length; + return this.visibleForms.length; case 'ACTIVEFORM': return this.forms.length ? { $obj: this.forms[this.forms.length - 1]!.handle } : null; case 'CAPTION': return 'FoxDev Studio'; case 'VISIBLE': return true; + // The main window's handle, which a program passes to the Windows API - CodeMine makes a + // hidden marker window with it as the parent, so a second copy of the application can + // find the first. The IDE's window handle cannot be had from here, and 0 is what Windows + // reads as no parent at all, so the call still does what it is for. + case 'HWND': + return 0; case 'APPLICATION': return { $obj: APP_HANDLE }; // `_SCREEN` is a Form - it answers "Form" for its own BaseClass - so everything a form @@ -1822,12 +1896,16 @@ export class Desktop implements HostReads { if (name.toUpperCase() === 'APPLICATION') return APP_HANDLE; const form = this.findForm(name); if (form) return form.handle; + if (name.toUpperCase() === 'FORMS') return 'method'; return this.appProp(name) !== undefined ? 'prop' : METHOD_NAMES.has(name.toUpperCase()) ? 'method' : 'none'; } if (obj === SCREEN_HANDLE) { if (name.toUpperCase() === 'APPLICATION') return APP_HANDLE; const form = this.findForm(name); if (form) return form.handle; + const own = this.screenValues.get(name.toUpperCase()); + if (own !== undefined) return handleOf(own) ?? 'prop'; + if (name.toUpperCase() === 'FORMS') return 'method'; return this.screenProp(name) !== undefined ? 'prop' : METHOD_NAMES.has(name.toUpperCase()) ? 'method' : 'none'; } const host = this.hosted.get(obj); @@ -1847,7 +1925,8 @@ export class Desktop implements HostReads { // a property holding an object is reached through like a member, which is how // `THISFORM.oToolbar.Left` finds the toolbar the form keeps const held = target.objectValue(name); - if (held !== undefined) return held; + // one holding a form that has since been released holds .NULL., which only a read can give + if (held !== undefined) return this.gone.has(held) ? 'prop' : held; // what a formset answers beyond a form: how many forms it holds, which of them is active, // and `Forms(n)`, which reads like a method call because it takes a subscript if (target instanceof FormSetInstance) { @@ -1924,6 +2003,26 @@ export class Desktop implements HostReads { return out; } + /** Forms, and what was on them, that have been released; references to them read as .NULL. */ + private gone = new Set(); + + released(obj: number): boolean { + return this.gone.has(obj); + } + + hasCodeMethod(obj: number, name: string): boolean { + const target = this.handles[obj]; + return target !== undefined && (target.hasClassMethod(name) || target.hasOwnMethod(name)); + } + + /** What `FOR EACH` walks: the forms of the screen, or of a formset. */ + enumerate(obj: number, name: string): VmValue | undefined { + if (name.toUpperCase() !== 'FORMS') return undefined; + const target = this.handles[obj]; + const forms = obj === SCREEN_HANDLE || obj === APP_HANDLE ? this.screenForms() : target instanceof FormSetInstance ? target.forms : undefined; + return forms && { $arr: forms.map((f) => ({ $obj: f.handle })), $cols: 0 }; + } + /** `DEFINE CLASS ... PROCEDURE Error`: what makes the VM route an error to the object. */ hasClassMethod(obj: number, name: string): boolean { return this.handles[obj]?.hasClassMethod(name) ?? false; @@ -2044,6 +2143,25 @@ export class Desktop implements HostReads { * same error a read would when the property does not exist. */ setProp(obj: number, name: string, value: VmValue): void { + // `_VFP` too - CodeMine puts the main window back where it was with `_VFP.Height = ...` - + // and a name it does not have is the automation error the product raises (measured) + if (obj === APP_HANDLE) { + const upper = name.toUpperCase(); + if (!this.appValues.has(upper) && this.appProp(name) === undefined) { + throw new HostError(1426, 'OLE error code 0x80020006: Unknown name.'); + } + this.appValues.set(upper, value); + return; + } + // the screen holds what it is told; a name it has never had is 1734, as on any form + if (obj === SCREEN_HANDLE) { + const upper = name.toUpperCase(); + if (!this.screenValues.has(upper) && this.screenProp(name) === undefined) { + throw new HostError(1734, `Property ${upper} is not found.`); + } + this.screenValues.set(upper, value); + return; + } const host = this.hosted.get(obj); if (host) { if (host.member(name) === 'none' && host.get(name) === undefined) { @@ -2067,6 +2185,7 @@ export class Desktop implements HostReads { const refused = target.refusesWrite(name); if (refused !== undefined) throw new HostError(refused, `${name.toUpperCase()} is a read-only property`); // a property may hold an object: a form keeps the toolbar it put up in one of its own + if (name.toUpperCase() === 'NAME' && typeof value === 'string') target.renamed = value; const object = handleOf(value); if (object !== undefined) target.setObjectValue(name, object); else if (isDate(value) || isDateTime(value)) target.setMomentValue(name, value); @@ -2138,6 +2257,7 @@ export class Desktop implements HostReads { if (result === undefined) throw new HostError(1925, `Unknown member ${name.toUpperCase()}.`); return result; } + if ((obj === SCREEN_HANDLE || obj === APP_HANDLE) && name.toUpperCase() === 'FORMS') return this.screenForm(args[0]); const target = obj === SCREEN_HANDLE ? undefined : this.handles[obj]; // a formset shows, hides and releases all of its forms at once, and hands them out by number if (target instanceof FormSetInstance) { @@ -2183,7 +2303,8 @@ export class Desktop implements HostReads { return null; case 'RELEASE': { const form = target instanceof FormInstance ? target : target?.form(); - return form ? this.releaseForm(form, { queryUnload: false }).then(() => null) : null; + // measured: Release() answers .T. + return form ? this.releaseForm(form, { queryUnload: false }).then(() => true) : null; } case 'SHOW': target?.set('Visible', true); @@ -2347,7 +2468,9 @@ export class Desktop implements HostReads { } case 'ADDPROPERTY': { const [property, value] = args; - if (typeof property !== 'string' || !target) return false; + if (typeof property !== 'string') return false; + if (obj === SCREEN_HANDLE) return this.addProperty(SCREEN_HANDLE, property, value ?? null); + if (!target) return false; return this.addProperty(target.handle, property, value ?? null); } case 'RESETTODEFAULT': { @@ -2527,6 +2650,7 @@ export class Desktop implements HostReads { // program runs can name one. `AddObject("ole1", "olecontrol", "WMPlayer.OCX")` is how // the samples reach the Media Player. const [name, klass, ole] = args; + if (obj === SCREEN_HANDLE) return this.screenAddObject(String(vmToProp(name ?? '') ?? ''), String(vmToProp(klass ?? '') ?? '')); if (!target) throw new HostError(1943, 'Member does not evaluate to an object.'); return this.addObject( target, @@ -2571,6 +2695,9 @@ export class Desktop implements HostReads { if (target?.isOleControl) { throw new HostError(1429, `${target.name} is an ActiveX control; this runtime cannot call into COM`); } + // an event the object has, called with no code written for it, answers .T. - measured on + // Init, Click, Destroy, Valid and the rest, which CodeMine leans on to fire a page's Init + if (target && (target.hasEvent(name) || target.hasMethodName(name))) return true; return undefined; } } @@ -2607,6 +2734,23 @@ export class Desktop implements HostReads { * `AddObject(cName, cClass)`: a control created while the form runs. VFP adds it hidden, so * the code that follows can position it before it appears. */ + /** + * `_SCREEN.AddObject(cName, cClass)`: an object of the class, made as CREATEOBJECT makes one, + * held by the screen under that name. A CodeMine application keeps its global object manager + * there - `_SCREEN.AddObject('cmGlobalObjectManager', 'cmGlobalObjectManager')`, a class its + * procedure file defines - and reaches it as `_SCREEN.cmGlobalObjectManager` from then on. + */ + private async screenAddObject(name: string, className: string): Promise { + if (!this.createNamedObject) throw new HostError(1733, `Class definition ${className.toUpperCase()} is not found.`); + const made = await this.createNamedObject(className); + // an Init that refused leaves the screen without the member, as it leaves a form without one + if (made === null || made === false) return false; + this.screenValues.set(name.toUpperCase(), made); + const handle = handleOf(made); + if (handle !== undefined) this.handles[handle]?.set('Name', name.toUpperCase(), 'program'); + return true; + } + private addObject(parent: RuntimeObject, name: string, className: string, oleClass = ''): Promise | VmValue { const type = baseClassToControlType(className); if (type === null || type === 'Form') throw new HostError(1733, `Class definition ${className.toUpperCase()} is not found.`); @@ -2790,6 +2934,13 @@ export class Desktop implements HostReads { * 31, and the answer is .T. whether or not the object already had the property. */ addProperty(obj: number, name: string, value: VmValue): boolean { + if (obj === SCREEN_HANDLE) { + // an array property on the screen is kept as its first value, which is all anything + // reads of one there + const bare = (arraySubscripts(name)?.name ?? name).toUpperCase(); + this.screenValues.set(bare, value ?? false); + return true; + } const target = this.handles[obj]; if (!target) return false; const sized = arraySubscripts(name); diff --git a/tests/runtime/formsAndAccessors.test.ts b/tests/runtime/formsAndAccessors.test.ts new file mode 100644 index 0000000..9689586 --- /dev/null +++ b/tests/runtime/formsAndAccessors.test.ts @@ -0,0 +1,310 @@ +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { setApi } from '@renderer/api/foxdev'; +import { createMemoryApi } from '@renderer/api/memoryApi'; +import { useSessionStore } from '@renderer/runtime/session'; +import { createProjectSource } from '@renderer/runtime/projectSource'; +import { loadFoxVm } from '../../src/wasm/foxvm/loader'; + +const source = createProjectSource(); +const printed = () => + useSessionStore + .getState() + .output.filter((o) => o.kind === 'output') + .map((o) => o.text.replace(/\s+/g, ' ').trim()); + +beforeAll(async () => { + await loadFoxVm(); +}); + +beforeEach(() => { + setApi(createMemoryApi()); + useSessionStore.getState().cancel(); + useSessionStore.setState({ output: [] }); +}); + +// Every expectation here was measured in Visual FoxPro 9 (the probe also had the wrapper's own +// form open, which is one more form than these programs make). +describe('_SCREEN.Forms', () => { + it('counts forms, topmost first, and leaves a Custom object out', async () => { + await useSessionStore.getState().execute( + source, + [ + 'LOCAL c1, o1, o2', + 'c1 = CREATEOBJECT("custom")', + 'o1 = CREATEOBJECT("form")', + 'o1.Name = "one"', + 'o2 = CREATEOBJECT("form")', + 'o2.Name = "two"', + '? "fc", _SCREEN.FormCount', + '? "vt", VARTYPE(_SCREEN.Forms(1))', + '? "names", _SCREEN.Forms(1).Name, _SCREEN.Forms(2).Name', + ].join('\n'), + ); + expect(printed()).toEqual(['fc 2', 'vt O', 'names two one']); + }); + + it('is walked by FOR EACH', async () => { + await useSessionStore.getState().execute( + source, + [ + 'LOCAL o1, o2, oF, n', + 'o1 = CREATEOBJECT("form")', + 'o1.Name = "one"', + 'o2 = CREATEOBJECT("form")', + 'o2.Name = "two"', + 'n = 0', + 'FOR EACH oF IN _SCREEN.Forms', + ' n = n + 1', + ' ? "each", n, oF.Name, oF.BaseClass', + 'ENDFOR', + '? "count", n', + 'TRY', + ' x = _SCREEN.Forms(3)', + 'CATCH TO e', + ' ? "past the end", e.ErrorNo', + 'ENDTRY', + ].join('\n'), + ); + expect(printed()).toEqual(['each 1 two Form', 'each 2 one Form', 'count 2', 'past the end 1924']); + }); + + it('leaves FOR EACH over an array property as it was', async () => { + await useSessionStore.getState().execute( + source, + [ + 'LOCAL o, x', + 'o = CREATEOBJECT("holder")', + 'FOR EACH x IN o.aItems', + ' ? "item", x', + 'ENDFOR', + 'DEFINE CLASS holder AS Custom', + ' DIMENSION aItems[2]', + ' PROCEDURE Init', + ' THIS.aItems[1] = "a"', + ' THIS.aItems[2] = "b"', + ' ENDPROC', + 'ENDDEFINE', + ].join('\n'), + ); + expect(printed()).toEqual(['item a', 'item b']); + }); +}); + +describe('a released form', () => { + it('is .NULL. to every variable and property still holding it', async () => { + // measured in Visual FoxPro 9, and what CodeMine's CloseSplash leans on: it asks + // ISNULL(THIS.oSplash) before releasing a splash screen that may have gone already + await useSessionStore.getState().execute( + source, + [ + 'LOCAL oF, oHold, oF2, x', + 'oF = CREATEOBJECT("form")', + 'oHold = CREATEOBJECT("custom")', + 'oHold.AddProperty("oSplash", oF)', + 'oF.Release()', + '? "var", VARTYPE(oF), ISNULL(oF)', + '? "prop", VARTYPE(oHold.oSplash), ISNULL(oHold.oSplash)', + 'oF2 = CREATEOBJECT("form")', + 'oHold.oSplash = oF2', + 'oF2 = .NULL.', + '? "held", VARTYPE(oHold.oSplash), ISNULL(oHold.oSplash)', + 'x = oHold.oSplash.Release()', + '? "ret", x', + '? "after", VARTYPE(oHold.oSplash), ISNULL(oHold.oSplash)', + ].join('\n'), + ); + expect(printed()).toEqual(['var X .T.', 'prop X .T.', 'held O .F.', 'ret .T.', 'after X .T.']); + }); +}); + +describe('a form made from a class', () => { + it('has no DataEnvironment', async () => { + // measured: only a form read from a form file has one + await useSessionStore.getState().execute( + source, + [ + 'LOCAL o, a[1]', + 'o = CREATEOBJECT("fx")', + '? "pem", PEMSTATUS(o, "DataEnvironment", 5)', + '? "type", TYPE("o.DataEnvironment")', + 'o = CREATEOBJECT("form")', + '? "form", PEMSTATUS(o, "DataEnvironment", 5)', + 'DEFINE CLASS fx AS Form', + 'ENDDEFINE', + ].join('\n'), + ); + expect(printed()).toEqual(['pem .F.', 'type U', 'form .F.']); + }); +}); + +describe('Access and Assign methods', () => { + it('stand in for reading and writing the property, except inside themselves', async () => { + // measured in Visual FoxPro 9; CodeMine's lDataManagerPresent_Access is the lFlag case + await useSessionStore.getState().execute( + source, + [ + 'LOCAL o', + 'o = CREATEOBJECT("cacc")', + '? "read", o.nValue', + 'o.nValue = 5', + '? "after write", o.nValue', + '? "inner", o.ReadInside()', + '? "flag", o.lFlag', + 'o.lFlag = .T.', + '? "flag2", o.lFlag', + '? "calls", o.nAccessed, o.cAssigned', + 'DEFINE CLASS cacc AS Custom', + ' nValue = 1', + ' lFlag = .NULL.', + ' nAccessed = 0', + ' cAssigned = ""', + ' PROCEDURE nValue_Access', + ' THIS.nAccessed = THIS.nAccessed + 1', + ' RETURN THIS.nValue * 10', + ' ENDPROC', + ' PROCEDURE nValue_Assign(vNew)', + ' THIS.cAssigned = TRANSFORM(vNew) + "/" + TRANSFORM(THIS.nValue)', + ' THIS.nValue = vNew + 1', + ' ENDPROC', + ' PROCEDURE ReadInside', + ' RETURN THIS.nValue', + ' ENDPROC', + ' PROCEDURE lFlag_Access', + ' IF ISNULL(THIS.lFlag)', + ' THIS.lFlag = .F.', + ' ENDIF', + ' RETURN THIS.lFlag', + ' ENDPROC', + 'ENDDEFINE', + ].join('\n'), + ); + expect(printed()).toEqual(['read 10', 'after write 60', 'inner 60', 'flag .F.', 'flag2 .T.', 'calls 3 5/1']); + }); + + it('hand an array property its subscript, and leave a write, ALEN and DIMENSION alone', async () => { + // measured in Visual FoxPro 9; the Solution sample aa_fun.scx reads THIS.myButtons[1] in Init. + // cLog records what each read handed the Access method. + await useSessionStore.getState().execute( + source, + [ + 'LOCAL o', + 'o = CREATEOBJECT("carr")', + '? "e1", o.myButtons[1], o.TakeLog()', + '? "e2", o.myButtons[2], o.TakeLog()', + '? "paren", o.myButtons("x"), o.TakeLog()', + '? "whole", VARTYPE(o.myButtons), o.TakeLog()', + 'o.myButtons[1] = 5', + '? "write", o.TakeLog()', + '? "written", o.myButtons[1], o.TakeLog()', + '? "alen", ALEN(o.myButtons), o.TakeLog()', + 'DIMENSION o.myButtons[4]', + '? "alen2", ALEN(o.myButtons), o.TakeLog()', + '? "inside", o.ReadOther(), o.TakeLog()', + 'x = o', + '? "private", x.myButtons[3], x.TakeLog()', + 'DEFINE CLASS carr AS Custom', + ' DIMENSION myButtons[3]', + ' cLog = ""', + ' PROCEDURE Init', + ' THIS.myButtons[1] = "a"', + ' THIS.myButtons[2] = "b"', + ' THIS.myButtons[3] = "c"', + ' ENDPROC', + ' PROCEDURE myButtons_Access', + ' LPARAMETERS nIndex', + ' THIS.cLog = THIS.cLog + "[" + VARTYPE(nIndex) + TRANSFORM(nIndex) + "]"', + ' IF VARTYPE(nIndex) = "N"', + ' RETURN THIS.myButtons[nIndex]', + ' ENDIF', + ' RETURN "whole"', + ' ENDPROC', + ' PROCEDURE ReadOther', + ' RETURN THIS.myButtons[2]', + ' ENDPROC', + ' PROCEDURE TakeLog', + ' LOCAL c', + ' c = THIS.cLog', + ' THIS.cLog = ""', + ' RETURN "log" + c', + ' ENDPROC', + 'ENDDEFINE', + ].join('\n'), + ); + expect(printed()).toEqual([ + 'e1 a log[N1]', + 'e2 b log[N2]', + 'paren whole log[Cx]', + 'whole C log[N1]', + 'write log', + 'written 5 log[N1]', + 'alen 3 log', + 'alen2 4 log', + 'inside b log[N2]', + 'private c log[N3]', + ]); + }); +}); + +describe('an event called as a method', () => { + it('answers .T. when nothing was written for it', async () => { + // measured: CodeMine's pageframe calls m.oPage.Init() to fire what VFP does not + await useSessionStore.getState().execute( + source, + [ + 'LOCAL o, p', + 'o = CREATEOBJECT("fx")', + 'p = o.pf.Page1', + '? "page", p.Init(), p.Click(), p.Destroy(), p.Activate()', + '? "form", o.Init(), o.Load(), o.Click()', + '? "cmd", o.cmd.Init(), o.cmd.Click(), o.cmd.Valid(), o.cmd.When()', + 'DEFINE CLASS fx AS Form', + ' ADD OBJECT pf AS PageFrame WITH PageCount = 2', + ' ADD OBJECT cmd AS CommandButton', + 'ENDDEFINE', + ].join('\n'), + ); + expect(useSessionStore.getState().output.filter((o) => o.kind === 'error').map((o) => o.text)).toEqual([]); + expect(printed()).toEqual(['page .T. .T. .T. .T.', 'form .T. .T. .T.', 'cmd .T. .T. .T. .T.']); + }); +}); + +describe('a page', () => { + it('answers PageOrder with its place in the pageframe', async () => { + // measured; CodeMine's ActivePage_Assign sets ActivePage from a page's PageOrder + await useSessionStore.getState().execute( + source, + [ + 'LOCAL o', + 'o = CREATEOBJECT("fx")', + '? "order", o.pf.Pages(1).PageOrder, o.pf.Pages(2).PageOrder, o.pf.Pages(3).PageOrder', + '? "active", o.pf.ActivePage', + 'o.pf.ActivePage = o.pf.Pages(2).PageOrder', + '? "after", o.pf.ActivePage', + 'DEFINE CLASS fx AS Form', + ' ADD OBJECT pf AS PageFrame WITH PageCount = 3', + 'ENDDEFINE', + ].join('\n'), + ); + expect(printed()).toEqual(['order 1 2 3', 'active 1', 'after 2']); + }); +}); + +describe('_VFP', () => { + it('takes what a program writes on it, and refuses a name it does not have', async () => { + // measured: CodeMine's RestoreSizeAndPosition writes the main window's size this way + await useSessionStore.getState().execute( + source, + [ + '_VFP.Height = 500', + '_VFP.Width = 700', + '? "after", _VFP.Height, _VFP.Width', + 'TRY', + ' _VFP.Foo = 1', + 'CATCH TO e', + ' ? "unknown", e.ErrorNo', + 'ENDTRY', + ].join('\n'), + ); + expect(printed()).toEqual(['after 500 700', 'unknown 1426']); + }); +}); diff --git a/tests/runtime/objectModel.test.ts b/tests/runtime/objectModel.test.ts index 92b4fdb..79a1b62 100644 --- a/tests/runtime/objectModel.test.ts +++ b/tests/runtime/objectModel.test.ts @@ -100,6 +100,22 @@ beforeAll(async () => { }); describe('runtime object model', () => { + it('_SCREEN holds what a program writes to it and what AddProperty gives it', () => { + // a CodeMine application's first lines: the caption, and a property that carries the + // start-up parameter across CLEAR ALL + const desktop = new Desktop(); + desktop.setProp(SCREEN_HANDLE, 'Caption', 'ShutterDesign II'); + expect(desktop.getProp(SCREEN_HANDLE, 'CAPTION')).toBe('ShutterDesign II'); + + expect(desktop.getMember(SCREEN_HANDLE, 'uCodeMineAppParameter')).toBe('none'); + expect(desktop.callMethod(SCREEN_HANDLE, 'AddProperty', ['uCodeMineAppParameter', 'x'])).toBe(true); + expect(desktop.getProp(SCREEN_HANDLE, 'ucodemineappparameter')).toBe('x'); + expect(desktop.getMember(SCREEN_HANDLE, 'uCodeMineAppParameter')).toBe('prop'); + + // a name the screen has never had is refused, as it is on a form + expect(() => desktop.setProp(SCREEN_HANDLE, 'cNothing', 1)).toThrow(/CNOTHING is not found/); + }); + it('builds the live tree from a form document', () => { const { desktop } = makeSession(); const form = desktop.instantiate(sampleDoc().form, -1);