Skip to content

Commit

Permalink
Merge pull request #779 from modos189/feature/search_api
Browse files Browse the repository at this point in the history
Search API
modos189 authored Nov 11, 2024
2 parents 1fcbd28 + 44c0d20 commit c81b019
Showing 11 changed files with 1,051 additions and 497 deletions.
564 changes: 93 additions & 471 deletions core/code/search.js

Large diffs are not rendered by default.

200 changes: 200 additions & 0 deletions core/code/search_hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/* global L -- eslint */

/**
* Handles search-related hooks for the IITC.search module, adding various search result types.
*
* These functions supply default search results to the IITC search system by responding to `search` hooks with
* data for portals, geographic coordinates, OpenStreetMap locations, and portal GUIDs.
*
* @namespace hooks
* @memberof IITC.search
*/

/**
* Searches for portals by matching the query term against portal titles and adds matched results.
*
* @param {Object} query - The search query object.
* @fires hook#search
*/
window.addHook('search', (query) => {
const term = query.term.toLowerCase();

for (const [guid, portal] of Object.entries(window.portals)) {
const data = portal.options.data;
if (!data.title) continue;

if (data.title.toLowerCase().includes(term)) {
window.search.addSearchResult(query, data, guid);
}
}
});

/**
* Searches for geographical coordinates formatted as latitude, longitude and adds the results.
* Supports both decimal format (e.g., 51.5074, -0.1278) and DMS format (e.g., 50°31'03.8"N 7°59'05.3"E).
*
* @param {Object} query - The search query object.
* @fires hook#search
*/
window.addHook('search', (query) => {
const added = new Set();

// Regular expression for decimal coordinates
const decimalRegex = /[+-]?\d+\.\d+, ?[+-]?\d+\.\d+/g;
// Regular expression for DMS coordinates
const dmsRegex = /(\d{1,3})°(\d{1,2})'(\d{1,2}(?:\.\d+)?)?"\s*([NS]),?\s*(\d{1,3})°(\d{1,2})'(\d{1,2}(?:\.\d+)?)?"\s*([EW])/g;

// Convert DMS to decimal format
const parseDMS = (deg, min, sec, dir) => {
const decimal = parseFloat(deg) + parseFloat(min) / 60 + parseFloat(sec) / 3600;
return dir === 'S' || dir === 'W' ? -decimal : decimal;
};

// Universal function for adding search result
const addResult = (lat, lng) => {
const latLngString = `${lat.toFixed(6)},${lng.toFixed(6)}`;
if (added.has(latLngString)) return;
added.add(latLngString);

query.addResult({
title: latLngString,
description: 'geo coordinates',
position: L.latLng(lat, lng),
onSelected: (result) => {
for (const [guid, portal] of Object.entries(window.portals)) {
const { lat: pLat, lng: pLng } = portal.getLatLng();
if (`${pLat.toFixed(6)},${pLng.toFixed(6)}` === latLngString) {
window.renderPortalDetails(guid);
return;
}
}
window.urlPortalLL = [result.position.lat, result.position.lng];
},
});
};

// Search and process decimal coordinates
const decimalMatches = query.term.replace(/%2C/gi, ',').match(decimalRegex);
if (decimalMatches) {
decimalMatches.forEach((location) => {
const [lat, lng] = location.split(',').map(Number);
addResult(lat, lng);
});
}

// Search and process DMS coordinates
const dmsMatches = Array.from(query.term.matchAll(dmsRegex));
dmsMatches.forEach((match) => {
const lat = parseDMS(match[1], match[2], match[3], match[4]);
const lng = parseDMS(match[5], match[6], match[7], match[8]);
addResult(lat, lng);
});
});

