12 Commits

Author SHA1 Message Date
f40ea91ee2 ci: tox pytest command 2024-08-06 12:33:32 +08:00
ade0bcb2b7 fix: py3.8 compatibility 2024-08-06 12:32:52 +08:00
abc535c59f fix: linter warning PLR2004 2024-08-06 12:32:43 +08:00
dbb5d680af fix ci 2024-08-06 00:31:09 +08:00
3e6cc8d7e7 fix imports 2024-08-06 00:24:08 +08:00
10b332e911 refactor: st3 parser (importer) 2024-08-06 00:23:31 +08:00
2a2a063a3c refactor: external importers
* packlist & songlist importer
2024-08-05 15:07:55 +08:00
6e2bcbd0ea wip, tests and others 2024-06-20 02:30:37 +08:00
62c80c9955 fix: ruff F401 warnings 2024-05-22 02:32:34 +08:00
8e79ffedce ci: sync changes in master branch 2024-05-22 01:37:39 +08:00
677ab6c31e test: refactor legacy tests 2024-05-21 21:12:11 +08:00
ab88b6903c test: conftest database clean-up 2024-05-21 21:04:30 +08:00
48 changed files with 1727 additions and 212 deletions

41
.github/workflows/main.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: test & lint
on:
push:
branches:
- '*'
pull_request:
types: [opened, reopened]
workflow_dispatch:
jobs:
pytest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dev dependencies
run: 'pip install .[dev]'
- name: Run tests
run: 'python -m pytest -v'
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dev dependencies
run: 'pip install .[dev]'
- name: Run linter
run: 'ruff check'

View File

@ -1,23 +0,0 @@
name: Run tests
on:
push:
branches:
- 'master'
pull_request:
types: [opened, reopened]
workflow_dispatch:
jobs:
pytest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- run: 'pip install -r requirements.dev.txt .'
- run: 'pytest -v'

View File

@ -4,11 +4,10 @@ repos:
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 23.1.0
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4
hooks:
- id: black
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
- id: ruff
args: ["--fix"]
- id: ruff-format

View File

