Source: aframe-pw-utils.js

const { expect } = require('@playwright/test');

const A = exports;


/* Set default values. */
A.cursorEntitySelector = 'a-scene'

/**
 * Fire a custom event
 * @param {object} o           - Object that wraps all the function parameters
 * @param {string} o.entitySelector  - The DOM Selector of the entity to fire the event on
 * @param {string} o.event     - The name of the custom event to fire
 * @param {object} o.detail    - An optional object containing additional detail to include on the event.
 */
exports.pfFireCustomEvent = function (o) {
    const event = new CustomEvent(o.event, {
        detail: o.detail
    });
    const el = document.querySelector(o.entitySelector)
    el.dispatchEvent(event);
}

/**
 * Fire a custom event 
 * @param {string} entitySelector  - The DOM Selector of the entity to fire the event on
 * @param {string} event     - The name of the custom event to fire
 * @param {object} detail    - An optional object containing additional detail to include on the event.
 */
 exports.fireCustomEvent = async function (entitySelector,
                                           event, 
                                           detail = null) {

  await A.page.evaluate(A.pfFireCustomEvent,
                        {entitySelector: entitySelector,
                         event: event,
                         detail: detail});
}

/**
 * Enter VR (page function)
 * This function also simulates connnection of a pair of Oculus Quest 2 controllers, on entry to VR.
 */
exports.pfXrEnterVR = function () {

    const scene = document.querySelector('a-scene')    

    // For now, we hardcode the presence of 2 x Oculus Quest v3 controllers
    // (these are the ones that come with the Quest 2).
    var trackedControlsSystem = scene && scene.systems['tracked-controls-webxr']
    if (!trackedControlsSystem) { return false; }
    trackedControlsSystem.controllers.push({hand: 'right', handedness: 'right', profiles: ['oculus-touch-controls', 'oculus-touch-v3']})
    trackedControlsSystem.controllers.push({hand: 'left', handedness: 'left', profiles: ['oculus-touch-controls', 'oculus-touch-v3']})

    scene.emit('controllersupdated');

    return;
}

/**
 * Enter VR
 * This function also simulates connnection of a pair of Oculus Quest 2 controllers, on entry to VR.
 * 
 */
exports.enterVR = async function () {

  const page = A.page;
  
  await page.click('.a-enter-vr-button')
  await page.evaluate(A.pfXrEnterVR);

}

/**
 * Exit VR
 * This function also simulates disconnnection of a pair of Oculus Quest 2 controllers, on entry to VR.
 */
exports.exitVR = async function () {

    const page = A.page;
    
    await page.keyboard.press('Escape');
    
    await A.fireCustomEvent('#leftHand', 'controllerdisconnected', {name: "oculus-touch-controls"});
    await A.fireCustomEvent('#rightHand', 'controllerdisconnected', {name: "oculus-touch-controls"});
}

/**
 * Set an entity's local position (object3D.position)
 * @param {object} o - Object that wraps all the function parameters
 * @param {string} o.entitySelector - The DOM Selector of the entity to re-position
 * @param {float} o.x - The value to set for the x co-ordinate
 * @param {float} o.y - The value to set for the y co-ordinate
 * @param {float} o.z - The value to set for the z co-ordinate
 */
exports.pfSetEntityPosition = function (o) {

      const el = document.querySelector(o.entitySelector);
      const object = el.object3D;

      if (o.x) {
          object.position.x = o.x
      }
      if (o.y) {
          object.position.y = o.y
      }
      if (o.z) {
          object.position.z = o.z
      }

      return;
}

/**
* Set an entity's local position (object3D.position)
* This can be used on any entity, but primarily intended for
* entities that can move autonomously (e.g. cameras and controllers).
* For realistc testing of entities within the scene that don't move autonomously
* consSelectorer moving them using the appropriate ens-user controls
* (e.g. Transform Controls, grab and move etc.)
* @param {enitySelector}  - The DOM Selector of the entity
* @param {float} x - The value to set for the x co-ordinate
* @param {float} y - The value to set for the y co-ordinate
* @param {float} z - The value to set for the z co-ordinate
*/
exports.setEntityPosition = async function(entitySelector, x, y, z) {

    await A.page.evaluate(A.pfSetEntityPosition, {entitySelector: entitySelector, x: x, y: y, z: z});
}

/**
 * Get an object's local position (object3D.position)
 * @param {entitySelector}  - The DOM Selector of the entity
 */
exports.pfgetEntityPosition = function(entitySelector) {

    const el = document.querySelector(entitySelector);
    const object = el.object3D;

    return(object.position);
}