/**
* Searches for results on OpenStreetMap based on the query term, considering map view boundaries.
*
* @param {Object} query - The search query object.
* @fires hook#search
*/
window.addHook('search', async (query) => {
if (!query.confirmed) return;

const mapBounds = window.map.getBounds();
const viewbox = `&viewbox=${mapBounds.getSouthWest().lng},${mapBounds.getSouthWest().lat},${mapBounds.getNorthEast().lng},${mapBounds.getNorthEast().lat}`;
// Bounded search allows amenity-only searches (e.g. "amenity=toilet") via special phrases
// https://wiki.openstreetmap.org/wiki/Nominatim/Special_Phrases/EN
const bounded = '&bounded=1';

const resultMap = new Set();
let resultCount = 0;

async function fetchResults(isViewboxResult) {
try {
const response = await fetch(`${window.NOMINATIM}${encodeURIComponent(query.term)}${isViewboxResult ? viewbox + bounded : viewbox}`);
const data = await response.json();

if (isViewboxResult && data.length === 0) {
// If no results found within the viewbox, try a broader search
await fetchResults(false);
return;
} else if (!isViewboxResult && resultCount === 0 && data.length === 0) {
// If no results at all
query.addResult({
title: 'No results on OpenStreetMap',
icon: '//www.openstreetmap.org/favicon.ico',
onSelected: () => true,
});
return;
}

resultCount += data.length;

data.forEach((item) => {
if (resultMap.has(item.place_id)) return; // duplicate
resultMap.add(item.place_id);

const result = {
title: item.display_name,
description: `Type: ${item.type}`,
position: L.latLng(parseFloat(item.lat), parseFloat(item.lon)),
icon: item.icon,
};

if (item.geojson) {
result.layer = L.geoJson(item.geojson, {
interactive: false,
color: 'red',
opacity: 0.7,
weight: 2,
fill: false,
pointToLayer: (featureData, latLng) =>
L.marker(latLng, {
icon: L.divIcon.coloredSvg('red'),
title: item.display_name,
}),
});
}

if (item.boundingbox) {
const [south, north, west, east] = item.boundingbox;
result.bounds = new L.LatLngBounds(L.latLng(parseFloat(south), parseFloat(west)), L.latLng(parseFloat(north), parseFloat(east)));
}

query.addResult(result);
});
} catch (error) {
console.error('Error fetching OSM data:', error);
}
}

// Start with viewbox-bounded search
await fetchResults(true);
});

/**
* Searches by GUID in the query term.
*
* @param {Object} query - The search query object.
* @fires hook#search
*/
window.addHook('search', async (query) => {
const guidRegex = /[0-9a-f]{32}\.[0-9a-f]{2}/;
const match = query.term.match(guidRegex);

if (match) {
const guid = match[0];
const data = window.portalDetail.get(guid);

if (data) {
window.search.addSearchResult(query, data, guid);
} else {
try {
const fetchedData = await window.portalDetail.request(guid);
window.search.addSearchResult(query, fetchedData, guid);
} catch (error) {
console.error('Error fetching portal details:', error);
}
}
}
});
317 changes: 317 additions & 0 deletions core/code/search_query.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
/* global IITC, L -- eslint */

/**
* @memberOf IITC.search.Query
* @typedef {Object} SearchResult
* @property {string} title - The label for this result (HTML-formatted).
* @property {string} [description] - Secondary information for this result (HTML-formatted).
* @property {L.LatLng} [position] - Position of this result.
* @property {L.LatLngBounds} [bounds] - Bounds of this result.
* @property {L.Layer|null} [layer] - Layer to be added to the map on result selection.
* @property {string} [icon] - URL to a 12x12px icon for the result list.
* @property {IITC.search.Query.onSelectedCallback} [onSelected] - Handler called when result is selected.
* May return `true` to prevent the map from being repositioned.
* You may reposition the map yourself or do other work.
* @property {IITC.search.Query.onRemoveCallback} [onRemove] - Handler called when result is removed from map.
* (because another result has been selected or the search was cancelled by the user).
*/

/**
* @memberOf IITC.search.Query
* @callback onSelectedCallback
* @param {IITC.search.Query.SearchResult} result - The selected search result.
* @param {Event} event - The event that triggered the selection.
* @returns {boolean} - Returns true to prevent map repositioning.
*/

/**
* @memberOf IITC.search.Query
* @callback onRemoveCallback
* @param {IITC.search.Query.SearchResult} result - The search result that is being removed.
* @returns {void} - No return value.
*/