@ -9,33 +9,45 @@ authors = [{ name = "283375", email = "log_283375@163.com" }]
description = "Manage your local Arcaea score database."
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"SQLAlchemy==2.0.20",
"SQLAlchemy-Utils==0.41.1",
]
dependencies = ["SQLAlchemy==2.0.20", "SQLAlchemy-Utils==0.41.1"]
classifiers = [
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3",
]
[project.optional-dependencies]
dev = ["ruff~=0.4", "pre-commit~=3.3", "pytest~=7.4", "tox~=4.11"]
[project.urls]
"Homepage" = "https://github.com/283375/arcaea-offline"
"Bug Tracker" = "https://github.com/283375/arcaea-offline/issues"
[tool.isort]
profile = "black"
src_paths = ["src/arcaea_offline"]
[tool.pyright]
ignore = ["build/"]
[tool.pylint.main]
jobs = 0
[tool.pylint.logging]
disable = [
"missing-module-docstring",
"missing-class-docstring",
"missing-function-docstring",
"not-callable", # false positive to sqlalchemy `func.*`, remove this when pylint-dev/pylint(#8138) closed
[tool.ruff.lint]
# Full list: https://docs.astral.sh/ruff/rules
select = [
"E", # pycodestyle (Error)
"W", # pycodestyle (Warning)
"F", # pyflakes
"I", # isort
"PL", # pylint
"N", # pep8-naming
"FBT", # flake8-boolean-trap
"A", # flake8-builtins
"DTZ", # flake8-datetimez
"LOG", # flake8-logging
"Q", # flake8-quotes
"G", # flake8-logging-format
"PIE", # flake8-pie
"PT", # flake8-pytest-style
]
ignore = [
"E501", # line-too-long
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"PLR2004", # magic-value-comparison
]

View File

@ -1,6 +1,4 @@
black==23.3.0
isort==5.12.0
pre-commit==3.3.1
pylint==3.0.2
pytest==7.4.3
tox==4.11.3
ruff~=0.4
pre-commit~=3.3
pytest~=7.4
tox~=4.11

View File

@ -1,2 +1,3 @@
from . import world
from .play_result import PlayResultCalculators
__all__ = ["PlayResultCalculators"]

View File

@ -7,7 +7,7 @@ from arcaea_offline.constants.play_result import ScoreLowerLimits
class PlayResultCalculators:
@staticmethod
def score_possible_range(notes: int, pure: int, far: int) -> tuple[int, int]:
def score_possible_range(notes: int, pure: int, far: int) -> Tuple[int, int]:
"""
Returns the possible range of score based on the given values.
@ -44,9 +44,9 @@ class PlayResultCalculators:
if score < 0:
raise ValueError("score cannot be negative")
if score >= 10000000:
if score >= ScoreLowerLimits.PM:
return Decimal(2)
if score >= 9800000:
if score >= ScoreLowerLimits.EX:
return Decimal(1) + (Decimal(score - 9800000) / 200000)
return Decimal(score - 9500000) / 300000

View File

@ -9,3 +9,17 @@ from .partners import (
MayaPartnerBonus,
MithraTerceraPartnerBonus,
)
__all__ = [
"AmaneBelowExPartnerBonus",
"AwakenedEtoPartnerBonus",
"AwakenedIlithPartnerBonus",
"AwakenedLunaPartnerBonus",
"LegacyMapStepBooster",
"MayaPartnerBonus",
"MemoriesStepBooster",
"MithraTerceraPartnerBonus",
"PartnerBonus",
"WorldMainMapCalculators",
"WorldPlayResult",
]

View File

@ -3,6 +3,7 @@ from dataclasses import dataclass
@dataclass(frozen=True)
class ScoreLowerLimits:
PM = 10000000
EX_PLUS = 9900000
EX = 9800000
AA = 9500000

View File

@ -1 +1,3 @@
from .db import Database
__all__ = ["Database"]

View File

@ -6,11 +6,8 @@ from sqlalchemy import Engine, func, inspect, select
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, sessionmaker
from arcaea_offline.external.arcsong.arcsong_json import ArcSongJsonBuilder
from arcaea_offline.external.exports import (
ArcaeaOfflineDEFV2_Score,
ScoreExport,
exporters,
)
from arcaea_offline.external.exports import exporters
from arcaea_offline.external.exports.types import ArcaeaOfflineDEFV2_Score, ScoreExport
from arcaea_offline.singleton import Singleton
from .models.v4.config import ConfigBase, Property

View File

@ -19,3 +19,24 @@ from .songs import (
SongsBase,
SongsViewBase,
)
__all__ = [
"CalculatedPotential",
"Chart",
"ChartInfo",
"ConfigBase",
"Difficulty",
"DifficultyLocalized",
"Pack",
"PackLocalized",
"Property",
"Score",
"ScoreBest",
"ScoreCalculated",
"ScoresBase",
"ScoresViewBase",
"Song",
"SongLocalized",
"SongsBase",
"SongsViewBase",
]

View File

@ -10,12 +10,12 @@ from .common import ReprHelper
from .songs import ChartInfo, Difficulty
__all__ = [
"ScoresBase",
"Score",
"ScoresViewBase",
"ScoreCalculated",
"ScoreBest",
"CalculatedPotential",
"Score",
"ScoreBest",
"ScoreCalculated",
"ScoresBase",
"ScoresViewBase",
]

View File

@ -9,16 +9,16 @@ from sqlalchemy_utils import create_view
from .common import ReprHelper
__all__ = [
"SongsBase",
"Chart",
"ChartInfo",
"Difficulty",
"DifficultyLocalized",
"Pack",
"PackLocalized",
"Song",
"SongLocalized",
"Difficulty",
"DifficultyLocalized",
"ChartInfo",
"SongsBase",
"SongsViewBase",
"Chart",
]

View File

@ -0,0 +1,38 @@
from .arcaea import (
Chart,
ChartInfo,
Difficulty,
DifficultyLocalized,
Pack,
PackLocalized,
Song,
SongLocalized,
SongSearchWord,
)
from .base import ModelsV5Base, ModelsV5ViewBase
from .config import Property
from .play_results import (
CalculatedPotential,
PlayResult,
PlayResultBest,
PlayResultCalculated,
)
__all__ = [
"CalculatedPotential",
"Chart",
"ChartInfo",
"Difficulty",
"DifficultyLocalized",
"ModelsV5Base",
"ModelsV5ViewBase",
"Pack",
"PackLocalized",
"PlayResult",
"PlayResultBest",
"PlayResultCalculated",
"Property",
"Song",
"SongLocalized",
"SongSearchWord",
]

View File

@ -0,0 +1,275 @@
from typing import List, Optional
from sqlalchemy import ForeignKey, and_, func, select
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy_utils import create_view
from arcaea_offline.constants.enums import ArcaeaRatingClass, ArcaeaSongSide
from .base import ModelsV5Base, ModelsV5ViewBase, ReprHelper
__all__ = [
"Chart",
"ChartInfo",
"Difficulty",
"DifficultyLocalized",
"Pack",
"PackLocalized",
"Song",
"SongLocalized",
]
class Pack(ModelsV5Base, ReprHelper):
__tablename__ = "packs"
id: Mapped[str] = mapped_column(primary_key=True)
name: Mapped[str]
description: Mapped[Optional[str]]
songs: Mapped[List["Song"]] = relationship(back_populates="pack", viewonly=True)
localized_objects: Mapped[List["PackLocalized"]] = relationship(
back_populates="parent", viewonly=True
)
class PackLocalized(ModelsV5Base, ReprHelper):
__tablename__ = "packs_localized"
pkid: Mapped[int] = mapped_column(primary_key=True)
id: Mapped[str] = mapped_column(
ForeignKey(Pack.id, onupdate="CASCADE", ondelete="NO ACTION")
)
lang: Mapped[str]
name: Mapped[Optional[str]]
description: Mapped[Optional[str]]
parent: Mapped[Pack] = relationship(viewonly=True)
class Song(ModelsV5Base, ReprHelper):
__tablename__ = "songs"
idx: Mapped[int]
id: Mapped[str] = mapped_column(primary_key=True)
title: Mapped[str]
artist: Mapped[str]
pack_id: Mapped[str] = mapped_column(
ForeignKey(Pack.id, onupdate="CASCADE", ondelete="NO ACTION")
)
bpm: Mapped[Optional[str]]
bpm_base: Mapped[Optional[float]]
audio_preview: Mapped[Optional[int]]
audio_preview_end: Mapped[Optional[int]]
side: Mapped[Optional[ArcaeaSongSide]]
version: Mapped[Optional[str]]
date: Mapped[Optional[int]]
bg: Mapped[Optional[str]]
bg_inverse: Mapped[Optional[str]]
bg_day: Mapped[Optional[str]]
bg_night: Mapped[Optional[str]]
source: Mapped[Optional[str]]
source_copyright: Mapped[Optional[str]]
pack: Mapped[Pack] = relationship(viewonly=True)
difficulties: Mapped[List["Difficulty"]] = relationship(
back_populates="song", viewonly=True
)
localized_objects: Mapped[List["SongLocalized"]] = relationship(
back_populates="parent", viewonly=True
)
@property
def charts_info(self):
return [d.chart_info for d in self.difficulties if d.chart_info is not None]
class SongLocalized(ModelsV5Base, ReprHelper):
__tablename__ = "songs_localized"
pkid: Mapped[int] = mapped_column(primary_key=True)
id: Mapped[str] = mapped_column(
ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION")
)
lang: Mapped[str]
title: Mapped[Optional[str]]
source: Mapped[Optional[str]]
parent: Mapped[Song] = relationship(
back_populates="localized_objects", viewonly=True
)
class SongSearchWord(ModelsV5Base, ReprHelper):
__tablename__ = "songs_search_words"
pkid: Mapped[int] = mapped_column(primary_key=True)
id: Mapped[str] = mapped_column(
ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION")
)
lang: Mapped[str]
type: Mapped[int] = mapped_column(comment="1: title, 2: artist")
value: Mapped[str]
class Difficulty(ModelsV5Base, ReprHelper):
__tablename__ = "difficulties"
song_id: Mapped[str] = mapped_column(
ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION"),
primary_key=True,
)
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(primary_key=True)
rating: Mapped[int]
rating_plus: Mapped[bool]
chart_designer: Mapped[Optional[str]]
jacket_desginer: Mapped[Optional[str]]
audio_override: Mapped[bool] = mapped_column(default=False)
jacket_override: Mapped[bool] = mapped_column(default=False)
jacket_night: Mapped[Optional[str]]
title: Mapped[Optional[str]]
artist: Mapped[Optional[str]]
bg: Mapped[Optional[str]]
bg_inverse: Mapped[Optional[str]]
bpm: Mapped[Optional[str]]
bpm_base: Mapped[Optional[float]]
version: Mapped[Optional[str]]
date: Mapped[Optional[int]]
song: Mapped[Song] = relationship(back_populates="difficulties", viewonly=True)
chart_info: Mapped[Optional["ChartInfo"]] = relationship(
primaryjoin=(
"and_(Difficulty.song_id==ChartInfo.song_id, "
"Difficulty.rating_class==ChartInfo.rating_class)"
),
viewonly=True,
)
localized_objects: Mapped[List["DifficultyLocalized"]] = relationship(
primaryjoin=(
"and_(Difficulty.song_id==DifficultyLocalized.song_id, "
"Difficulty.rating_class==DifficultyLocalized.rating_class)"
),
viewonly=True,
)
class DifficultyLocalized(ModelsV5Base, ReprHelper):
__tablename__ = "difficulties_localized"
pkid: Mapped[int] = mapped_column(primary_key=True)
song_id: Mapped[str] = mapped_column(
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION")
)
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION")
)
lang: Mapped[str]
title: Mapped[Optional[str]]
artist: Mapped[Optional[str]]
parent: Mapped[Difficulty] = relationship(
primaryjoin=and_(
Difficulty.song_id == song_id, Difficulty.rating_class == rating_class
),
viewonly=True,
)
class ChartInfo(ModelsV5Base, ReprHelper):
__tablename__ = "charts_info"
song_id: Mapped[str] = mapped_column(
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION"),
primary_key=True,
)
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION"),
primary_key=True,
)
constant: Mapped[int] = mapped_column(
comment="real_constant * 10. For example, Crimson Throne [FTR] is 10.4, then store 104."
)
notes: Mapped[Optional[int]]
difficulty: Mapped[Difficulty] = relationship(
primaryjoin=and_(
Difficulty.song_id == song_id, Difficulty.rating_class == rating_class
),
viewonly=True,
)
class Chart(ModelsV5ViewBase, ReprHelper):
__tablename__ = "charts"
song_idx: Mapped[int]
song_id: Mapped[str]
rating_class: Mapped[ArcaeaRatingClass]
rating: Mapped[int]
rating_plus: Mapped[bool]
title: Mapped[str]
artist: Mapped[str]
pack_id: Mapped[str]
bpm: Mapped[Optional[str]]
bpm_base: Mapped[Optional[float]]
audio_preview: Mapped[Optional[int]]
audio_preview_end: Mapped[Optional[int]]
side: Mapped[Optional[int]]
version: Mapped[Optional[str]]
date: Mapped[Optional[int]]
bg: Mapped[Optional[str]]
bg_inverse: Mapped[Optional[str]]
bg_day: Mapped[Optional[str]]
bg_night: Mapped[Optional[str]]
source: Mapped[Optional[str]]
source_copyright: Mapped[Optional[str]]
chart_designer: Mapped[Optional[str]]
jacket_desginer: Mapped[Optional[str]]
audio_override: Mapped[bool]
jacket_override: Mapped[bool]
jacket_night: Mapped[Optional[str]]
constant: Mapped[int]
notes: Mapped[Optional[int]]
__table__ = create_view(
name=__tablename__,
selectable=select(
Song.idx.label("song_idx"),
Difficulty.song_id,
Difficulty.rating_class,
Difficulty.rating,
Difficulty.rating_plus,
func.coalesce(Difficulty.title, Song.title).label("title"),
func.coalesce(Difficulty.artist, Song.artist).label("artist"),
Song.pack_id,
func.coalesce(Difficulty.bpm, Song.bpm).label("bpm"),
func.coalesce(Difficulty.bpm_base, Song.bpm_base).label("bpm_base"),
Song.audio_preview,
Song.audio_preview_end,
Song.side,
func.coalesce(Difficulty.version, Song.version).label("version"),
func.coalesce(Difficulty.date, Song.date).label("date"),
func.coalesce(Difficulty.bg, Song.bg).label("bg"),
func.coalesce(Difficulty.bg_inverse, Song.bg_inverse).label("bg_inverse"),
Song.bg_day,
Song.bg_night,
Song.source,
Song.source_copyright,
Difficulty.chart_designer,
Difficulty.jacket_desginer,
Difficulty.audio_override,
Difficulty.jacket_override,
Difficulty.jacket_night,
ChartInfo.constant,
ChartInfo.notes,
)
.select_from(Difficulty)
.join(
ChartInfo,
(Difficulty.song_id == ChartInfo.song_id)
& (Difficulty.rating_class == ChartInfo.rating_class),
)
.join(Song, Difficulty.song_id == Song.id),
metadata=ModelsV5ViewBase.metadata,
cascade_on_drop=False,
)

View File

@ -0,0 +1,62 @@
from datetime import datetime
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.exc import DetachedInstanceError
from arcaea_offline.constants.enums import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
ArcaeaSongSide,
)
from .._custom_types import DbIntEnum, TZDateTime
TYPE_ANNOTATION_MAP = {
datetime: TZDateTime,
ArcaeaRatingClass: DbIntEnum(ArcaeaRatingClass),
ArcaeaSongSide: DbIntEnum(ArcaeaSongSide),
ArcaeaPlayResultClearType: DbIntEnum(ArcaeaPlayResultClearType),
ArcaeaPlayResultModifier: DbIntEnum(ArcaeaPlayResultModifier),
}
class ModelsV5Base(DeclarativeBase):
type_annotation_map = TYPE_ANNOTATION_MAP
class ModelsV5ViewBase(DeclarativeBase):
type_annotation_map = TYPE_ANNOTATION_MAP
class ReprHelper:
# pylint: disable=no-member
def _repr(self, **kwargs) -> str:
"""
Helper for __repr__
https://stackoverflow.com/a/55749579/16484891
CC BY-SA 4.0
"""
field_strings = []
at_least_one_attached_attribute = False
for key, field in kwargs.items():
try:
field_strings.append(f"{key}={field!r}")
except DetachedInstanceError:
field_strings.append(f"{key}=DetachedInstanceError")
else:
at_least_one_attached_attribute = True
if at_least_one_attached_attribute:
return f"<{self.__class__.__name__}({', '.join(field_strings)})>"
return f"<{self.__class__.__name__} {id(self)}>"
def __repr__(self):
if isinstance(self, DeclarativeBase):
return self._repr(
**{c.key: getattr(self, c.key) for c in self.__table__.columns}
)
return super().__repr__()

View File

@ -0,0 +1,12 @@
from sqlalchemy.orm import Mapped, mapped_column
from .base import ModelsV5Base, ReprHelper
__all__ = ["Property"]
class Property(ModelsV5Base, ReprHelper):
__tablename__ = "properties"
key: Mapped[str] = mapped_column(primary_key=True)
value: Mapped[str] = mapped_column()

View File

@ -0,0 +1,197 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import ForeignKey, and_, case, func, inspect, select, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy_utils import create_view
from arcaea_offline.constants.enums import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from .arcaea import ChartInfo, Difficulty
from .base import ModelsV5Base, ModelsV5ViewBase, ReprHelper
__all__ = [
"CalculatedPotential",
"PlayResult",
"PlayResultBest",
"PlayResultCalculated",
]
class PlayResult(ModelsV5Base, ReprHelper):
__tablename__ = "play_results"
id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True)
song_id: Mapped[str] = mapped_column(
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION"),
index=True,
)
rating_class: Mapped[ArcaeaRatingClass] = mapped_column(
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION"),
index=True,
)
score: Mapped[int]
pure: Mapped[Optional[int]]
far: Mapped[Optional[int]]
lost: Mapped[Optional[int]]
date: Mapped[Optional[datetime]] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[ArcaeaPlayResultModifier]]
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]]
comment: Mapped[Optional[str]]
difficulty: Mapped[Difficulty] = relationship(
primaryjoin=and_(
song_id == Difficulty.song_id,
rating_class == Difficulty.rating_class,
),
viewonly=True,
)
# How to create an SQL View with SQLAlchemy?
# https://stackoverflow.com/a/53253105/16484891
# CC BY-SA 4.0
class PlayResultCalculated(ModelsV5ViewBase, ReprHelper):
__tablename__ = "play_results_calculated"
id: Mapped[int]
song_id: Mapped[str]
rating_class: Mapped[ArcaeaRatingClass]
score: Mapped[int]
pure: Mapped[Optional[int]]
shiny_pure: Mapped[Optional[int]]
far: Mapped[Optional[int]]
lost: Mapped[Optional[int]]
date: Mapped[Optional[datetime]]
max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[ArcaeaPlayResultModifier]]
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]]
potential: Mapped[float]
comment: Mapped[Optional[str]]
__table__ = create_view(
name=__tablename__,
selectable=select(
PlayResult.id,
Difficulty.song_id,
Difficulty.rating_class,
PlayResult.score,
PlayResult.pure,
(
case(
(
(
ChartInfo.notes.is_not(None)
& PlayResult.pure.is_not(None)
& PlayResult.far.is_not(None)
& (ChartInfo.notes != 0)
),
PlayResult.score
- func.floor(
(PlayResult.pure * 10000000.0 / ChartInfo.notes)
+ (PlayResult.far * 0.5 * 10000000.0 / ChartInfo.notes)
),
),
else_=text("NULL"),
)
).label("shiny_pure"),
PlayResult.far,
PlayResult.lost,
PlayResult.date,
PlayResult.max_recall,
PlayResult.modifier,
PlayResult.clear_type,
case(
(PlayResult.score >= 10000000, ChartInfo.constant / 10.0 + 2), # noqa: PLR2004
(
PlayResult.score >= 9800000, # noqa: PLR2004
ChartInfo.constant / 10.0
+ 1
+ (PlayResult.score - 9800000) / 200000.0,
),
else_=func.max(
(ChartInfo.constant / 10.0)
+ (PlayResult.score - 9500000) / 300000.0,
0,
),
).label("potential"),
PlayResult.comment,
)
.select_from(Difficulty)
.join(
ChartInfo,
(Difficulty.song_id == ChartInfo.song_id)
& (Difficulty.rating_class == ChartInfo.rating_class),
)
.join(
PlayResult,
(Difficulty.song_id == PlayResult.song_id)
& (Difficulty.rating_class == PlayResult.rating_class),
),
metadata=ModelsV5ViewBase.metadata,
cascade_on_drop=False,
)
class PlayResultBest(ModelsV5ViewBase, ReprHelper):
__tablename__ = "play_results_best"
id: Mapped[int]
song_id: Mapped[str]
rating_class: Mapped[int]
score: Mapped[int]
pure: Mapped[Optional[int]]
shiny_pure: Mapped[Optional[int]]
far: Mapped[Optional[int]]
lost: Mapped[Optional[int]]
date: Mapped[Optional[datetime]]
max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[ArcaeaPlayResultModifier]]
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]]
potential: Mapped[float]
comment: Mapped[Optional[str]]
__table__ = create_view(
name=__tablename__,
selectable=select(
*[
col
for col in inspect(PlayResultCalculated).columns
if col.name != "potential"
],
func.max(PlayResultCalculated.potential).label("potential"),
)
.select_from(PlayResultCalculated)
.group_by(PlayResultCalculated.song_id, PlayResultCalculated.rating_class)
.order_by(PlayResultCalculated.potential.desc()),
metadata=ModelsV5ViewBase.metadata,
cascade_on_drop=False,
)
class CalculatedPotential(ModelsV5ViewBase, ReprHelper):
__tablename__ = "calculated_potential"
b30: Mapped[float]
_select_bests_subquery = (
select(PlayResultBest.potential.label("b30_sum"))
.order_by(PlayResultBest.potential.desc())
.limit(30)
.subquery()
)
__table__ = create_view(
name=__tablename__,
selectable=select(func.avg(_select_bests_subquery.c.b30_sum).label("b30")),
metadata=ModelsV5ViewBase.metadata,
cascade_on_drop=False,
)

View File

@ -1 +1,3 @@
from .api_data import AndrealImageGeneratorApiDataConverter
__all__ = ["AndrealImageGeneratorApiDataConverter"]

View File

@ -2,3 +2,11 @@ from .online import ArcaeaOnlineParser
from .packlist import PacklistParser
from .songlist import SonglistDifficultiesParser, SonglistParser
from .st3 import St3ScoreParser
__all__ = [
"ArcaeaOnlineParser",
"PacklistParser",
"SonglistDifficultiesParser",
"SonglistParser",
"St3ScoreParser",
]

View File

@ -1 +1,3 @@
from .arcsong_db import ArcsongDbParser
__all__ = ["ArcsongDbParser"]

View File

@ -3,7 +3,7 @@ from typing import List
from sqlalchemy.orm import Session
from ...models.songs import ChartInfo
from arcaea_offline.database.models.v4 import ChartInfo
class ArcsongDbParser:

View File

@ -5,7 +5,7 @@ from typing import List, Optional, TypedDict
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ...models import (
from arcaea_offline.database.models.v4 import (
ChartInfo,
Difficulty,
DifficultyLocalized,

View File

@ -1 +1,3 @@
from .parser import ChartInfoDbParser
__all__ = ["ChartInfoDbParser"]

View File

@ -1,2 +0,0 @@
from . import exporters
from .types import ArcaeaOfflineDEFV2_Score, ScoreExport

View File

@ -1,4 +1,5 @@
from ...models import Score
from arcaea_offline.database.models.v4 import Score
from .types import ArcaeaOfflineDEFV2_ScoreItem, ScoreExport

View File

@ -0,0 +1,31 @@
from typing import Union
def fix_timestamp(timestamp: int) -> Union[int, None]:
"""
Some of the `date` column in st3 are unexpectedly truncated. For example,
a `1670283375` may be truncated to `167028`, even a single `1`.
To properly handle this:
If `timestamp > 1489017600` (the release date of Arcaea), consider it's ok.
Otherwise, if the timestamp is 'fixable'
(`1489 <= timestamp <= 9999` or `timestamp > 14889`),
pad zeros to the end of timestamp.
For example, a `1566` will be padded to `1566000000`.
Otherwise, treat the timestamp as `None`.
:param timestamp: `date` value
"""
if timestamp > 1489017600: # noqa: PLR2004
return timestamp
timestamp_fixable = 1489 <= timestamp <= 9999 or timestamp > 14889 # noqa: PLR2004
if not timestamp_fixable:
return None
timestamp_str = str(timestamp)
timestamp_str = timestamp_str.ljust(10, "0")
return int(timestamp_str, 10)

View File

@ -0,0 +1,178 @@
"""
packlist and songlist parsers
"""
import json
from typing import List, Union
from arcaea_offline.constants.enums import (
ArcaeaLanguage,
ArcaeaRatingClass,
ArcaeaSongSide,
)
from arcaea_offline.database.models.v5 import (
Difficulty,
DifficultyLocalized,
Pack,
PackLocalized,
Song,
SongLocalized,
SongSearchWord,
)
class ArcaeaListParser:
def __init__(self, list_text: str):
self.list_text = list_text
class PacklistParser(ArcaeaListParser):
def parse(self) -> List[Union[Pack, PackLocalized]]:
root = json.loads(self.list_text)
packs = root["packs"]
results: List[Union[Pack, PackLocalized]] = [
Pack(id="single", name="Memory Archive")
]
for item in packs:
pack = Pack()
pack.id = item["id"]
pack.name = item["name_localized"]["en"]
pack.description = item["description_localized"]["en"] or None
results.append(pack)
for key in ArcaeaLanguage:
name_localized = item["name_localized"].get(key.value, None)
description_localized = item["description_localized"].get(
key.value, None
)
if name_localized or description_localized:
pack_localized = PackLocalized(id=pack.id)
pack_localized.lang = key.value
pack_localized.name = name_localized
pack_localized.description = description_localized
results.append(pack_localized)
return results
class SonglistParser(ArcaeaListParser):
def parse_songs(self) -> List[Union[Song, SongLocalized, SongSearchWord]]:
root = json.loads(self.list_text)
songs = root["songs"]
results = []
for item in songs:
song = Song()
song.idx = item["idx"]
song.id = item["id"]
song.title = item["title_localized"]["en"]
song.artist = item["artist"]
song.bpm = item["bpm"]
song.bpm_base = item["bpm_base"]
song.pack_id = item["set"]
song.audio_preview = item["audioPreview"]
song.audio_preview_end = item["audioPreviewEnd"]
song.side = ArcaeaSongSide(item["side"])
song.version = item["version"]
song.date = item["date"]
song.bg = item.get("bg")
song.bg_inverse = item.get("bg_inverse")
if item.get("bg_daynight"):
song.bg_day = item["bg_daynight"].get("day")
song.bg_night = item["bg_daynight"].get("night")
if item.get("source_localized"):
song.source = item["source_localized"]["en"]
song.source_copyright = item.get("source_copyright")
results.append(song)
for lang in ArcaeaLanguage:
# SongLocalized objects
title_localized = item["title_localized"].get(lang.value, None)
source_localized = item.get("source_localized", {}).get(
lang.value, None
)
if title_localized or source_localized:
song_localized = SongLocalized(id=song.id)
song_localized.lang = lang.value
song_localized.title = title_localized
song_localized.source = source_localized
results.append(song_localized)
# SongSearchTitle
search_titles = item.get("search_title", {}).get(lang.value, None)
if search_titles:
for search_title in search_titles:
song_search_word = SongSearchWord(
id=song.id, lang=lang.value, type=1, value=search_title
)
results.append(song_search_word)
search_artists = item.get("search_artist", {}).get(lang.value, None)
if search_artists:
for search_artist in search_artists:
song_search_word = SongSearchWord(
id=song.id, lang=lang.value, type=2, value=search_artist
)
results.append(song_search_word)
return results
def parse_difficulties(self) -> List[Union[Difficulty, DifficultyLocalized]]:
root = json.loads(self.list_text)
songs = root["songs"]
results = []
for song in songs:
difficulties = song.get("difficulties")
if not difficulties:
continue
for item in difficulties:
if item["rating"] == 0:
continue
difficulty = Difficulty()
difficulty.song_id = song["id"]
difficulty.rating_class = ArcaeaRatingClass(item["ratingClass"])
difficulty.rating = item["rating"]
difficulty.rating_plus = item.get("ratingPlus") or False
difficulty.chart_designer = item["chartDesigner"]
difficulty.jacket_desginer = item.get("jacketDesigner") or None
difficulty.audio_override = item.get("audioOverride") or False
difficulty.jacket_override = item.get("jacketOverride") or False
difficulty.jacket_night = item.get("jacketNight") or None
difficulty.title = item.get("title_localized", {}).get("en") or None
difficulty.artist = item.get("artist") or None
difficulty.bg = item.get("bg") or None
difficulty.bg_inverse = item.get("bg_inverse")
difficulty.bpm = item.get("bpm") or None
difficulty.bpm_base = item.get("bpm_base") or None
difficulty.version = item.get("version") or None
difficulty.date = item.get("date") or None
results.append(difficulty)
for lang in ArcaeaLanguage:
title_localized = item.get("title_localized", {}).get(
lang.value, None
)
artist_localized = item.get("artist_localized", {}).get(
lang.value, None
)
if title_localized or artist_localized:
difficulty_localized = DifficultyLocalized(
song_id=difficulty.song_id,
rating_class=difficulty.rating_class,
)
difficulty_localized.lang = lang.value
difficulty_localized.title = title_localized
difficulty_localized.artist = artist_localized
results.append(difficulty_localized)
return results
def parse_all(self):
return self.parse_songs() + self.parse_difficulties()

View File

@ -0,0 +1,118 @@
"""
Game database play results importer
"""
import logging
import sqlite3
from datetime import datetime, timezone
from typing import List, overload
from arcaea_offline.constants.enums import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from arcaea_offline.database.models.v5 import PlayResult
from .common import fix_timestamp
logger = logging.getLogger(__name__)
class St3Parser:
@classmethod
@overload
def parse(cls, db: sqlite3.Connection) -> List[PlayResult]: ...
@classmethod
@overload
def parse(cls, db: sqlite3.Cursor) -> List[PlayResult]: ...
@classmethod
def parse(cls, db) -> List[PlayResult]:
if isinstance(db, sqlite3.Connection):
return cls.parse(db.cursor())
if not isinstance(db, sqlite3.Cursor):
raise TypeError(
"Unknown overload of `db`. Expected `sqlite3.Connection` or `sqlite3.Cursor`."
)
entities = []
query_results = db.execute("""
SELECT s.id AS _id, s.songId, s.songDifficulty AS ratingClass, s.score,
s.perfectCount AS pure, s.nearCount AS far, s.missCount AS lost,
s.`date`, s.modifier, ct.clearType
FROM scores s JOIN cleartypes ct
ON s.songId = ct.songId AND s.songDifficulty = ct.songDifficulty""")
# maybe `s.id = ct.id`?
now = datetime.now(tz=timezone.utc)
import_comment = (
f"Imported from st3 at {now.astimezone().isoformat(timespec='seconds')}"
)
for result in query_results:
(
_id,
song_id,
rating_class,
score,
pure,
far,
lost,
date,
modifier,
clear_type,
) = result
try:
rating_class_enum = ArcaeaRatingClass(rating_class)
except ValueError:
logger.warning(
"Unknown rating class [%r] at entry id %d, skipping!",
rating_class,
_id,
)
continue
try:
clear_type_enum = ArcaeaPlayResultClearType(clear_type)
except ValueError:
logger.warning(
"Unknown clear type [%r] at entry id %d, falling back to `None`!",
clear_type,
_id,
)
clear_type_enum = None
try:
modifier_enum = ArcaeaPlayResultModifier(modifier)
except ValueError:
logger.warning(
"Unknown modifier [%r] at entry id %d, falling back to `None`!",
modifier,
_id,
)
modifier_enum = None
if date := fix_timestamp(date):
date = datetime.fromtimestamp(date).astimezone()
else:
date = None
entities.append(
PlayResult(
song_id=song_id,
rating_class=rating_class_enum,
score=score,
pure=pure,
far=far,
lost=lost,
date=date,
modifier=modifier_enum,
clear_type=clear_type_enum,
comment=import_comment,
)
)
return entities

View File

@ -1 +1,3 @@
from .b30_csv import SmartRteB30CsvConverter
__all__ = ["SmartRteB30CsvConverter"]

View File

@ -1,2 +1,7 @@
from .play_result import PlayResultFormatter
from .rating_class import RatingClassFormatter
__all__ = [
"PlayResultFormatter",
"RatingClassFormatter",
]

View File

@ -1,27 +1,53 @@
import pytest
from sqlalchemy import create_engine
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# region sqlalchemy fixtures
# from https://medium.com/@vittorio.camisa/agile-database-integration-tests-with-python-sqlalchemy-and-factory-boy-6824e8fe33a1
engine = create_engine("sqlite:///:memory:")
Session = sessionmaker()
@pytest.fixture(scope="module")
@pytest.fixture(scope="session")
def db_conn():
connection = engine.connect()
yield connection
connection.close()
conn = engine.connect()
yield conn
conn.close()
@pytest.fixture(scope="function")
@pytest.fixture()
def db_session(db_conn):
transaction = db_conn.begin()
session = Session(bind=db_conn)
yield session
session.close()
transaction.rollback()
# drop everything
query_tables = db_conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
for row in query_tables:
table_name = row[0]
db_conn.execute(text(f"DROP TABLE {table_name}"))
query_views = db_conn.execute(
text("SELECT name FROM sqlite_master WHERE type='view'")
).fetchall()
for row in query_views:
view_name = row[0]
db_conn.execute(text(f"DROP VIEW {view_name}"))
query_indexes = db_conn.execute(
text("SELECT name FROM sqlite_master WHERE type='index'")
).fetchall()
for row in query_indexes:
index_name = row[0]
db_conn.execute(text(f"DROP INDEX {index_name}"))
query_triggers = db_conn.execute(
text("SELECT name FROM sqlite_master WHERE type='trigger'")
).fetchall()
for row in query_triggers:
trigger_name = row[0]
db_conn.execute(text(f"DROP TRIGGER {trigger_name}"))
# endregion

View File

@ -1,5 +0,0 @@
from sqlalchemy import Engine, create_engine, inspect
def create_engine_in_memory():
return create_engine("sqlite:///:memory:")

View File

@ -41,7 +41,7 @@ class TestCustomTypes:
)
).one()[0]
TestBase.metadata.create_all(db_session.bind)
TestBase.metadata.create_all(db_session.bind, checkfirst=False)
basic_obj = IntEnumTestModel(id=1, value=TestIntEnum.TWO)
null_obj = IntEnumTestModel(id=2, value=None)
@ -53,7 +53,7 @@ class TestCustomTypes:
assert _query_value(2) is None
def test_tz_datetime(self, db_session):
TestBase.metadata.create_all(db_session.bind)
TestBase.metadata.create_all(db_session.bind, checkfirst=False)
dt1 = datetime.now(tz=timezone(timedelta(hours=8)))

View File

@ -1,118 +0,0 @@
from sqlalchemy import Engine
from sqlalchemy.orm import Session
from arcaea_offline.models.songs import (
Chart,
ChartInfo,
Difficulty,
Pack,
Song,
SongsBase,
SongsViewBase,
)
from ..db import create_engine_in_memory
def _song(**kw):
defaults = {"artist": "test"}
defaults.update(kw)
return Song(**defaults)
def _difficulty(**kw):
defaults = {"rating_plus": False, "audio_override": False, "jacket_override": False}
defaults.update(kw)
return Difficulty(**defaults)
class Test_Chart:
def init_db(self, engine: Engine):
SongsBase.metadata.create_all(engine)
SongsViewBase.metadata.create_all(engine)
def db(self):
db = create_engine_in_memory()
self.init_db(db)
return db
def test_chart_info(self):
pre_entites = [
Pack(id="test", name="Test Pack"),
_song(idx=0, id="song0", set="test", title="Full Chart Info"),
_song(idx=1, id="song1", set="test", title="Partial Chart Info"),
_song(idx=2, id="song2", set="test", title="No Chart Info"),
_difficulty(song_id="song0", rating_class=2, rating=9),
_difficulty(song_id="song1", rating_class=2, rating=9),
_difficulty(song_id="song2", rating_class=2, rating=9),
ChartInfo(song_id="song0", rating_class=2, constant=90, notes=1234),
ChartInfo(song_id="song1", rating_class=2, constant=90),
]
db = self.db()
with Session(db) as session:
session.add_all(pre_entites)
session.commit()
chart_song0_ratingclass2 = (
session.query(Chart)
.where((Chart.song_id == "song0") & (Chart.rating_class == 2))
.one()
)
assert chart_song0_ratingclass2.constant == 90
assert chart_song0_ratingclass2.notes == 1234
chart_song1_ratingclass2 = (
session.query(Chart)
.where((Chart.song_id == "song1") & (Chart.rating_class == 2))
.one()
)
assert chart_song1_ratingclass2.constant == 90
assert chart_song1_ratingclass2.notes is None
chart_song2_ratingclass2 = (
session.query(Chart)
.where((Chart.song_id == "song2") & (Chart.rating_class == 2))
.first()
)
assert chart_song2_ratingclass2 is None
def test_difficulty_title_override(self):
pre_entites = [
Pack(id="test", name="Test Pack"),
_song(idx=0, id="test", set="test", title="Test"),
_difficulty(song_id="test", rating_class=0, rating=2),
_difficulty(song_id="test", rating_class=1, rating=5),
_difficulty(song_id="test", rating_class=2, rating=8),
_difficulty(
song_id="test", rating_class=3, rating=10, title="TEST ~REVIVE~"
),
ChartInfo(song_id="test", rating_class=0, constant=10),
ChartInfo(song_id="test", rating_class=1, constant=10),
ChartInfo(song_id="test", rating_class=2, constant=10),
ChartInfo(song_id="test", rating_class=3, constant=10),
]
db = self.db()
with Session(db) as session:
session.add_all(pre_entites)
session.commit()
charts_original_title = (
session.query(Chart)
.where((Chart.song_id == "test") & (Chart.rating_class in [0, 1, 2]))
.all()
)
assert all(chart.title == "Test" for chart in charts_original_title)
chart_overrided_title = (
session.query(Chart)
.where((Chart.song_id == "test") & (Chart.rating_class == 3))
.one()
)
assert chart_overrided_title.title == "TEST ~REVIVE~"

View File

View File

@ -0,0 +1,108 @@
from arcaea_offline.database.models.v4.songs import (
Chart,
ChartInfo,
Difficulty,
Pack,
Song,
SongsBase,
SongsViewBase,
)
def _song(**kw):
defaults = {"artist": "test"}
defaults.update(kw)
return Song(**defaults)
def _difficulty(**kw):
defaults = {"rating_plus": False, "audio_override": False, "jacket_override": False}
defaults.update(kw)
return Difficulty(**defaults)
class Test_Chart:
def init_db(self, session):
SongsBase.metadata.create_all(session.bind, checkfirst=False)
SongsViewBase.metadata.create_all(session.bind, checkfirst=False)
def test_chart_info(self, db_session):
self.init_db(db_session)
pre_entites = [
Pack(id="test", name="Test Pack"),
_song(idx=0, id="song0", set="test", title="Full Chart Info"),
_song(idx=1, id="song1", set="test", title="Partial Chart Info"),
_song(idx=2, id="song2", set="test", title="No Chart Info"),
_difficulty(song_id="song0", rating_class=2, rating=9),
_difficulty(song_id="song1", rating_class=2, rating=9),
_difficulty(song_id="song2", rating_class=2, rating=9),
ChartInfo(song_id="song0", rating_class=2, constant=90, notes=1234),
ChartInfo(song_id="song1", rating_class=2, constant=90),
]
db_session.add_all(pre_entites)
db_session.commit()
chart_song0_ratingclass2 = (
db_session.query(Chart)
.where((Chart.song_id == "song0") & (Chart.rating_class == 2))
.one()
)
assert chart_song0_ratingclass2.constant == 90
assert chart_song0_ratingclass2.notes == 1234
chart_song1_ratingclass2 = (
db_session.query(Chart)
.where((Chart.song_id == "song1") & (Chart.rating_class == 2))
.one()
)
assert chart_song1_ratingclass2.constant == 90
assert chart_song1_ratingclass2.notes is None
chart_song2_ratingclass2 = (
db_session.query(Chart)
.where((Chart.song_id == "song2") & (Chart.rating_class == 2))
.first()
)
assert chart_song2_ratingclass2 is None
def test_difficulty_title_override(self, db_session):
self.init_db(db_session)
pre_entites = [
Pack(id="test", name="Test Pack"),
_song(idx=0, id="test", set="test", title="Test"),
_difficulty(song_id="test", rating_class=0, rating=2),
_difficulty(song_id="test", rating_class=1, rating=5),
_difficulty(song_id="test", rating_class=2, rating=8),
_difficulty(
song_id="test", rating_class=3, rating=10, title="TEST ~REVIVE~"
),
ChartInfo(song_id="test", rating_class=0, constant=10),
ChartInfo(song_id="test", rating_class=1, constant=10),
ChartInfo(song_id="test", rating_class=2, constant=10),
ChartInfo(song_id="test", rating_class=3, constant=10),
]
db_session.add_all(pre_entites)
db_session.commit()
charts_original_title = (
db_session.query(Chart)
.where((Chart.song_id == "test") & (Chart.rating_class in [0, 1, 2]))
.all()
)
assert all(chart.title == "Test" for chart in charts_original_title)
chart_overrided_title = (
db_session.query(Chart)
.where((Chart.song_id == "test") & (Chart.rating_class == 3))
.one()
)
assert chart_overrided_title.title == "TEST ~REVIVE~"

View File

View File

@ -0,0 +1,143 @@
"""
Database model v5 common relationships
┌──────┐ ┌──────┐ ┌────────────┐ ┌────────────┐
│ Pack ◄───► Song ◄───► Difficulty ◄───┤ PlayResult │
└──────┘ └──┬───┘ └─────▲──────┘ └────────────┘
│ │
│ ┌─────▼─────┐
└───────► ChartInfo │
└───────────┘
"""
from arcaea_offline.constants.enums import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import (
ChartInfo,
Difficulty,
ModelsV5Base,
Pack,
PlayResult,
Song,
)
class TestSongRelationships:
@staticmethod
def init_db(session):
ModelsV5Base.metadata.create_all(session.bind)
def test_relationships(self, db_session):
self.init_db(db_session)
song_id = "test_song"
title_en = "Test Lorem Ipsum"
artist_en = "Test Artist"
pack = Pack(
id="test_pack",
name="Test Pack",
description="This is a test pack.",
)
song = Song(
idx=1,
id=song_id,
title=title_en,
artist=artist_en,
pack_id=pack.id,
)
difficulty_pst = Difficulty(
song_id=song.id,
rating_class=ArcaeaRatingClass.PAST,
rating=2,
rating_plus=False,
)
chart_info_pst = ChartInfo(
song_id=song.id,
rating_class=ArcaeaRatingClass.PAST,
constant=20,
notes=200,
)
difficulty_prs = Difficulty(
song_id=song.id,
rating_class=ArcaeaRatingClass.PRESENT,
rating=7,
rating_plus=True,
)
chart_info_prs = ChartInfo(
song_id=song.id,
rating_class=ArcaeaRatingClass.PRESENT,
constant=78,
notes=780,
)
difficulty_ftr = Difficulty(
song_id=song.id,
rating_class=ArcaeaRatingClass.FUTURE,
rating=10,
rating_plus=True,
)
chart_info_ftr = ChartInfo(
song_id=song.id,
rating_class=ArcaeaRatingClass.FUTURE,
constant=109,
notes=1090,
)
difficulty_etr = Difficulty(
song_id=song.id,
rating_class=ArcaeaRatingClass.ETERNAL,
rating=9,
rating_plus=True,
)
play_result_ftr = PlayResult(
song_id=song.id,
rating_class=ArcaeaRatingClass.FUTURE,
score=123456,
)
db_session.add_all(
[
pack,
song,
difficulty_pst,
chart_info_pst,
difficulty_prs,
chart_info_prs,
difficulty_ftr,
chart_info_ftr,
difficulty_etr,
play_result_ftr,
]
)
db_session.commit()
assert pack.songs == [song]
assert song.pack == pack
assert song.difficulties == [
difficulty_pst,
difficulty_prs,
difficulty_ftr,
difficulty_etr,
]
assert song.charts_info == [chart_info_pst, chart_info_prs, chart_info_ftr]
assert difficulty_pst.song == song
assert difficulty_prs.song == song
assert difficulty_ftr.song == song
assert difficulty_etr.song == song
assert difficulty_pst.chart_info == chart_info_pst
assert difficulty_prs.chart_info == chart_info_prs
assert difficulty_ftr.chart_info == chart_info_ftr
assert difficulty_etr.chart_info is None
assert chart_info_pst.difficulty == difficulty_pst
assert chart_info_prs.difficulty == difficulty_prs
assert chart_info_ftr.difficulty == difficulty_ftr
assert play_result_ftr.difficulty == difficulty_ftr

View File

@ -0,0 +1,69 @@
"""
Database model v5 relationships
Pack <> PackLocalized
"""
from arcaea_offline.constants.enums import ArcaeaLanguage
from arcaea_offline.database.models.v5 import ModelsV5Base, Pack, PackLocalized
class TestPackRelationships:
@staticmethod
def init_db(session):
ModelsV5Base.metadata.create_all(session.bind)
def test_localized_objects(self, db_session):
self.init_db(db_session)
pack_id = "test_pack"
name_en = "Test Pack"
description_en = "Travel through common database models\nfrom the unpopular framework 'Arcaea Offline'\ntogether with an ordinary partner ''."
pack = Pack(
id=pack_id,
name=name_en,
description=description_en,
)
pkid_ja = 1
description_ja = "普通のパートナー「∅」と一緒に、\n不人気フレームワーク「Arcaea Offline」より、\n一般的なデータベース・モデルを通過する。"
pack_localized_ja = PackLocalized(
pkid=pkid_ja,
id=pack_id,
lang=ArcaeaLanguage.JA.value,
name=None,
description=description_ja,
)
pkid_zh_hans = 2
description_zh_hans = "与平凡的「∅」一起,\n在没人用的「Arcaea Offline」框架里\n一同探索随处可见的数据库模型。"
pack_localized_zh_hans = PackLocalized(
pkid=pkid_zh_hans,
id=pack_id,
lang=ArcaeaLanguage.ZH_HANS.value,
name=None,
description=description_zh_hans,
)
db_session.add_all([pack, pack_localized_ja])
db_session.commit()
assert len(pack.localized_objects) == len([pack_localized_ja])
assert pack_localized_ja.parent.description == pack.description
# relationships should be viewonly
new_pack = Pack(
id=f"{pack_id}_new",
name="NEW",
description="new new pack",
)
db_session.add(new_pack)
pack_localized_ja.parent = new_pack
pack.localized_objects.append(pack_localized_zh_hans)
db_session.commit()
assert pack_localized_ja.parent == pack
assert len(pack.localized_objects) == 1

View File

@ -0,0 +1,110 @@
"""
Database models v5
Chart functionalities
- basic data handling
- Difficulty song info overriding
"""
from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass
from arcaea_offline.database.models.v5 import (
Chart,
ChartInfo,
Difficulty,
ModelsV5Base,
ModelsV5ViewBase,
Pack,
Song,
)
class TestChart:
def init_db(self, session):
ModelsV5Base.metadata.create_all(session.bind)
ModelsV5ViewBase.metadata.create_all(session.bind)
def test_basic(self, db_session):
self.init_db(db_session)
pack_id = "test_pack"
song_id = "test_song"
rating_class = ArcaeaRatingClass.FUTURE
pack = Pack(id=pack_id, name="Test Pack")
song = Song(
idx=2,
id=song_id,
title="~TEST~",
artist="~test~",
pack_id=pack_id,
)
difficulty = Difficulty(
song_id=song_id,
rating_class=rating_class,
rating=9,
rating_plus=True,
)
chart_info = ChartInfo(
song_id=song_id,
rating_class=rating_class,
constant=98,
notes=980,
)
db_session.add_all([pack, song, difficulty, chart_info])
chart: Chart = (
db_session.query(Chart)
.where((Chart.song_id == song_id) & (Chart.rating_class == rating_class))
.one()
)
# `song_id` and `rating_class` are guarded by the WHERE clause above
assert chart.song_idx == song.idx
assert chart.title == song.title
assert chart.artist == song.artist
assert chart.pack_id == song.pack_id
assert chart.rating == difficulty.rating
assert chart.rating_plus == difficulty.rating_plus
assert chart.constant == chart_info.constant
assert chart.notes == chart_info.notes
def test_difficulty_override(self, db_session):
self.init_db(db_session)
pack_id = "test_pack"
song_id = "test_song"
rating_class = ArcaeaRatingClass.FUTURE
pack = Pack(id=pack_id, name="Test Pack")
song = Song(
idx=2,
id=song_id,
title="~TEST~",
artist="~test~",
pack_id=pack_id,
)
difficulty = Difficulty(
song_id=song_id,
rating_class=rating_class,
rating=9,
rating_plus=True,
title="~TEST DIFF~",
artist="~diff~",
)
chart_info = ChartInfo(
song_id=song_id,
rating_class=rating_class,
constant=98,
notes=980,
)
db_session.add_all([pack, song, difficulty, chart_info])
chart: Chart = (
db_session.query(Chart)
.where((Chart.song_id == song_id) & (Chart.rating_class == rating_class))
.one()
)
assert chart.song_idx == song.idx
assert chart.title == difficulty.title
assert chart.artist == difficulty.artist

View File

@ -0,0 +1,55 @@
import sqlite3
from datetime import datetime
import pytest
from arcaea_offline.constants.enums.arcaea import (
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
)
from arcaea_offline.external.importers.arcaea.st3 import St3Parser
import tests.resources
class TestSt3Parser:
DB_PATH = tests.resources.get_resource("st3-test.db")
@property
def play_results(self):
conn = sqlite3.connect(str(self.DB_PATH))
return St3Parser.parse(conn)
def test_basic(self):
play_results = self.play_results
assert len(play_results) == 4
test1 = next(filter(lambda x: x.song_id == "test1", play_results))
assert test1.rating_class is ArcaeaRatingClass.FUTURE
assert test1.score == 9441167
assert test1.pure == 895
assert test1.far == 32
assert test1.lost == 22
assert test1.date == datetime.fromtimestamp(1722100000).astimezone()
assert test1.clear_type is ArcaeaPlayResultClearType.TRACK_LOST
assert test1.modifier is ArcaeaPlayResultModifier.HARD
def test_corrupt_handling(self):
play_results = self.play_results
corrupt1 = filter(lambda x: x.song_id == "corrupt1", play_results)
# `rating_class` out of range, so this should be ignored during parsing,
# thus does not present in the result.
assert len(list(corrupt1)) == 0
corrupt2 = next(filter(lambda x: x.song_id == "corrupt2", play_results))
assert corrupt2.clear_type is None
assert corrupt2.modifier is None
date1 = next(filter(lambda x: x.song_id == "date1", play_results))
assert date1.date is None
def test_invalid_input(self):
pytest.raises(TypeError, St3Parser.parse, "abcdefghijklmn")
pytest.raises(TypeError, St3Parser.parse, 123456)

View File

@ -0,0 +1,16 @@
import importlib.resources
import sys
def get_resource(path: str):
"""
A wrapper for `importlib.resources.files()` since it's not available in Python 3.8.
"""
if sys.version_info >= (3, 9, 0):
with importlib.resources.as_file(
importlib.resources.files(__name__).joinpath(path)
) as resource_path:
return resource_path
with importlib.resources.path(__name__, path) as resource_path:
return resource_path

BIN
tests/resources/st3-test.db Normal file

Binary file not shown.

View File

@ -0,0 +1,117 @@
{
"_comment": "A quick preview of the data in st3-test.db",
"scores": [
{
"id": 1,
"version": 1,
"score": 9441167,
"shinyPerfectCount": 753,
"perfectCount": 895,
"nearCount": 32,
"missCount": 22,
"date": 1722100000,
"songId": "test1",
"songDifficulty": 2,
"modifier": 2,
"health": 0,
"ct": 0
},
{
"id": 2,
"version": 1,
"score": 9752087,
"shinyPerfectCount": 914,
"perfectCount": 1024,
"nearCount": 29,
"missCount": 12,
"date": 1722200000,
"songId": "test2",
"songDifficulty": 2,
"modifier": 0,
"health": 100,
"ct": 0
},
{
"id": 3,
"version": 1,
"score": 9750000,
"shinyPerfectCount": 900,
"perfectCount": 1000,
"nearCount": 20,
"missCount": 10,
"date": 1722200000,
"songId": "corrupt1",
"songDifficulty": 5,
"modifier": 0,
"health": 0,
"ct": 0
},
{
"id": 4,
"version": 1,
"score": 9750000,
"shinyPerfectCount": 900,
"perfectCount": 1000,
"nearCount": 20,
"missCount": 10,
"date": 1722200000,
"songId": "corrupt2",
"songDifficulty": 2,
"modifier": 9,
"health": 0,
"ct": 0
},
{
"id": 5,
"version": 1,
"score": 9750000,
"shinyPerfectCount": 900,
"perfectCount": 1000,
"nearCount": 20,
"missCount": 10,
"date": 1,
"songId": "date1",
"songDifficulty": 2,
"modifier": 0,
"health": 0,
"ct": 0
}
],
"cleartypes": [
{
"id": 1,
"songId": "test1",
"songDifficulty": 2,
"clearType": 0,
"ct": 0
},
{
"id": 2,
"songId": "test2",
"songDifficulty": 2,
"clearType": 1,
"ct": 0
},
{
"id": 3,
"songId": "corrupt1",
"songDifficulty": 5,
"clearType": 0,
"ct": 0
},
{
"id": 4,
"songId": "corrupt2",
"songDifficulty": 2,
"clearType": 7,
"ct": 0
},
{
"id": 5,
"songId": "date1",
"songDifficulty": 2,
"clearType": 1,
"ct": 0
}
]
}

View File

@ -13,4 +13,4 @@ wheel_build_env = .pkg
deps =
pytest==7.4.3
commands =
pytest {tty:--color=yes} {posargs}
python -m pytest {tty:--color=yes} {posargs}