/**
* Get an entity's local position (object3D.position)
* @param {entitySelector}  - The DOM Selector of the entity
*/
exports.getEntityPosition = async function(entitySelector) {

    const position = await A.page.evaluate(A.pfgetEntityPosition, entitySelector);
    return(position);
}

/**
* Get an entity's material (object3D.material of its Mesh)
* @param {entitySelector}  - The DOM Selector of the element to query
*/
exports.pfGetEntityMaterial = function(entitySelector) {

    const el = document.querySelector(entitySelector);
    const object = el.getObject3D('mesh');

    return(object.material);
}

/**
* Get an entity's material (object3D.material of its Mesh)
* @param {entitySelector}  - The DOM Selector of the element to query
*/
exports.getEntityMaterial = async function(entitySelector) {

    const material = await A.page.evaluate(A.pfGetEntityMaterial, entitySelector);
    return(material);
}

/**
 * Simulate a "mousenter" or "mouseleave" cursor event for an entity.
 * @param {object} o           - Object that wraps all the function parameters
 * @param {string} o.cursorSelector - The DOM Selector of the entity where the cursor is configured.
 * @param {string} o.targetSelector  - The DOM Selector of the entity that the mouse is hovering over.
 * @param {string} o.eventName  - The name of the event to emit.
 */
exports.pfCursorMouseEvent = function(o) {

    const target = document.querySelector(o.targetSelector);
    const event = new CustomEvent(o.eventName, {
      detail: {intersectedEl: target},
      bubbles: true
    });
    const cursor = document.querySelector(o.cursorSelector);

    // cursor dispatches event on both entities - simulate this.
    cursor.dispatchEvent(event);
    target.dispatchEvent(event);
}

/**
 * Simulate a "click" cursor event for an entity.
 * @param {object} o           - Object that wraps all the function parameters
 * @param {string} o.cursorSelector  - The DOM Selector of the entity where the cursor is configured.
 * @param {string} o.targetSelector  - The DOM Selector of the entity that is clicked.
 */
exports.pfClick = function(o) {

    const target = document.querySelector(o.targetSelector);
    const targetObject = target.getObject3D('mesh');
    const event = new CustomEvent('click', {
      detail: {intersection: { object: targetObject}},
      bubbles: true
    });
    const cursor = document.querySelector(o.cursorSelector);

    // cursor dispatches event on both entities - simulate this.
    cursor.dispatchEvent(event);
    target.dispatchEvent(event);
}

A.page = null;

/**
 * Set a page as the page that functions apply to, and brings it into focus.
 * This lasts until this page is called with a different page
 * @param {object} page         - The Playwright "page" object for the page that should become active. 
 * @param {boolean} viewLogs    - Set to true to view all console logs for the page in the Playwright test output.
 */
 exports.setPage = function(page, viewLogs = false) {
  A.page = page;
  page.bringToFront()

  // Set up listener so that a page error leads to immediate test failure.
  page.on("pageerror", (err) => {
    console.log(err)    
    expect(false).toBe(true);
  })

  if (viewLogs) { 
    page.on('console', async msg => {
        
      /* Sample code on playwright page.  
         Achieves neater logging results than console.log(msg), esp. for warnings & errors */
      const values = [];
      for (const arg of msg.args())
        values.push(await arg.jsonValue());
      console.log(...values);
    });
  }
}

/**
 * Set the entity on which the cursor component is configured
 * This lasts until this function is called with a different entitySelector.
 * @param {string} entitySelector      - The DOM Selector of the entity that the cursor component is configured on.
 */
exports.setCursorEntity = function(entitySelector) {
  A.cursorEntitySelector = entitySelector;
}

/**
 * Check that a string is a naked element id (without leading #)
 */
 exports.checkId = function(elementId) {
  expect(elementId).toBeNakedId();
}

/**
 * Simulate a "mouseenter" cursor event for an entity.
 * This assumes that a cursor entity has been set up by a call to A.setCursorEntity.
 * @param {string} targetSelector  - The DOM Selector of the entity that the mouse is hovering over.
 */
 exports.cursorMouseEnter = async function(targetSelector) {

   await A.page.evaluate(A.pfCursorMouseEvent, {cursorSelector: A.cursorEntitySelector,
                                                targetSelector: targetSelector,
                                                eventName: "mouseenter"})
}

/**
 * Simulate a "mouseleave" cursor event for an entity.
 * This assumes that a cursor entity has been set up by a call to A.setCursorEntity.
 * @param {string} targetSelector  - The DOM Selector of the entity that the mouse is hovering over.
 */
 exports.cursorMouseLeave = async function(targetSelector) {

   await A.page.evaluate(A.pfCursorMouseEvent, {cursorSelector: A.cursorEntitySelector,
                                                targetSelector: targetSelector,
                                                eventName: "mouseleave"})
}