/**
* Represents a search query within the IITC search module, managing query state, results, and UI rendering.
*
* This class provides functionality to handle search operations such as displaying and interacting with results,
* including selection, hover actions, and map adjustments. Hooks for custom search actions are triggered when
* a new search query is initialized.
*
* @memberof IITC.search
* @class
*/
class Query {
/**
* Initializes the search query, setting up UI elements and triggering the 'search' hook.
*
* @constructor
* @param {string} term - The search term.
* @param {boolean} confirmed - Indicates if the search is confirmed (e.g., by pressing Enter).
*/
constructor(term, confirmed) {
this.term = term;
this.confirmed = confirmed;
this.results = [];
this.resultsView = new IITC.search.QueryResultsView(term, confirmed);

window.runHooks('search', this);
}

/**
* Displays the search query results in the specified resultsView container.
*
* @memberof IITC.search.Query
* @function show
* @private
*/
show() {
this.resultsView.renderIn('#searchwrapper');
}

/**
* Hides and removes the current search results, clearing selection and hover states.
*
* @memberof IITC.search.Query
* @function show
* @private
*/
hide() {
this.resultsView.remove();
this.removeSelectedResult();
this.removeHoverResult();
}

/**
* Adds a search result to the query and triggers re-rendering of the results list.
*
* @memberof IITC.search.Query
* @function addResult
* @param {IITC.search.Query.SearchResult} result - The search result to add, including title, position, and interactions.
*/
addResult(result) {
this.results.push(result);
this.renderResults();
}

/**
* Adds a search result for a portal to the search query results.
*
* @memberof IITC.search.Query
* @function addPortalResult
* @param {Object} data - The portal data for the search result. This includes information such as title, team, level, health, etc.
* @param {string} guid - GUID if the portal.
*/
addPortalResult(data, guid) {
const team = window.teamStringToId(data.team);
const color = team === window.TEAM_NONE ? '#CCC' : window.COLORS[team];
const latLng = L.latLng(data.latE6 / 1e6, data.lngE6 / 1e6);

this.addResult({
title: data.title,
description: `${window.TEAM_SHORTNAMES[team]}, L${data.level}, ${data.health}%, ${data.resCount} Resonators`,
position: latLng,
icon: `data:image/svg+xml;base64,${btoa('@include_string:images/icon-portal.svg@'.replace(/%COLOR%/g, color))}`,

onSelected(result, event) {
const { position } = result;

if (event.type === 'dblclick') {
window.zoomToAndShowPortal(guid, latLng);
} else if (window.portals[guid]) {
if (!window.map.getBounds().contains(position)) {
window.map.setView(position);
}
window.renderPortalDetails(guid);
} else {
window.selectPortalByLatLng(latLng);
}
return true;
},
});
}

/**
* Handles keyboard interactions for selecting a result with Enter or Space keys.
*
* @memberof IITC.search.Query
* @function handleKeyPress
* @param {Event} ev - The keyboard event.
* @param {Object} result - The result being interacted with.
* @private
*/
handleKeyPress(ev, result) {
if (ev.key === ' ' || ev.key === 'Enter') {
ev.preventDefault();
const type = ev.key === ' ' ? 'click' : 'dblclick';
this.onResultSelected(result, { ...ev, type });
}
}

/**
* Renders all search results through the resultsView class and sets up event handling for each result.
*
* @memberof IITC.search.Query
* @function renderResults
* @private
*/
renderResults() {
this.resultsView.renderResults(this.results, (result, event) => this.handleResultInteraction(result, event));
}

/**
* Manages interactions with search results, such as clicks, hovers, and keyboard events.
*
* @memberof IITC.search.Query
* @function handleResultInteraction
* @param {Object} result - The result being interacted with.
* @param {Event} event - The event associated with the interaction.
* @private
*/
handleResultInteraction(result, event) {
switch (event.type) {
case 'click':
case 'dblclick':
this.onResultSelected(result, event);
break;
case 'mouseover':
this.onResultHoverStart(result);
break;
case 'mouseout':
this.onResultHoverEnd();
break;
case 'keydown':
this.handleKeyPress(event, result);
break;
}
}

/**
* Creates and returns a map layer for the given search result, which could include markers or shapes.
*
* @memberof IITC.search.Query
* @function resultLayer
* @param {Object} result - The search result object.
* @returns {L.Layer} - The generated layer for the result.
* @private
*/
resultLayer(result) {
if (!result.layer) {
result.layer = L.layerGroup();

if (result.position) {
L.marker(result.position, {
icon: L.divIcon.coloredSvg('red'),
title: result.title,
}).addTo(result.layer);
}

if (result.bounds) {
L.rectangle(result.bounds, {
title: result.title,
interactive: false,
color: 'red',
fill: false,
}).addTo(result.layer);
}
}
return result.layer;
}

/**
* Handles the selection of a search result, adjusting the map view and adding its layer to the map.
*
* @memberof IITC.search.Query
* @function onResultSelected
* @param {Object} result - The selected search result object.
* @param {Event} event - The event associated with the selection.
* @private
*/
onResultSelected(result, event) {
this.removeHoverResult();
this.removeSelectedResult();
this.selectedResult = result;

if (result.onSelected && result.onSelected(result, event)) return;

const { position, bounds } = result;
if (event.type === 'dblclick') {
if (position) {
window.map.setView(position, window.DEFAULT_ZOOM);
} else if (bounds) {
window.map.fitBounds(bounds, { maxZoom: window.DEFAULT_ZOOM });
}
} else {
if (bounds) {
window.map.fitBounds(bounds, { maxZoom: window.DEFAULT_ZOOM });
} else if (position) {
window.map.setView(position);
}
}

result.layer = this.resultLayer(result);

if (result.layer) window.map.addLayer(result.layer);
if (window.isSmartphone()) window.show('map');
}

/**
* Removes the currently selected search result from the map and performs necessary cleanup.
*
* @memberof IITC.search.Query
* @function removeSelectedResult
* @private
*/
removeSelectedResult() {
if (this.selectedResult) {
if (this.selectedResult.layer) window.map.removeLayer(this.selectedResult.layer);
if (this.selectedResult.onRemove) this.selectedResult.onRemove(this.selectedResult);
}
}

/**
* Starts a hover interaction on a search result, displaying its layer on the map.
*
* @memberof IITC.search.Query
* @function onResultHoverStart
* @param {Object} result - The result being hovered over.
* @private
*/
onResultHoverStart(result) {
this.removeHoverResult();
this.hoverResult = result;

if (result === this.selectedResult) return;

result.layer = this.resultLayer(result);

if (result.layer) window.map.addLayer(result.layer);
}

/**
* Ends a hover interaction by removing the hover layer from the map if it is not selected.
*
* @memberof IITC.search.Query
* @function removeHoverResult
* @private
*/
removeHoverResult() {
if (this.hoverResult && this.hoverResult.layer && this.hoverResult !== this.selectedResult) {
window.map.removeLayer(this.hoverResult.layer);
}
this.hoverResult = null;
}

/**
* Handles the end of a hover event, removing the hover layer from the map.
*
* @memberof IITC.search.Query
* @function onResultHoverEnd
* @private
*/
onResultHoverEnd() {
this.removeHoverResult();
}
}

