Optionally add tag to place photo
Some checks failed
CI / Lint (pull_request) Successful in 52s
CI / Test (pull_request) Failing after 56s

This commit is contained in:
2026-06-05 17:48:55 +04:00
parent 70d2fe1c6c
commit 200100686d
12 changed files with 542 additions and 0 deletions

View File

@@ -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(
<template><PlacePhotoUpload @place={{this.place}} /></template>
);
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(
<template><PlacePhotoUpload @place={{this.place}} /></template>
);
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(
<template><PlacePhotoUpload @place={{this.place}} /></template>
);
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
await selectFile(this.element, file);
assert.dom('.photo-tag-suggestions').doesNotExist();
});
});

View File

@@ -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');
});
});

View File

@@ -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, []);
});
});

View File

@@ -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'));
});
});