/**
 * Simulate a "click" cursor event for an entity.
 * This assumes that a cursor entity has been set up by a call to A.setCursorEntity.
 * @param {string} targetSelector  - The DOM Selector of the entity that the mouse is hovering over.
 */
 exports.cursorClick = async function(targetSelector) {

  // A little unsure about this implementation.  Would it be preferable to have an A-Frame A.mouseEnter() intermediary
  // function in aframe-utils?
  // Or make this an A-Frame utils function, with cursorSelector set up in advance, as we do with 'page'?
  await A.page.evaluate(A.pfClick, {cursorSelector: A.cursorEntitySelector,
                                    targetSelector: targetSelector})

}

/**
 * Get an rgb object representing a color (as returned in e.g. object material), from a 3 digit or 6 digit hex string.
 * @param {hexNumber} - A 3 digit or 6 digit hex number, e.g. 0xFFF or 0xFFFFFF.
 */
exports.color = function(hexNumber) {

  const rgb3Component = (n) => {
    const value = parseInt(hexString.padStart(3, '0')[n], 16)/0xF;
    return value;
  }
  const rgb6Component = (n) => {
    const value = parseInt(hexString.padStart(6, '0').substring(2 * n, 2 * n + 2), 16)/0xFF;
    return value;
  }

  const hexString = hexNumber.toString(16);
  if (hexString.length <= 3) {
    r = rgb3Component(0);
    g = rgb3Component(1);
    b = rgb3Component(2);
  }
  else if (hexString.length <= 6) {
    r = rgb6Component(0);
    g = rgb6Component(1);
    b = rgb6Component(2);
  }

  return ({r: r, g: g, b: b});
}

/**
* Get an attribute value from an entity.
* @param {object} o           - Object that wraps all the function parameters
* @param {o.entitySelector}   - The DOM selector of the entity to query 
* @param {o.attributeName}    - The name of the attribute to query. 
* @returns {attributeValue}   - An object containing the value of the attribute
*/
exports.pfGetEntityAttributeValue = function(o) {
    
  const el = document.querySelector(o.entitySelector);
  const attributeValue = el.getAttribute(o.attributeName);
    
  return(attributeValue);
}

/**
* Get an attribute value from an entity.
* This uses a page function, rather than just querying the DOM, because
* A-Frame does not always flush updates to the DOM.
* @param {entitySelector}   - The DOM selector of the entity to query 
* @param {attributeName}    - The name of the attribute to query.
* @returns {attributeValue} - An object containing the value of the attribute
*/
exports.getEntityAttributeValue = async function(entitySelector, attributeName) {
  
  const attributeValue = await A.page.evaluate(A.pfGetEntityAttributeValue,
                                               {entitySelector: entitySelector,
                                                attributeName: attributeName});
  return(attributeValue);
}

/**
* Get an entity's visibility & opacity setttings
* @param {string}    entitySelector   - The DOM selector of the entity to query 
* @returns {object}  o          - Object that wraps all the return parameters
* @returns {boolean} o.opacity  - Opacity of the entity (0 to 1)
* @returns {boolean} o.visible  - Whether or not the entity is visible.  This includes analysing the
*                                 ThreeJS object tree to check for parents whos visibility may be set to false. */
exports.pfGetEntityVisibility = function(entitySelector) {

  // recursive function to check visibility of an object.
  function getVisibility(object) {
    var visible = object.visible
    if (object.parent) {
      visible = (visible && getVisibility(object.parent));
    }
    return visible
  }
    
  const el = document.querySelector(entitySelector);  
  const mesh = el.getObject3D('mesh')

  const o = {    
    opacity: mesh.material.opacity,
    visible: getVisibility(mesh)
  }
    
  return(o);
}

/**
* Get an entity's visibility & clipping data.
* This could be e.g. a Simulation Result, a Measurement sphere, or an anatomy or device entity.
* @param {string}   entitySelector    - The DOM selector of the entity to query
* @returns {object} o           - Object that wraps all the return parameters
* @returns {boolean} o.opacity  - Opacity of the entity (0 to 1)
* @returns {boolean} o.visible  - Whether or not the entity is visible.  This only consider's the object3D's local setting, and doesn't consider the fact that parent objects may be invisible, or may have 0 opacity.
*/
exports.getEntityVisibility = async function(entitySelector) {
  const page = A.page;
  
  await page.locator(entitySelector);
  const o = await page.evaluate(A.pfGetEntityVisibility, entitySelector);
  
  return o;
}