IITC.search.Query = Query;
197 changes: 197 additions & 0 deletions core/code/search_query_results_view.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/* global IITC -- eslint */

/**
* Represents the view for displaying search query results in the IITC search module.
*
* @memberof IITC.search
* @class
*/
class QueryResultsView {
/**
* Initializes the query results view, setting up the display elements for the search term.
*
* @constructor
* @param {string} term - The search term.
* @param {boolean} confirmed - Indicates if the search is confirmed (e.g., by pressing Enter).
*/
constructor(term, confirmed) {
this.term = term;
this.confirmed = confirmed;
this.container = this.createContainer();
this.header = this.createHeader();
this.list = this.createList();
this.setupAccordion();
}

/**
* Creates and returns the main container element for the query results.
*
* @memberof IITC.search.QueryResultsView
* @function createContainer
* @returns {HTMLElement} - The container element for the results.
* @private
*/
createContainer() {
const container = document.createElement('div');
container.classList.add('searchquery');
return container;
}

/**
* Creates and appends a header to the container based on the search term.
*
* @memberof IITC.search.QueryResultsView
* @function createHeader
* @returns {HTMLElement} - The header element displaying the search term or a loading message.
* @private
*/
createHeader() {
const header = document.createElement('h3');
let headerText;

if (this.confirmed) {
headerText = this.term;
} else {
if (this.term.length > 16) {
const start = this.term.slice(0, 8);
const end = this.term.slice(-8);
headerText = `${start}${end} (Return to load more)`;
} else {
headerText = `${this.term} (Return to load more)`;
}
}

header.textContent = headerText;
this.container.appendChild(header);
return header;
}

/**
* Creates and appends an initial list element to display the search results.
*
* @memberof IITC.search.QueryResultsView
* @function createList
* @returns {HTMLElement} - The list element for displaying the results.
* @private
*/
createList() {
const list = document.createElement('ul');
const initialItem = document.createElement('li');
initialItem.textContent = this.confirmed ? 'No local results, searching online...' : 'No local results.';
list.appendChild(initialItem);
this.container.appendChild(list);
return list;
}

/**
* Sets up the accordion functionality for expanding and collapsing results.
*
* @memberof IITC.search.QueryResultsView
* @function setupAccordion
* @private
*/
setupAccordion() {
this.header.addEventListener('click', () => {
this.container.classList.toggle('collapsed');
});
}

/**
* Renders the search results within the list container and sets up event interactions.
*
* @memberof IITC.search.QueryResultsView
* @function renderResults
* @param {Array<Object>} results - An array of search result objects to display.
* @param {Function} onResultInteraction - A callback function for handling interaction events on results.
*/
renderResults(results, onResultInteraction) {
this.clearList();

if (results.length === 0) {
const noResultsItem = document.createElement('li');
noResultsItem.textContent = 'No results found.';
this.list.appendChild(noResultsItem);
} else {
results.forEach((result) => {
const item = this.createListItem(result);
item.addEventListener('click', (ev) => onResultInteraction(result, ev));
item.addEventListener('dblclick', (ev) => onResultInteraction(result, ev));
item.addEventListener('mouseover', (ev) => onResultInteraction(result, ev));
item.addEventListener('mouseout', (ev) => onResultInteraction(result, ev));
item.addEventListener('keydown', (ev) => onResultInteraction(result, ev));
this.list.appendChild(item);
});
}
}

/**
* Creates and returns a list item for an individual search result.
*
* @memberof IITC.search.QueryResultsView
* @function createListItem
* @param {Object} result - The search result object with properties such as title, description, and icon.
* @returns {HTMLElement} - The list item element representing the search result.
* @private
*/
createListItem(result) {
const item = document.createElement('li');
item.tabIndex = 0;

const link = document.createElement('a');
link.innerHTML = result.title;

if (result.icon) {
link.style.backgroundImage = `url("${result.icon}")`;
item.style.listStyle = 'none';
}

item.appendChild(link);

if (result.description) {
const description = document.createElement('em');
description.innerHTML = result.description;
item.appendChild(document.createElement('br'));
item.appendChild(description);
}

return item;
}

/**
* Appends the results container to a specified selector on the page.
*
* @memberof IITC.search.QueryResultsView
* @function renderIn
* @param {string} selector - The selector string for the target container.
*/
renderIn(selector) {
const target = document.querySelector(selector);
if (target) target.appendChild(this.container);
}

/**
* Removes the results container from the page.
*
* @memberof IITC.search.QueryResultsView
* @function remove
* @private
*/
remove() {
if (this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
}

/**
* Clears all items from the results list.
*
* @memberof IITC.search.QueryResultsView
* @function clearList
* @private
*/
clearList() {
this.list.innerHTML = '';
}
}

IITC.search.QueryResultsView = QueryResultsView;
Binary file removed core/images/current-location.png
Binary file not shown.
1 change: 1 addition & 0 deletions core/images/icon-close-small.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions core/images/icon-my-location.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions core/images/icon-search.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
73 changes: 50 additions & 23 deletions core/style.css
Original file line number Diff line number Diff line change
@@ -564,53 +564,80 @@ input[type="search"], input[type="url"] {
}
#search {
width: 100%;
padding-right: 24px;
padding-left: 24px;
padding-right: 36px;
}
#buttongeolocation {
#search::-webkit-search-cancel-button {
-webkit-appearance: none;
}
#searchicon, #searchcancel, #buttongeolocation {
display: flex;
align-items: center;
position: absolute;
right: 0;
bottom: 0;
margin: 0;
border: 0 none transparent;
padding: 0 2px 0 0;
height: 100%;
padding: 0 2px;
height: 24px;
background-color: transparent;
}
#searchicon img, #searchcancel img, #buttongeolocation img {
height: 20px;
}
#searchicon {
left: 0;
}
#searchcancel {
right: 24px;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
#searchcancel.visible {
opacity: 1;
pointer-events: auto;
}
#buttongeolocation {
right: 0;
cursor: pointer;
}
#buttongeolocation:focus {
outline: 1px dotted #ffce00;
}
#buttongeolocation img {
vertical-align: middle;
.searchquery {
max-height: 25em;
overflow-y: auto;
}
#searchwrapper h3 {
padding: 0 0 0 25px;
position: relative;
font-size: 1em;
height: auto;
cursor: pointer;
}
.searchquery {
max-height: 25em;
overflow-y: auto;
}
#searchwrapper .ui-accordion-header::before {
font-size: 18px;
#searchwrapper h3::before {
position: absolute;
left: 5px;
font-size: 14px;
margin-right: 2px;
font-weight: normal;
line-height: 1em;
content: "⊞";
}
#searchwrapper .ui-accordion-header-active::before {
content: "⊟";
content: "▲";
transition: transform 0.3s ease;
}
#searchwrapper .ui-accordion-content {
margin: 0;
overflow: hidden;
#searchwrapper .searchquery.collapsed h3::before {
transform: rotate(180deg);
}
#searchwrapper ul {
padding-left: 14px;
max-height: 100%;
padding-left: 16px;
overflow: hidden;
}
#searchwrapper li {
cursor: pointer;
}
#searchwrapper .searchquery.collapsed ul {
max-height: 0;
}
#searchwrapper li a {
margin-left: -14px;
padding-left: 14px;
18 changes: 15 additions & 3 deletions core/total-conversion-build.js
Original file line number Diff line number Diff line change
@@ -6,6 +6,10 @@

