diff --git a/app/components/photo-gallery.gjs b/app/components/photo-gallery.gjs index dc9ecbe..43a6fe9 100644 --- a/app/components/photo-gallery.gjs +++ b/app/components/photo-gallery.gjs @@ -21,7 +21,10 @@ const GalleryContent = ); - // Let carousel settle - await new Promise((resolve) => setTimeout(resolve, 150)); + assert + .dom('.photo-gallery-content') + .hasAttribute('data-current-event-id', 'event1'); // Right Arrow await triggerKeyEvent(document, 'keydown', 'ArrowRight'); - await new Promise((resolve) => setTimeout(resolve, 150)); - // Let's just assert that currentPhoto was updated internally, which trickles down. - // The actual DOM update for the main image might be tricky if the carousel relies on scroll events. - // We can at least check if the thumbnail selection changed, as that is directly driven by currentPhoto assert - .dom('.thumbnail-strip-container .carousel-slide.active img') - .hasAttribute('data-src', 'photo2.jpg'); + .dom('.photo-gallery-content') + .hasAttribute('data-current-event-id', 'event2'); // Right Arrow again await triggerKeyEvent(document, 'keydown', 'ArrowRight'); - await new Promise((resolve) => setTimeout(resolve, 150)); assert - .dom('.thumbnail-strip-container .carousel-slide.active img') - .hasAttribute('data-src', 'photo3.jpg'); + .dom('.photo-gallery-content') + .hasAttribute('data-current-event-id', 'event3'); // Left Arrow await triggerKeyEvent(document, 'keydown', 'ArrowLeft'); - await new Promise((resolve) => setTimeout(resolve, 150)); assert - .dom('.thumbnail-strip-container .carousel-slide.active img') - .hasAttribute('data-src', 'photo2.jpg'); + .dom('.photo-gallery-content') + .hasAttribute('data-current-event-id', 'event2'); }); test('escape key closes gallery', async function (assert) { diff --git a/tests/integration/components/place-photo-upload-test.gjs b/tests/integration/components/place-photo-upload-test.gjs new file mode 100644 index 0000000..bd3f5e6 --- /dev/null +++ b/tests/integration/components/place-photo-upload-test.gjs @@ -0,0 +1,100 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'marco/tests/helpers'; +import { render, click, triggerEvent } from '@ember/test-helpers'; +import Service from '@ember/service'; +import PlacePhotoUpload from 'marco/components/place-photo-upload'; + +module('Integration | Component | place-photo-upload', function (hooks) { + setupRenderingTest(hooks); + + class MockNostrAuthService extends Service { + get isConnected() { + return true; + } + + get signer() { + return null; + } + } + + hooks.beforeEach(function () { + this.owner.register('service:nostrAuth', MockNostrAuthService); + }); + + async function selectFile(element, file) { + const input = element.querySelector('#photo-upload-input'); + Object.defineProperty(input, 'files', { + value: [file], + configurable: true, + }); + await triggerEvent(input, 'change'); + } + + test('it shows tag suggestions when they exist after upload selection', async function (assert) { + this.place = { + title: 'Cafe Alpha', + osmId: '123', + osmType: 'node', + osmTags: { amenity: 'cafe' }, + }; + + await render( + + ); + + assert.dom('.photo-tag-suggestions').doesNotExist(); + + const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' }); + await selectFile(this.element, file); + + assert.dom('.photo-tag-suggestions').exists(); + assert.dom('.photo-tag-chip').exists(); + assert.dom('.photo-tag-chip').includesText('Food'); + }); + + test('it only allows one selected tag at a time', async function (assert) { + this.place = { + title: 'Cafe Alpha', + osmId: '123', + osmType: 'node', + osmTags: { amenity: 'cafe' }, + }; + + await render( + + ); + + const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' }); + await selectFile(this.element, file); + + const chips = this.element.querySelectorAll('.photo-tag-chip'); + assert.ok(chips.length > 1, 'multiple tag chips are rendered'); + + await click(chips[0]); + assert.dom('.photo-tag-chip.is-selected').exists({ count: 1 }); + assert.dom(chips[0]).hasClass('is-selected'); + + await click(chips[1]); + assert.dom('.photo-tag-chip.is-selected').exists({ count: 1 }); + assert.dom(chips[1]).hasClass('is-selected'); + assert.dom(chips[0]).doesNotHaveClass('is-selected'); + }); + + test('it hides tag suggestions when no tags are suggested', async function (assert) { + this.place = { + title: 'Office Beta', + osmId: '456', + osmType: 'node', + osmTags: { office: 'lawyer' }, + }; + + await render( + + ); + + const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' }); + await selectFile(this.element, file); + + assert.dom('.photo-tag-suggestions').doesNotExist(); + }); +}); diff --git a/tests/integration/components/search-box-test.gjs b/tests/integration/components/search-box-test.gjs index d6936e5..0a9e4c3 100644 --- a/tests/integration/components/search-box-test.gjs +++ b/tests/integration/components/search-box-test.gjs @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'marco/tests/helpers'; -import { render, fillIn, click, waitFor } from '@ember/test-helpers'; +import { render, fillIn, click, waitFor, focus } from '@ember/test-helpers'; import SearchBox from 'marco/components/search-box'; import Service from '@ember/service'; @@ -208,18 +208,22 @@ module('Integration | Component | search-box', function (hooks) { ); // Type "Resta" to trigger "Restaurants" category match + await focus('.search-input'); await fillIn('.search-input', 'Resta'); - // Wait for debounce (300ms) + execution - const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - await delay(400); + await waitFor('.search-result-item'); - // The first result should be the category match - assert.dom('.search-result-item').exists({ count: 1 }); - assert.dom('.result-title').hasText('Restaurants'); + const resultItems = Array.from( + this.element.querySelectorAll('.search-result-item') + ); + const categoryResult = resultItems.find((item) => + item.textContent.includes('Restaurants') + ); + + assert.ok(categoryResult, 'Restaurants category result is shown'); // Click the result - await click('.search-result-item'); + await click(categoryResult); // Assert transition with lat/lon from map center assert.verifySteps([ diff --git a/tests/unit/utils/nostr-test.js b/tests/unit/utils/nostr-test.js new file mode 100644 index 0000000..19e51b7 --- /dev/null +++ b/tests/unit/utils/nostr-test.js @@ -0,0 +1,144 @@ +import { module, test } from 'qunit'; +import { normalizeRelayUrl, parsePlacePhotos } from 'marco/utils/nostr'; + +module('Unit | Utility | nostr', function () { + test('normalizeRelayUrl normalizes protocol, case, and slashes', function (assert) { + assert.strictEqual(normalizeRelayUrl(null), ''); + assert.strictEqual(normalizeRelayUrl(''), ''); + assert.strictEqual(normalizeRelayUrl(' '), ''); + + assert.strictEqual( + normalizeRelayUrl('Relay.example.com'), + 'wss://relay.example.com' + ); + assert.strictEqual( + normalizeRelayUrl('ws://Relay.example.com/'), + 'ws://relay.example.com' + ); + assert.strictEqual( + normalizeRelayUrl('wss://relay.example.com///'), + 'wss://relay.example.com' + ); + }); + + test('parsePlacePhotos includes event t tags on photo objects', function (assert) { + const events = [ + { + id: 'event-1', + pubkey: 'pubkey-1', + created_at: 123, + tags: [ + ['i', 'osm:node:123'], + ['t', 'food'], + ['t', 'vibe'], + ['imeta', 'url https://example.com/photo.jpg', 'dim 800x600'], + ], + }, + ]; + + const photos = parsePlacePhotos(events); + + assert.strictEqual(photos.length, 1); + assert.deepEqual(photos[0].tags, ['food', 'vibe']); + }); + + test('parsePlacePhotos sorts by created_at', function (assert) { + const events = [ + { + id: 'event-2', + pubkey: 'pubkey-2', + created_at: 200, + tags: [ + ['i', 'osm:node:456'], + ['imeta', 'url https://example.com/late.jpg', 'dim 600x900'], + ], + }, + { + id: 'event-1', + pubkey: 'pubkey-1', + created_at: 100, + tags: [ + ['i', 'osm:node:123'], + ['imeta', 'url https://example.com/early.jpg', 'dim 600x900'], + ], + }, + ]; + + const photos = parsePlacePhotos(events); + + assert.strictEqual(photos.length, 2); + assert.strictEqual(photos[0].url, 'https://example.com/early.jpg'); + assert.strictEqual(photos[1].url, 'https://example.com/late.jpg'); + }); + + test('parsePlacePhotos promotes first landscape photo to index 0', function (assert) { + const events = [ + { + id: 'event-1', + pubkey: 'pubkey-1', + created_at: 100, + tags: [ + ['imeta', 'url https://example.com/portrait.jpg', 'dim 600x900'], + ], + }, + { + id: 'event-2', + pubkey: 'pubkey-2', + created_at: 200, + tags: [ + ['imeta', 'url https://example.com/landscape.jpg', 'dim 1200x600'], + ], + }, + ]; + + const photos = parsePlacePhotos(events); + + assert.strictEqual(photos.length, 2); + assert.strictEqual(photos[0].url, 'https://example.com/landscape.jpg'); + assert.strictEqual(photos[1].url, 'https://example.com/portrait.jpg'); + }); + + test('parsePlacePhotos skips imeta entries without urls', function (assert) { + const events = [ + { + id: 'event-1', + pubkey: 'pubkey-1', + created_at: 100, + tags: [['imeta', 'dim 800x600']], + }, + ]; + + const photos = parsePlacePhotos(events); + + assert.deepEqual(photos, []); + }); + + test('parsePlacePhotos returns one photo per event imeta tag', function (assert) { + const events = [ + { + id: 'event-1', + pubkey: 'pubkey-1', + created_at: 100, + tags: [ + ['i', 'osm:node:123'], + ['imeta', 'url https://example.com/photo-1.jpg', 'dim 800x600'], + ], + }, + { + id: 'event-2', + pubkey: 'pubkey-2', + created_at: 200, + tags: [ + ['i', 'osm:node:456'], + ['imeta', 'url https://example.com/photo-2.jpg', 'dim 600x800'], + ], + }, + ]; + + const photos = parsePlacePhotos(events); + + assert.strictEqual(photos.length, 2); + assert.strictEqual(photos[0].placeIdentifier, 'osm:node:123'); + assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456'); + }); +}); diff --git a/tests/unit/utils/photo-tag-suggestions-test.js b/tests/unit/utils/photo-tag-suggestions-test.js new file mode 100644 index 0000000..f86b5f4 --- /dev/null +++ b/tests/unit/utils/photo-tag-suggestions-test.js @@ -0,0 +1,30 @@ +import { module, test } from 'qunit'; +import { POI_CATEGORIES } from 'marco/utils/poi-categories'; +import { getMatchingPoiCategoryIds } from 'marco/utils/poi-category-matcher'; +import { + getSuggestedPhotoTags, + CATEGORY_TAGS, +} from 'marco/utils/photo-tag-suggestions'; + +module('Unit | Utility | photo-tag-suggestions', function () { + test('returns tags for all matching categories with de-duplication', function (assert) { + const place = { osmTags: { amenity: 'cafe' } }; + const categoryIds = getMatchingPoiCategoryIds( + place.osmTags, + POI_CATEGORIES + ); + + assert.ok(categoryIds.includes('restaurants')); + assert.ok(categoryIds.includes('coffee')); + + const result = getSuggestedPhotoTags(place); + assert.deepEqual(result, CATEGORY_TAGS.restaurants); + }); + + test('returns no tags when no category matches', function (assert) { + const place = { osmTags: { office: 'lawyer' } }; + const result = getSuggestedPhotoTags(place); + + assert.deepEqual(result, []); + }); +}); diff --git a/tests/unit/utils/poi-category-matcher-test.js b/tests/unit/utils/poi-category-matcher-test.js new file mode 100644 index 0000000..4f74706 --- /dev/null +++ b/tests/unit/utils/poi-category-matcher-test.js @@ -0,0 +1,38 @@ +import { module, test } from 'qunit'; +import { POI_CATEGORIES } from 'marco/utils/poi-categories'; +import { + getMatchingPoiCategories, + getMatchingPoiCategoryIds, +} from 'marco/utils/poi-category-matcher'; + +module('Unit | Utility | poi-category-matcher', function () { + test('matches multiple categories from OSM tags', function (assert) { + const tags = { amenity: 'cafe' }; + const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES); + + assert.ok(categoryIds.includes('restaurants')); + assert.ok(categoryIds.includes('coffee')); + }); + + test('supports semicolon-separated values', function (assert) { + const tags = { amenity: 'cafe;bar' }; + const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES); + + assert.ok(categoryIds.includes('coffee')); + }); + + test('negative regex clause fails if any value matches', function (assert) { + const tags = { amenity: 'cafe', cuisine: 'coffee;irish' }; + const categoryIds = getMatchingPoiCategoryIds(tags, POI_CATEGORIES); + + assert.notOk(categoryIds.includes('restaurants')); + }); + + test('presence clause matches when tag exists', function (assert) { + const tags = { historic: 'castle' }; + const categories = getMatchingPoiCategories(tags, POI_CATEGORIES); + const categoryIds = categories.map((category) => category.id); + + assert.ok(categoryIds.includes('things-to-do')); + }); +});