/**
* Point a controller towards another entity.
* @param {string} controllerSelector - The DOM Selector of the controller to point at a target.
* @param {string} targetSelector     - The DOM Selector of the target to point at.
* @param {string} controllerType     - The type of controller being used.  One of: oculus-touch, oculus-touch-v2, oculus-touch-v3
* 
* Similar to pointEntityAt, but allows for adjustment based on ray origin & ray direction, which 
* are set up based on the specified controller.
*/ 

exports.pointControllerAt = async function(controllerSelector, 
                                       targetSelector,
                                       controllerType) {

  const page = A.page;
  const o = {entitySelector: controllerSelector,
             targetSelector: targetSelector};

  switch (controllerType) {

    // Data for controllers here taken from https://github.com/aframevr/aframe/blob/v1.3.0/src/components/oculus-touch-controls.js
    // 
    // Can easily be extended as desired for other controller models.
    //
    // Minor issue: it doesn't yet adjust for left/right hand - x offsets vary in +/- signs.
    // Data below is for the right controller.
    
    case "oculus-touch":
      o.originOffset = {x: -0.002, y: -0.005, z: -0.03}
      o.direction = {x: 0, y: -0.8, z: -1}
      break;

    case "oculus-touch-v2":          
      o.originOffset = {x: 0.01, y: -0.02, z: 0}
      o.direction = {x: 0, y: -0.5, z: -1}
      break;

    case "oculus-touch-v3":
      o.originOffset = {x: -0.015, y: 0.005, z: 0}            
      o.direction = {x: 0, y: 0, z: -1}
      break;

    default:
      // Unknown controller.
      console.warn("Unknown controller type: ", controllerType)
      o.originOffset = {x: 0, y: 0, z: 0}
      o.direction = {x: 0, y: 0, z: -1}
      break;

  }

  await page.locator(controllerSelector);
  await page.evaluate(A.pfPointEntityAt, o);

}

/**
* Point one entity towards another entity.
* 
* @param {string} entitySelector - The DOM Selector of the entity to point at a target.
* @param {string} targetSelector - The DOM Selector of the target to point at.
*/

exports.pointEntityAt = async function(entitySelector, 
                                       targetSelector) {

  const page = A.page;
  await page.locator(entitySelector);
  await page.evaluate(A.pfPointEntityAt,
                      {entitySelector: entitySelector,
                       targetSelector: targetSelector});
}

/**
* Page Function to point one entity towards another entity.
* Optionally allows for a configurable offset & direction for the "eyes" of the entity.
* This is useful for controllers, where the ray origin & direction may vary by controller type.
* @param {object} o                - Object that wraps all the function parameters
* @param {string} o.entitySelector - The DOM Selector of the entity to point at a target.
* @param {string} o.targetSelector - The DOM Selector of the target to point at.
* @param {object} o.originOffset   - x, y, z co-ordinates.  Optional, default is (0, 0, 0)
* @param {object} o.direction      - x, y, z direction vector for the direction of the entity's "eyes".  Optional, default is (0, 0, -1)
*/
exports.pfPointEntityAt = function(o) {


  const targetWorldPosition = new THREE.Vector3();  
  const baseDirection = new THREE.Vector3(0, 0, 1);
  const entityDirection = new THREE.Vector3();
  const offsetVector = new THREE.Vector3();

  if (o.direction !== undefined) {
   entityDirection.set(o.direction.x, o.direction.y, o.direction.z).normalize();
  }
  else {
    entityDirection.set(0, 0, -1);
  }

  if (o.originOffset !== undefined) {
    offsetVector.set(o.originOffset.x, o.originOffset.y, o.originOffset.z);
  }
  else {
    offsetVector.set(0, 0, 0);
  }
    
  const quaternion = new THREE.Quaternion().setFromUnitVectors(entityDirection, baseDirection);

  const el = document.querySelector(o.entitySelector);
  const object = el.object3D;
  
  const target = document.querySelector(o.targetSelector);
  target.object3D.getWorldPosition(targetWorldPosition);

  // apply the origin offset to the target, in the object's local space.
  ///console.log("World space target position:",  targetWorldPosition.x, targetWorldPosition.y, targetWorldPosition.z)
  object.worldToLocal(targetWorldPosition)
  targetWorldPosition.sub(offsetVector)
  object.localToWorld(targetWorldPosition)
  //console.log("Allowing for offset, aim at: ",  targetWorldPosition.x, targetWorldPosition.y, targetWorldPosition.z)

  // Now look at this target position.
  object.lookAt(targetWorldPosition);

  // and apply a quaternion to compensate for the specified direction offset
  object.quaternion.multiply(quaternion);

  return;
}