/* global plugin_info, PLAYER -- eslint */

/**
* @namespace IITC
*/

// create IITC scope
const IITC = {};
window.IITC = IITC;
@@ -169,10 +173,18 @@ document.body.innerHTML =
' <div id="playerstat">t</div>' +
' <div id="gamestat">&nbsp;loading global control stats</div>' +
' <div id="searchwrapper">' +
' <button title="Current location" id="buttongeolocation"><img src="' +
'@include_img:images/current-location.png@' +
' <div id="searchbox">' +
' <div id="searchicon"><img src="' +
'@include_img:images/icon-search.svg@' +
'" alt="Search"/></div>' +
' <div id="searchcancel"><img src="' +
'@include_img:images/icon-close-small.svg@' +
'" alt="Cancel search"/></div>' +
' <button title="Current location" id="buttongeolocation"><img src="' +
'@include_img:images/icon-my-location.svg@' +
'" alt="Current location"/></button>' +
' <input id="search" placeholder="Search location…" type="search" accesskey="f" title="Search for a place [f]"/>' +
' <input id="search" placeholder="Search location…" type="search" accesskey="f" title="Search for a place [f]"/>' +
' </div>' +
' </div>' +
' <div id="portaldetails"></div>' +
' <input id="redeem" placeholder="Redeem code…" type="text"/>' +
176 changes: 176 additions & 0 deletions test/search_query.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { describe, it, beforeEach } from 'mocha';
import { expect } from 'chai';

/* global IITC, L */
/* eslint-disable no-unused-expressions */

if (!globalThis.window) globalThis.window = {};
if (!globalThis.L) globalThis.L = {};
if (!globalThis.IITC) globalThis.IITC = {};
if (!globalThis.IITC.search) globalThis.IITC.search = {};
globalThis.IITC.search.Query = {};
import('../core/code/search_query.js');

describe('IITC.search.Query', () => {
let query;
let fakeMap;

beforeEach(() => {
// Mock objects and methods
fakeMap = {
addTo: () => {},
addLayer: () => {},
};

globalThis.window = {
...globalThis.window,
...{
map: fakeMap,
runHooks: () => {},
isSmartphone: () => false,
TEAM_SHORTNAMES: { NEUTRAL: 'NEU', ENLIGHTENED: 'ENL' },
COLORS: { NEUTRAL: '#CCC', ENLIGHTENED: '#008000' },
teamStringToId: (team) => (team === 'ENLIGHTENED' ? 'ENLIGHTENED' : 'NEUTRAL'),
},
};

globalThis.L = {
...globalThis.L,
...{
LatLng: class {},
latLng: (lat, lng) => new L.LatLng(lat, lng),
layerGroup: () => fakeMap,
marker: () => fakeMap,
divIcon: {
coloredSvg: () => {},
},
},
};

globalThis.IITC.search.QueryResultsView = class {
constructor(term, confirmed) {
this.term = term;
this.confirmed = confirmed;
}
renderResults() {}
};

query = new IITC.search.Query('test', true);
});

// Test for initialization
it('should initialize with an empty results array', () => {
expect(query.results).to.be.an('array').that.is.empty;
});

// Test for the addResult method
it('should add a result to the results array with addResult', () => {
const mockResult = { title: 'Test Result', position: new L.LatLng(0, 0) };

query.addResult(mockResult);

expect(query.results).to.have.lengthOf(1);
expect(query.results[0]).to.deep.equal(mockResult);

let renderCalled = false;
query.renderResults = () => {
renderCalled = true;
};
query.addResult(mockResult);
expect(renderCalled).to.be.true;
});

// Test for the addPortalResult method
it('should add a portal result to the results array with addPortalResult', () => {
const portalData = {
title: 'Test Portal',
team: 'ENLIGHTENED',
level: 8,
health: 100,
resCount: 8,
latE6: 50000000,
lngE6: 100000000,
};

query.addPortalResult(portalData, 'abc123');

expect(query.results).to.have.lengthOf(1);
const addedResult = query.results[0];

expect(addedResult.title).to.equal('Test Portal');
expect(addedResult.description).to.contain('ENL');
expect(addedResult.description).to.contain('L8');
expect(addedResult.description).to.contain('100%');
expect(addedResult.description).to.contain('8 Resonators');
expect(addedResult.icon).to.be.a('string').that.contains('data:image/svg+xml;base64');
});

// Test for hover interaction handling
it('should start hover interaction and add layer to map', () => {
const mockResult = { title: 'Hover Result', layer: null, position: new L.LatLng(0, 0) };
let layerAdded = false;

fakeMap.addLayer = () => {
layerAdded = true;
};
query.onResultHoverStart(mockResult);

expect(layerAdded).to.be.true;
});

it('should handle Space key press for selecting a result', () => {
let eventHandled = false;
const mockEvent = { key: ' ', preventDefault: () => {} };
const result = { title: 'Test Result' };

query.onResultSelected = () => {
eventHandled = true;
};

query.handleKeyPress(mockEvent, result);
expect(eventHandled).to.be.true;
});

it('should remove hover interaction layer from map', () => {
const mockResult = { layer: fakeMap };
let layerRemoved = false;

query.hoverResult = mockResult;
fakeMap.removeLayer = () => {
layerRemoved = true;
};

query.removeHoverResult();
expect(layerRemoved).to.be.true;
});

// Test for selecting a result
it('should select a result and adjust the map view', () => {
const mockResult = {
title: 'Selected Result',
position: new L.LatLng(0, 0),
onSelected: () => false,
};
let viewSet = false;

fakeMap.setView = () => {
viewSet = true;
};
query.onResultSelected(mockResult, { type: 'click' });

expect(viewSet).to.be.true;
expect(query.selectedResult).to.equal(mockResult);
});

it('should prevent map repositioning if onSelected returns true', () => {
const mockResult = { title: 'Selected Result', onSelected: () => true };
let viewSet = false;

fakeMap.setView = () => {
viewSet = true;
};
query.onResultSelected(mockResult, { type: 'click' });

expect(viewSet).to.be.false;
});
});

0 comments on commit c81b019

Please sign in to comment.